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
244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { validRange } from 'semver';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { extractModuleSpecifiers } from '../scripts/architectureUtils';
|
|
|
|
type PackageManifest = {
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
optionalDependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
};
|
|
|
|
function readPackageJson<T>(relativePath: string): T {
|
|
const packageJsonPath = path.join(process.cwd(), relativePath);
|
|
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as T;
|
|
}
|
|
|
|
const SOURCE_FILE_EXTENSIONS = /\.(ts|tsx|mts|cts|js|mjs|cjs)$/;
|
|
const EXPECTED_SHARP_VERSION = '^0.35.1';
|
|
const OPENAI_PACKAGE_NAMES = ['@openai/agents', '@openai/codex-sdk', 'openai'] as const;
|
|
const TYPESCRIPT_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
|
|
|
function collectSourceFiles(rootDir: string, excluded: Set<string>): string[] {
|
|
const results: string[] = [];
|
|
const walk = (dir: string) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (excluded.has(full) || entry.name === 'node_modules') {
|
|
continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
walk(full);
|
|
} else if (SOURCE_FILE_EXTENSIONS.test(entry.name)) {
|
|
results.push(full);
|
|
}
|
|
}
|
|
};
|
|
walk(rootDir);
|
|
return results;
|
|
}
|
|
|
|
function collectPackageJsonFiles(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 full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(full);
|
|
} else if (entry.name === 'package.json') {
|
|
results.push(full);
|
|
}
|
|
}
|
|
};
|
|
|
|
walk(rootDir);
|
|
return results.sort();
|
|
}
|
|
|
|
function getDependencyRange(
|
|
packageJson: PackageManifest,
|
|
dependencyName: (typeof OPENAI_PACKAGE_NAMES)[number],
|
|
): string | undefined {
|
|
return (
|
|
packageJson.dependencies?.[dependencyName] ??
|
|
packageJson.devDependencies?.[dependencyName] ??
|
|
packageJson.optionalDependencies?.[dependencyName] ??
|
|
packageJson.peerDependencies?.[dependencyName]
|
|
);
|
|
}
|
|
|
|
function findExtensionUnsafeRelativeSpecifiers(sourceText: string, filePath: string): string[] {
|
|
return extractModuleSpecifiers(sourceText, filePath).filter((specifier) => {
|
|
if (!specifier.startsWith('.')) {
|
|
return false;
|
|
}
|
|
|
|
const extension = path.posix.extname(specifier);
|
|
return !extension || TYPESCRIPT_SOURCE_EXTENSIONS.has(extension);
|
|
});
|
|
}
|
|
|
|
describe('package manifests', () => {
|
|
it('publishes the lightweight contracts subpath', () => {
|
|
const packageJson = readPackageJson<{
|
|
exports?: Record<string, unknown>;
|
|
typesVersions?: Record<string, Record<string, string[]>>;
|
|
}>('package.json');
|
|
|
|
expect(packageJson.exports?.['./contracts']).toEqual({
|
|
import: {
|
|
types: './dist/src/contracts.d.ts',
|
|
default: './dist/src/contracts.js',
|
|
},
|
|
require: {
|
|
types: './dist/src/contracts.d.cts',
|
|
default: './dist/src/contracts.cjs',
|
|
},
|
|
});
|
|
expect(packageJson.typesVersions?.['*']?.contracts).toEqual(['dist/src/contracts.d.ts']);
|
|
});
|
|
|
|
it('keeps the contracts subpath extension-safe for emitted ESM', () => {
|
|
const contractsDir = path.join(process.cwd(), 'src', 'contracts');
|
|
const files = [
|
|
path.join(process.cwd(), 'src', 'contracts.ts'),
|
|
...collectSourceFiles(contractsDir, new Set()),
|
|
];
|
|
const offenders = files.flatMap((file) => {
|
|
const contents = fs.readFileSync(file, 'utf8');
|
|
return findExtensionUnsafeRelativeSpecifiers(contents, file).map(
|
|
(specifier) => `${path.relative(process.cwd(), file)}: ${specifier}`,
|
|
);
|
|
});
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('detects extension-unsafe relative specifiers across module syntax', () => {
|
|
// The contracts files are dominated by type-only imports/exports, so the detector that the
|
|
// extension-safety guard relies on MUST catch those forms, not just runtime imports.
|
|
expect(
|
|
findExtensionUnsafeRelativeSpecifiers(
|
|
`
|
|
import './side-effect';
|
|
export { value } from './exported';
|
|
import('./dynamic');
|
|
import type { A } from './type-import';
|
|
export type { B } from './type-export';
|
|
import { type C, D } from './inline-type';
|
|
import type Default from './default-type';
|
|
export { schema } from './schema.json';
|
|
`,
|
|
'fixture.ts',
|
|
),
|
|
).toEqual([
|
|
'./side-effect',
|
|
'./exported',
|
|
'./dynamic',
|
|
'./type-import',
|
|
'./type-export',
|
|
'./inline-type',
|
|
'./default-type',
|
|
]);
|
|
});
|
|
|
|
it('pins root TypeScript compilation to noEmit', () => {
|
|
const tsconfig = readPackageJson<{
|
|
compilerOptions?: {
|
|
noEmit?: boolean;
|
|
};
|
|
}>('tsconfig.json');
|
|
|
|
expect(tsconfig.compilerOptions?.noEmit).toBe(true);
|
|
});
|
|
|
|
it('keeps sharp out of the root install path', () => {
|
|
const packageJson = readPackageJson<{
|
|
devDependencies?: Record<string, string>;
|
|
optionalDependencies?: Record<string, string>;
|
|
}>('package.json');
|
|
|
|
expect(packageJson.devDependencies?.sharp).toBeUndefined();
|
|
expect(packageJson.optionalDependencies?.sharp).toBe(EXPECTED_SHARP_VERSION);
|
|
});
|
|
|
|
it('keeps jsdom out of root runtime dependencies', () => {
|
|
const packageJson = readPackageJson<{
|
|
dependencies?: Record<string, string>;
|
|
}>('package.json');
|
|
|
|
// The intent of this guard is "jsdom must stay gone and parse5 must exist
|
|
// as its replacement" — not "parse5 must be exactly version X". Accept any
|
|
// semver range so future major bumps don't re-fail this regression test.
|
|
const parse5Range = packageJson.dependencies?.parse5;
|
|
expect(packageJson.dependencies?.jsdom).toBeUndefined();
|
|
expect(parse5Range).toBeDefined();
|
|
expect(validRange(parse5Range as string)).not.toBeNull();
|
|
});
|
|
|
|
it('does not import jsdom from root src/', () => {
|
|
// Guards against re-introducing jsdom into the CLI startup graph, which
|
|
// previously broke `npx promptfoo` on Node 24 via ERR_REQUIRE_ASYNC_MODULE.
|
|
// The src/app workspace is excluded because it legitimately uses jsdom
|
|
// as a browser test environment.
|
|
const srcDir = path.join(process.cwd(), 'src');
|
|
const files = collectSourceFiles(srcDir, new Set([path.join(srcDir, 'app')]));
|
|
// Match static `from 'jsdom'`, CJS `require('jsdom')`, and dynamic
|
|
// `import('jsdom')` — including whitespace around the parenthesis.
|
|
const jsdomImportPattern = /(?:\bfrom|\brequire\s*\(|\bimport\s*\()\s*['"]jsdom['"]/;
|
|
const offenders = files.filter((file) =>
|
|
jsdomImportPattern.test(fs.readFileSync(file, 'utf8')),
|
|
);
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('keeps sharp optional for the docs workspace', () => {
|
|
const sitePackageJson = readPackageJson<{
|
|
devDependencies?: Record<string, string>;
|
|
optionalDependencies?: Record<string, string>;
|
|
}>('site/package.json');
|
|
|
|
expect(sitePackageJson.devDependencies?.sharp).toBeUndefined();
|
|
expect(sitePackageJson.optionalDependencies?.sharp).toBe(EXPECTED_SHARP_VERSION);
|
|
});
|
|
|
|
it('keeps OpenAI example dependency ranges aligned with the root manifest', () => {
|
|
const rootPackageJson = readPackageJson<PackageManifest>('package.json');
|
|
const rootRanges = new Map(
|
|
OPENAI_PACKAGE_NAMES.map((dependencyName) => [
|
|
dependencyName,
|
|
getDependencyRange(rootPackageJson, dependencyName),
|
|
]),
|
|
);
|
|
const examplesDir = path.join(process.cwd(), 'examples');
|
|
const mismatches = collectPackageJsonFiles(examplesDir).flatMap((packageJsonPath) => {
|
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageManifest;
|
|
const relativePath = path.relative(process.cwd(), packageJsonPath);
|
|
|
|
return OPENAI_PACKAGE_NAMES.flatMap((dependencyName) => {
|
|
const exampleRange = getDependencyRange(packageJson, dependencyName);
|
|
if (!exampleRange) {
|
|
return [];
|
|
}
|
|
|
|
const rootRange = rootRanges.get(dependencyName);
|
|
if (exampleRange === rootRange) {
|
|
return [];
|
|
}
|
|
|
|
return [`${relativePath}: ${dependencyName}=${exampleRange} (root: ${rootRange})`];
|
|
});
|
|
});
|
|
|
|
expect(mismatches).toEqual([]);
|
|
});
|
|
});
|