d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
cleanupExecutionBase64Cache,
|
|
hydrateUserFilesWithBase64,
|
|
} from '@/lib/uploads/utils/user-file-base64.server'
|
|
import type { UserFile } from '@/executor/types'
|
|
|
|
const { mockDownloadFile, mockGetRedisClient, mockRedis, mockVerifyFileAccess } = vi.hoisted(() => {
|
|
const mockRedis = {
|
|
get: vi.fn(),
|
|
set: vi.fn(),
|
|
hget: vi.fn(),
|
|
hset: vi.fn(),
|
|
hgetall: vi.fn(),
|
|
expire: vi.fn(),
|
|
scan: vi.fn(),
|
|
del: vi.fn(),
|
|
eval: vi.fn(),
|
|
}
|
|
return {
|
|
mockDownloadFile: vi.fn(),
|
|
mockGetRedisClient: vi.fn(),
|
|
mockRedis,
|
|
mockVerifyFileAccess: vi.fn(),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/core/config/redis', () => ({
|
|
getRedisClient: mockGetRedisClient,
|
|
}))
|
|
|
|
vi.mock('@/lib/uploads', () => ({
|
|
StorageService: {
|
|
downloadFile: mockDownloadFile,
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/uploads/contexts/execution/execution-file-manager', () => ({
|
|
downloadExecutionFile: mockDownloadFile,
|
|
}))
|
|
|
|
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
|
|
downloadFileFromStorage: mockDownloadFile,
|
|
}))
|
|
|
|
vi.mock('@/app/api/files/authorization', () => ({
|
|
verifyFileAccess: mockVerifyFileAccess,
|
|
}))
|
|
|
|
describe('hydrateUserFilesWithBase64', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetRedisClient.mockReturnValue(null)
|
|
mockRedis.get.mockResolvedValue(null)
|
|
mockRedis.set.mockResolvedValue('OK')
|
|
mockRedis.hget.mockResolvedValue(null)
|
|
mockRedis.hset.mockResolvedValue(1)
|
|
mockRedis.hgetall.mockResolvedValue({})
|
|
mockRedis.expire.mockResolvedValue(1)
|
|
mockRedis.scan.mockResolvedValue(['0', []])
|
|
mockRedis.del.mockResolvedValue(1)
|
|
mockRedis.eval.mockResolvedValue([1, 'ok', 0, 0])
|
|
mockVerifyFileAccess.mockResolvedValue(true)
|
|
})
|
|
|
|
it('strips existing base64 when it exceeds maxBytes', async () => {
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'large.txt',
|
|
key: 'execution/workspace/workflow/execution/large.txt',
|
|
url: 'https://example.com/large.txt',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
base64: Buffer.from('hello').toString('base64'),
|
|
}
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 1 })
|
|
|
|
expect(hydrated.file).not.toHaveProperty('base64')
|
|
})
|
|
|
|
it('keeps existing base64 when it is within maxBytes', async () => {
|
|
const base64 = Buffer.from('hello').toString('base64')
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'small.txt',
|
|
key: 'execution/workspace/workflow/execution/small.txt',
|
|
url: 'https://example.com/small.txt',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
base64,
|
|
}
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10 })
|
|
|
|
expect(hydrated.file.base64).toBe(base64)
|
|
})
|
|
|
|
it('does not hydrate URL-only internal file objects', async () => {
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'private.txt',
|
|
key: '',
|
|
url: '/api/files/serve/execution/workspace/workflow/execution/private.txt?context=execution',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
}
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10, userId: 'user-1' })
|
|
|
|
expect(hydrated.file).not.toHaveProperty('base64')
|
|
})
|
|
|
|
it('hydrates prior-execution files when workflow-scoped reads are enabled', async () => {
|
|
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'prior.txt',
|
|
key: 'execution/workspace/workflow/source-execution/prior.txt',
|
|
url: '/api/files/serve/execution/workspace/workflow/source-execution/prior.txt?context=execution',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
}
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64(
|
|
{ file },
|
|
{
|
|
workspaceId: 'workspace',
|
|
workflowId: 'workflow',
|
|
executionId: 'resume-execution',
|
|
allowLargeValueWorkflowScope: true,
|
|
userId: 'user-1',
|
|
maxBytes: 10,
|
|
}
|
|
)
|
|
|
|
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
|
|
})
|
|
|
|
it('materializes large refs before hydrating nested files', async () => {
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'nested.txt',
|
|
key: 'execution/workspace/workflow/source-execution/nested.txt',
|
|
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
}
|
|
const ref = {
|
|
__simLargeValueRef: true,
|
|
version: 1,
|
|
id: 'lv_ABCDEFGHIJKL',
|
|
kind: 'object',
|
|
size: 256,
|
|
key: 'execution/workspace/workflow/source-execution/large-value-lv_ABCDEFGHIJKL.json',
|
|
executionId: 'source-execution',
|
|
}
|
|
|
|
mockDownloadFile.mockImplementation(async ({ key }) => {
|
|
if (key.includes('large-value')) {
|
|
return Buffer.from(JSON.stringify({ file }), 'utf8')
|
|
}
|
|
return Buffer.from('hello', 'utf8')
|
|
})
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64(
|
|
{ ref },
|
|
{
|
|
workspaceId: 'workspace',
|
|
workflowId: 'workflow',
|
|
executionId: 'resume-execution',
|
|
largeValueExecutionIds: ['source-execution'],
|
|
userId: 'user-1',
|
|
maxBytes: 1024,
|
|
}
|
|
)
|
|
|
|
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
|
|
Buffer.from('hello').toString('base64')
|
|
)
|
|
})
|
|
|
|
it('preserves large-value metadata while hydrating visible files when requested', async () => {
|
|
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello', 'utf8'))
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'visible.txt',
|
|
key: 'execution/workspace/workflow/execution-1/visible.txt',
|
|
url: '/api/files/serve/execution/workspace/workflow/execution-1/visible.txt?context=execution',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
}
|
|
const ref = {
|
|
__simLargeValueRef: true,
|
|
version: 1,
|
|
id: 'lv_PRESERVEREF1',
|
|
kind: 'object',
|
|
size: 256,
|
|
key: 'execution/workspace/workflow/source-execution/large-value-lv_PRESERVEREF1.json',
|
|
executionId: 'source-execution',
|
|
}
|
|
const manifest = {
|
|
__simLargeArrayManifest: true,
|
|
version: 2,
|
|
kind: 'array',
|
|
totalCount: 1,
|
|
chunkCount: 1,
|
|
byteSize: 256,
|
|
chunks: [
|
|
{
|
|
ref,
|
|
count: 1,
|
|
byteSize: 256,
|
|
},
|
|
],
|
|
preview: [{ id: 1 }],
|
|
}
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64(
|
|
{ file, ref, manifest },
|
|
{
|
|
workspaceId: 'workspace',
|
|
workflowId: 'workflow',
|
|
executionId: 'execution-1',
|
|
userId: 'user-1',
|
|
maxBytes: 1024,
|
|
preserveLargeValueMetadata: true,
|
|
}
|
|
)
|
|
|
|
expect(hydrated.file.base64).toBe(Buffer.from('hello').toString('base64'))
|
|
expect(hydrated.ref).toBe(ref)
|
|
expect(hydrated.manifest).toBe(manifest)
|
|
expect(mockDownloadFile).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('hydrates nested prior-execution files discovered from exact-key large refs', async () => {
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'nested.txt',
|
|
key: 'execution/workspace/workflow/source-execution/nested.txt',
|
|
url: '/api/files/serve/execution/workspace/workflow/source-execution/nested.txt?context=execution',
|
|
size: 5,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
}
|
|
const ref = {
|
|
__simLargeValueRef: true,
|
|
version: 1,
|
|
id: 'lv_MNOPQRSTUVWX',
|
|
kind: 'object',
|
|
size: 256,
|
|
key: 'execution/workspace/workflow/source-execution/large-value-lv_MNOPQRSTUVWX.json',
|
|
executionId: 'source-execution',
|
|
}
|
|
|
|
mockDownloadFile.mockImplementation(async ({ key }) => {
|
|
if (key.includes('large-value')) {
|
|
return Buffer.from(JSON.stringify({ file }), 'utf8')
|
|
}
|
|
return Buffer.from('hello', 'utf8')
|
|
})
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64(
|
|
{ ref },
|
|
{
|
|
workspaceId: 'workspace',
|
|
workflowId: 'workflow',
|
|
executionId: 'resume-execution',
|
|
largeValueKeys: [ref.key],
|
|
userId: 'user-1',
|
|
maxBytes: 1024,
|
|
}
|
|
)
|
|
|
|
expect((hydrated.ref as unknown as { file: UserFile }).file.base64).toBe(
|
|
Buffer.from('hello').toString('base64')
|
|
)
|
|
})
|
|
|
|
it('releases reserved Redis budget when cleaning up execution cache entries', async () => {
|
|
mockGetRedisClient.mockReturnValue(mockRedis)
|
|
const rawEntry = JSON.stringify({ bytes: 12, userId: 'user-1' })
|
|
mockRedis.hgetall.mockResolvedValueOnce({
|
|
'key:file-1': rawEntry,
|
|
})
|
|
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
|
|
if (script.includes('HGET') && script.includes('HDEL') && script.includes('DECRBY')) {
|
|
expect(args).toEqual([
|
|
4,
|
|
'user-file:base64-budget:exec:exec-1',
|
|
'user-file:base64:exec:exec-1:key:file-1',
|
|
'execution:redis-budget:execution:exec-1',
|
|
'execution:redis-budget:user:user-1',
|
|
'key:file-1',
|
|
rawEntry,
|
|
12,
|
|
60 * 60,
|
|
])
|
|
return [1, 1]
|
|
}
|
|
return 1
|
|
})
|
|
|
|
await cleanupExecutionBase64Cache('exec-1')
|
|
|
|
expect(mockRedis.eval).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('releases indexed budget entries even when cache keys already expired', async () => {
|
|
mockGetRedisClient.mockReturnValue(mockRedis)
|
|
mockRedis.hgetall.mockResolvedValueOnce({
|
|
'key:file-1': JSON.stringify({ bytes: 7, userId: 'user-1' }),
|
|
})
|
|
mockRedis.eval.mockResolvedValueOnce([1, 0])
|
|
|
|
await cleanupExecutionBase64Cache('exec-1')
|
|
|
|
expect(mockRedis.eval).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('writes execution cache and budget index through one delta-aware script', async () => {
|
|
mockGetRedisClient.mockReturnValue(mockRedis)
|
|
mockDownloadFile.mockResolvedValueOnce(Buffer.from('hello world!', 'utf8'))
|
|
let reservedBytes = 0
|
|
mockRedis.eval.mockImplementation(async (script: string, ...args: unknown[]) => {
|
|
if (script.includes('HGET') && script.includes('HSET') && script.includes('SET')) {
|
|
const keyCount = Number(args[0])
|
|
const valueBytes = Number(args[keyCount + 5])
|
|
reservedBytes = valueBytes - 10
|
|
return [1, 'ok', reservedBytes, reservedBytes]
|
|
}
|
|
return 1
|
|
})
|
|
const file: UserFile = {
|
|
id: 'file-1',
|
|
name: 'delta.txt',
|
|
key: 'execution/workspace/workflow/exec-1/delta.txt',
|
|
url: '/api/files/serve/execution/workspace/workflow/exec-1/delta.txt?context=execution',
|
|
size: 12,
|
|
type: 'text/plain',
|
|
context: 'execution',
|
|
}
|
|
|
|
const hydrated = await hydrateUserFilesWithBase64(
|
|
{ file },
|
|
{
|
|
workspaceId: 'workspace',
|
|
workflowId: 'workflow',
|
|
executionId: 'exec-1',
|
|
userId: 'user-1',
|
|
maxBytes: 20,
|
|
}
|
|
)
|
|
|
|
expect(hydrated.file.base64).toBe(Buffer.from('hello world!').toString('base64'))
|
|
expect(reservedBytes).toBe(Buffer.from('hello world!').toString('base64').length - 10)
|
|
expect(mockRedis.eval).toHaveBeenCalledWith(
|
|
expect.stringContaining('HGET'),
|
|
4,
|
|
'user-file:base64:exec:exec-1:key:execution/workspace/workflow/exec-1/delta.txt',
|
|
'user-file:base64-budget:exec:exec-1',
|
|
'execution:redis-budget:execution:exec-1',
|
|
'execution:redis-budget:user:user-1',
|
|
Buffer.from('hello world!').toString('base64'),
|
|
60 * 60,
|
|
'key:execution/workspace/workflow/exec-1/delta.txt',
|
|
JSON.stringify({
|
|
bytes: Buffer.from('hello world!').toString('base64').length,
|
|
userId: 'user-1',
|
|
}),
|
|
Buffer.from('hello world!').toString('base64').length,
|
|
64 * 1024 * 1024,
|
|
256 * 1024 * 1024,
|
|
60 * 60
|
|
)
|
|
expect(mockRedis.hget).not.toHaveBeenCalled()
|
|
expect(mockRedis.set).not.toHaveBeenCalled()
|
|
})
|
|
})
|