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
266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getShareAuthorizedBlob } from '../../src/blobs';
|
|
import { uploadBlobRemote } from '../../src/blobs/remoteUpload';
|
|
import { createRemoteBlobUploadCache, uploadBlobRefsForShare } from '../../src/blobs/shareUpload';
|
|
import logger from '../../src/logger';
|
|
|
|
import type { StoredBlob } from '../../src/blobs';
|
|
|
|
vi.mock('../../src/blobs', () => ({
|
|
getShareAuthorizedBlob: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/blobs/remoteUpload', () => ({
|
|
uploadBlobRemote: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/logger', () => ({
|
|
default: {
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const storedBlob: StoredBlob = {
|
|
data: Buffer.from('image-bytes'),
|
|
metadata: {
|
|
createdAt: '2026-06-08T00:00:00.000Z',
|
|
key: 'blob-key',
|
|
mimeType: 'image/png',
|
|
provider: 'filesystem',
|
|
sizeBytes: 11,
|
|
},
|
|
};
|
|
|
|
describe('share-time blob upload', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(getShareAuthorizedBlob).mockResolvedValue(storedBlob);
|
|
vi.mocked(uploadBlobRemote).mockResolvedValue({
|
|
deduplicated: false,
|
|
ref: {
|
|
hash: 'a'.repeat(64),
|
|
mimeType: 'image/png',
|
|
provider: 'cloud',
|
|
sizeBytes: 11,
|
|
uri: `promptfoo://blob/${'a'.repeat(64)}`,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('uploads each referenced blob only once when the share context is unchanged', async () => {
|
|
const hash = 'a'.repeat(64);
|
|
const cache = createRemoteBlobUploadCache();
|
|
|
|
await uploadBlobRefsForShare(
|
|
{
|
|
output: `promptfoo://blob/${hash}`,
|
|
images: [{ blobRef: { hash, uri: `promptfoo://blob/${hash}` } }],
|
|
},
|
|
cache,
|
|
{ localEvalId: 'local-eval-123', remoteEvalId: 'remote-eval-123', promptIdx: 2, testIdx: 1 },
|
|
);
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, {
|
|
localEvalId: 'local-eval-123',
|
|
remoteEvalId: 'remote-eval-123',
|
|
promptIdx: 2,
|
|
testIdx: 1,
|
|
});
|
|
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledOnce();
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledWith(hash, 'local-eval-123');
|
|
expect(uploadBlobRemote).toHaveBeenCalledOnce();
|
|
expect(uploadBlobRemote).toHaveBeenCalledWith(Buffer.from('image-bytes'), 'image/png', {
|
|
evalId: 'remote-eval-123',
|
|
kind: 'image',
|
|
location: 'share',
|
|
promptIdx: 2,
|
|
testIdx: 1,
|
|
});
|
|
});
|
|
|
|
it('uploads the same blob again when it appears in a different result row', async () => {
|
|
const hash = '7'.repeat(64);
|
|
const cache = createRemoteBlobUploadCache();
|
|
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, {
|
|
localEvalId: 'local-eval-123',
|
|
remoteEvalId: 'remote-eval-123',
|
|
promptIdx: 2,
|
|
testIdx: 1,
|
|
});
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, {
|
|
localEvalId: 'local-eval-123',
|
|
remoteEvalId: 'remote-eval-123',
|
|
promptIdx: 4,
|
|
testIdx: 3,
|
|
});
|
|
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledTimes(2);
|
|
expect(getShareAuthorizedBlob).toHaveBeenNthCalledWith(1, hash, 'local-eval-123');
|
|
expect(getShareAuthorizedBlob).toHaveBeenNthCalledWith(2, hash, 'local-eval-123');
|
|
expect(uploadBlobRemote).toHaveBeenCalledTimes(2);
|
|
expect(uploadBlobRemote).toHaveBeenNthCalledWith(1, Buffer.from('image-bytes'), 'image/png', {
|
|
evalId: 'remote-eval-123',
|
|
kind: 'image',
|
|
location: 'share',
|
|
promptIdx: 2,
|
|
testIdx: 1,
|
|
});
|
|
expect(uploadBlobRemote).toHaveBeenNthCalledWith(2, Buffer.from('image-bytes'), 'image/png', {
|
|
evalId: 'remote-eval-123',
|
|
kind: 'image',
|
|
location: 'share',
|
|
promptIdx: 4,
|
|
testIdx: 3,
|
|
});
|
|
});
|
|
|
|
it('keeps a zero-index result row distinct from a coordinate-less reference', async () => {
|
|
// Pins the `?? null` (not `||`) choice in getUploadCacheKey: promptIdx/testIdx
|
|
// of 0 is the first row of every eval and must not collapse into a reference
|
|
// that carries no coordinates. A regression to `|| null` would dedupe these two
|
|
// into a single upload, dropping the (0, 0) row's provenance.
|
|
const hash = '0'.repeat(64);
|
|
const cache = createRemoteBlobUploadCache();
|
|
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, {
|
|
localEvalId: 'local-eval-zero',
|
|
remoteEvalId: 'remote-eval-zero',
|
|
promptIdx: 0,
|
|
testIdx: 0,
|
|
});
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, {
|
|
localEvalId: 'local-eval-zero',
|
|
remoteEvalId: 'remote-eval-zero',
|
|
});
|
|
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledTimes(2);
|
|
expect(uploadBlobRemote).toHaveBeenCalledTimes(2);
|
|
expect(uploadBlobRemote).toHaveBeenNthCalledWith(1, Buffer.from('image-bytes'), 'image/png', {
|
|
evalId: 'remote-eval-zero',
|
|
kind: 'image',
|
|
location: 'share',
|
|
promptIdx: 0,
|
|
testIdx: 0,
|
|
});
|
|
expect(uploadBlobRemote).toHaveBeenNthCalledWith(2, Buffer.from('image-bytes'), 'image/png', {
|
|
evalId: 'remote-eval-zero',
|
|
kind: 'image',
|
|
location: 'share',
|
|
promptIdx: undefined,
|
|
testIdx: undefined,
|
|
});
|
|
});
|
|
|
|
it('does not upload a copied blob URI that is not share-authorized for the eval', async () => {
|
|
const hash = 'd'.repeat(64);
|
|
vi.mocked(getShareAuthorizedBlob).mockResolvedValue(null);
|
|
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, createRemoteBlobUploadCache(), {
|
|
localEvalId: 'local-eval-unauthorized',
|
|
remoteEvalId: 'remote-eval-unauthorized',
|
|
});
|
|
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledWith(hash, 'local-eval-unauthorized');
|
|
expect(uploadBlobRemote).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs upload failures without failing the share', async () => {
|
|
const hash = 'b'.repeat(64);
|
|
vi.mocked(uploadBlobRemote).mockRejectedValue(new Error('network unavailable'));
|
|
|
|
await expect(
|
|
uploadBlobRefsForShare(`promptfoo://blob/${hash}`, createRemoteBlobUploadCache(), {
|
|
localEvalId: 'local-eval-456',
|
|
remoteEvalId: 'remote-eval-456',
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[Share] Failed to upload referenced blob; shared media may be unavailable',
|
|
expect.objectContaining({
|
|
error: 'network unavailable',
|
|
evalId: 'remote-eval-456',
|
|
hash,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('logs unavailable remote storage without failing the share', async () => {
|
|
const hash = 'c'.repeat(64);
|
|
vi.mocked(uploadBlobRemote).mockResolvedValue(null);
|
|
|
|
await expect(
|
|
uploadBlobRefsForShare(`promptfoo://blob/${hash}`, createRemoteBlobUploadCache(), {
|
|
localEvalId: 'local-eval-789',
|
|
remoteEvalId: 'remote-eval-789',
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[Share] Failed to upload referenced blob; shared media may be unavailable',
|
|
{
|
|
evalId: 'remote-eval-789',
|
|
hash,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('checks authorization only once for repeated unauthorized references', async () => {
|
|
const hash = 'e'.repeat(64);
|
|
vi.mocked(getShareAuthorizedBlob).mockResolvedValue(null);
|
|
const cache = createRemoteBlobUploadCache();
|
|
const context = { localEvalId: 'local-eval-999', remoteEvalId: 'remote-eval-999' };
|
|
|
|
await uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, context);
|
|
await uploadBlobRefsForShare({ output: `promptfoo://blob/${hash}` }, cache, context);
|
|
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledOnce();
|
|
expect(uploadBlobRemote).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shares one upload across concurrent references to the same blob', async () => {
|
|
const hash = 'f'.repeat(64);
|
|
let releaseAuth: ((blob: StoredBlob | null) => void) | undefined;
|
|
vi.mocked(getShareAuthorizedBlob).mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseAuth = resolve;
|
|
}),
|
|
);
|
|
const cache = createRemoteBlobUploadCache();
|
|
const context = { localEvalId: 'local-eval-111', remoteEvalId: 'remote-eval-111' };
|
|
|
|
const uploads = Promise.all([
|
|
uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, context),
|
|
uploadBlobRefsForShare(`promptfoo://blob/${hash}`, cache, context),
|
|
]);
|
|
releaseAuth?.(storedBlob);
|
|
await uploads;
|
|
|
|
expect(getShareAuthorizedBlob).toHaveBeenCalledOnce();
|
|
expect(uploadBlobRemote).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('skips the blob without failing the share when the authorization check errors', async () => {
|
|
const hash = '9'.repeat(64);
|
|
vi.mocked(getShareAuthorizedBlob).mockRejectedValue(new Error('db locked'));
|
|
|
|
await expect(
|
|
uploadBlobRefsForShare(`promptfoo://blob/${hash}`, createRemoteBlobUploadCache(), {
|
|
localEvalId: 'local-eval-222',
|
|
remoteEvalId: 'remote-eval-222',
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(uploadBlobRemote).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[Share] Failed to upload referenced blob; shared media may be unavailable',
|
|
expect.objectContaining({
|
|
error: 'db locked',
|
|
hash,
|
|
}),
|
|
);
|
|
});
|
|
});
|