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
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

545 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockProcessEnv } from './util/utils';
// Create mock for exec - using vi.hoisted to ensure it's available in vi.mock factory
const { mockExecAsync } = vi.hoisted(() => {
const mockExecAsync = vi.fn();
return { mockExecAsync };
});
// Mock child_process.exec with custom promisify symbol
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
const mockExec = Object.assign(vi.fn(), {
[Symbol.for('nodejs.util.promisify.custom')]: mockExecAsync,
});
return {
...actual,
exec: mockExec,
};
});
vi.mock('../src/util/fetch/index.ts', () => ({
fetchWithTimeout: vi.fn(),
}));
vi.mock('../src/envars', () => ({
getEnvBool: (key: string) => {
const value = process.env[key]?.toLowerCase();
return value === '1' || value === 'true';
},
getEnvInt: (key: string, defaultValue?: number) => {
const value = process.env[key];
return value === undefined ? defaultValue : Number.parseInt(value, 10);
},
getEnvString: (key: string, defaultValue?: string) => process.env[key] ?? defaultValue,
}));
vi.mock('../src/logger', () => ({
default: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('../src/version', () => ({
VERSION: '0.11.0',
POSTHOG_KEY: '',
ENGINES: { node: '>=20.0.0' },
}));
import logger from '../src/logger';
import {
checkForUpdates,
checkModelAuditUpdates,
getLatestVersion,
getModelAuditCurrentVersion,
getModelAuditLatestVersion,
} from '../src/updates';
import { getUpdateCommands } from '../src/updates/updateCommands';
import { fetchWithTimeout } from '../src/util/fetch/index';
import { VERSION } from '../src/version';
beforeEach(() => {
vi.mocked(fetchWithTimeout).mockReset();
mockExecAsync.mockReset();
mockExecAsync.mockResolvedValue({
stdout: 'modelaudit, version 0.0.0',
stderr: '',
});
});
describe('getUpdateCommands', () => {
it('separates official images, custom containers, and package installs', () => {
expect(
getUpdateCommands({ isContainer: false, isOfficialDockerImage: true, isNpx: false }),
).toEqual({
primary: 'docker pull ghcr.io/promptfoo/promptfoo:latest',
alternative: null,
commandType: 'docker',
});
expect(
getUpdateCommands({ isContainer: true, isOfficialDockerImage: false, isNpx: false }),
).toEqual({
primary: '',
alternative: null,
commandType: 'npm',
isCustomContainer: true,
});
expect(
getUpdateCommands({ isContainer: false, isOfficialDockerImage: false, isNpx: true }),
).toEqual({
primary: 'npx promptfoo@latest',
alternative: 'npm install -g promptfoo@latest',
commandType: 'npx',
});
expect(
getUpdateCommands({ isContainer: false, isOfficialDockerImage: false, isNpx: false }),
).toEqual({
primary: 'npm install -g promptfoo@latest',
alternative: 'npx promptfoo@latest',
commandType: 'npm',
});
});
});
describe('getLatestVersion', () => {
it('should return the latest version of the package', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
const latestVersion = await getLatestVersion();
expect(latestVersion).toBe('1.1.0');
});
it('should throw an error if the response is not ok', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: false,
} as never);
await expect(getLatestVersion()).rejects.toThrow(
'Failed to fetch package information for promptfoo',
);
});
});
describe('checkForUpdates', () => {
let loggerInfoSpy: ReturnType<typeof vi.spyOn>;
let loggerWarnSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let restoreEnv: () => void;
beforeEach(() => {
// Reset fetchWithTimeout to clear any queued mockResolvedValueOnce from other tests
vi.mocked(fetchWithTimeout).mockReset();
restoreEnv = mockProcessEnv({ PROMPTFOO_DISABLE_UPDATE: undefined });
loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
loggerWarnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
loggerInfoSpy.mockRestore();
loggerWarnSpy.mockRestore();
consoleWarnSpy.mockRestore();
restoreEnv();
});
it('should skip the CLI update check when PROMPTFOO_DISABLE_UPDATE is set', async () => {
const restoreDisableUpdate = mockProcessEnv({ PROMPTFOO_DISABLE_UPDATE: 'true' });
try {
expect(await checkForUpdates()).toBe(false);
expect(fetchWithTimeout).not.toHaveBeenCalled();
expect(loggerInfoSpy).not.toHaveBeenCalled();
expect(loggerWarnSpy).not.toHaveBeenCalled();
} finally {
restoreDisableUpdate();
}
});
it('should log an update message if a newer version is available - minor ver', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
const result = await checkForUpdates();
expect(result).toBeTruthy();
});
it('should log an update message if a newer version is available - major ver', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
const result = await checkForUpdates();
expect(result).toBeTruthy();
});
it('should not log an update message if the current version is up to date', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: VERSION }),
} as never);
const result = await checkForUpdates();
expect(result).toBeFalsy();
});
it('should tell Node.js 20 users to upgrade Node before installing latest after cutoff', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
const result = await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-30T00:00:00.000Z'),
});
expect(result).toBe(true);
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Upgrade to Node.js 22.22.0 or newer'),
);
expect(loggerInfoSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should keep normal update guidance for Node.js 20 before cutoff', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
const result = await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-29T23:59:59.999Z'),
});
expect(result).toBe(true);
expect(loggerInfoSpy).toHaveBeenCalledWith(expect.stringContaining('npx promptfoo@latest'));
expect(loggerWarnSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should preserve official-image pull guidance before and after the cutoff', async () => {
const restoreDocker = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: 'true',
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
});
vi.mocked(fetchWithTimeout)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
try {
await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-29T23:59:59.999Z'),
});
await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-30T00:00:00.000Z'),
});
} finally {
restoreDocker();
}
expect(loggerInfoSpy).toHaveBeenCalledTimes(2);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
1,
expect.stringContaining('docker pull ghcr.io/promptfoo/promptfoo:latest'),
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
expect.stringContaining('docker pull ghcr.io/promptfoo/promptfoo:latest'),
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
expect.stringContaining(
'If this is a derived image, update its Promptfoo base and rebuild it',
),
);
expect(loggerWarnSpy).not.toHaveBeenCalled();
});
it('should keep package update safeguards in generic self-hosted mode', async () => {
const restoreSelfHosted = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
PROMPTFOO_RUNNING_IN_DOCKER: undefined,
PROMPTFOO_SELF_HOSTED: 'true',
});
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
try {
await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-30T00:00:00.000Z'),
});
} finally {
restoreSelfHosted();
}
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Upgrade to Node.js 22.22.0 or newer'),
);
expect(loggerInfoSpy).not.toHaveBeenCalled();
});
it('should require a source update before rebuilding a custom container', async () => {
const restoreContainer = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
});
vi.mocked(fetchWithTimeout)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
try {
await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-29T23:59:59.999Z'),
});
await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-30T00:00:00.000Z'),
});
} finally {
restoreContainer();
}
expect(loggerInfoSpy).toHaveBeenCalledOnce();
expect(loggerInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('Update the Promptfoo source, dependency, or parent image'),
);
expect(loggerInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('npx promptfoo'));
expect(loggerInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('ghcr.io/promptfoo'));
expect(loggerWarnSpy).toHaveBeenCalledOnce();
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('and use Node.js 22.22.0 or newer (24 LTS recommended)'),
);
});
it('should let cadence suppress duplicate custom-container cutoff guidance', async () => {
const restoreContainer = mockProcessEnv({
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
});
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
try {
expect(
await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-30T00:00:00.000Z'),
suppressRuntimeBlockedWarning: true,
}),
).toBe(true);
} finally {
restoreContainer();
}
expect(loggerInfoSpy).not.toHaveBeenCalled();
expect(loggerWarnSpy).not.toHaveBeenCalled();
});
it('should let the runtime campaign suppress duplicate post-cutoff guidance', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ latestVersion: '1.1.0' }),
} as never);
const result = await checkForUpdates({
currentNodeVersion: 'v20.20.2',
now: new Date('2026-07-30T00:00:00.000Z'),
suppressRuntimeBlockedWarning: true,
});
expect(result).toBe(true);
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(loggerInfoSpy).not.toHaveBeenCalled();
expect(loggerWarnSpy).not.toHaveBeenCalled();
});
});
describe('getModelAuditLatestVersion', () => {
it('should return the latest version from PyPI', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ info: { version: '0.1.7' } }),
} as never);
const version = await getModelAuditLatestVersion();
expect(version).toBe('0.1.7');
expect(fetchWithTimeout).toHaveBeenCalledWith(
'https://pypi.org/pypi/modelaudit/json',
{ headers: { 'x-promptfoo-silent': 'true' } },
10000,
);
});
it('should return null if PyPI request fails', async () => {
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: false,
} as never);
const version = await getModelAuditLatestVersion();
expect(version).toBeNull();
});
it('should return null if fetch throws', async () => {
vi.mocked(fetchWithTimeout).mockRejectedValueOnce(new Error('Network error'));
const version = await getModelAuditLatestVersion();
expect(version).toBeNull();
});
});
describe('getModelAuditCurrentVersion', () => {
it('should return the current version from modelaudit --version', async () => {
mockExecAsync.mockResolvedValueOnce({
stdout: 'modelaudit, version 0.1.5',
stderr: '',
});
const version = await getModelAuditCurrentVersion();
expect(version).toBe('0.1.5');
});
it('should return null if modelaudit --version fails', async () => {
mockExecAsync.mockRejectedValueOnce(new Error('Command failed'));
const version = await getModelAuditCurrentVersion();
expect(version).toBeNull();
});
it('should return null if version pattern not found', async () => {
mockExecAsync.mockResolvedValueOnce({
stdout: 'some invalid output',
stderr: '',
});
const version = await getModelAuditCurrentVersion();
expect(version).toBeNull();
});
});
describe('checkModelAuditUpdates', () => {
let loggerInfoSpy: ReturnType<typeof vi.spyOn>;
let restoreEnv: () => void;
beforeEach(() => {
loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
restoreEnv = mockProcessEnv({ PROMPTFOO_DISABLE_UPDATE: undefined });
});
afterEach(() => {
loggerInfoSpy.mockRestore();
restoreEnv();
});
it('should return true and log message when update is available', async () => {
mockExecAsync.mockResolvedValueOnce({
stdout: 'modelaudit, version 0.1.5',
stderr: '',
});
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ info: { version: '0.1.7' } }),
} as never);
const result = await checkModelAuditUpdates();
expect(result).toBeTruthy();
});
it('should return false when versions are equal', async () => {
mockExecAsync.mockResolvedValueOnce({
stdout: 'modelaudit, version 0.1.7',
stderr: '',
});
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ info: { version: '0.1.7' } }),
} as never);
const result = await checkModelAuditUpdates();
expect(result).toBeFalsy();
});
it('should return false when current version is newer', async () => {
mockExecAsync.mockResolvedValueOnce({
stdout: 'modelaudit, version 0.2.0',
stderr: '',
});
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ info: { version: '0.1.7' } }),
} as never);
const result = await checkModelAuditUpdates();
expect(result).toBeFalsy();
});
it('should return false when PROMPTFOO_DISABLE_UPDATE is set', async () => {
const restoreDisableUpdate = mockProcessEnv({ PROMPTFOO_DISABLE_UPDATE: 'true' });
try {
vi.mocked(fetchWithTimeout).mockReset();
const result = await checkModelAuditUpdates();
expect(result).toBeFalsy();
expect(fetchWithTimeout).not.toHaveBeenCalled();
} finally {
restoreDisableUpdate();
}
});
it('should return false when current version cannot be determined', async () => {
mockExecAsync.mockRejectedValueOnce(new Error('Command failed'));
vi.mocked(fetchWithTimeout).mockResolvedValueOnce({
ok: true,
json: async () => ({ info: { version: '0.1.7' } }),
} as never);
const result = await checkModelAuditUpdates();
expect(result).toBeFalsy();
});
it('should return false when latest version cannot be determined', async () => {
mockExecAsync.mockResolvedValueOnce({
stdout: 'modelaudit, version 0.1.5',
stderr: '',
});
vi.mocked(fetchWithTimeout).mockRejectedValueOnce(new Error('Network error'));
const result = await checkModelAuditUpdates();
expect(result).toBeFalsy();
});
});