Share web progress console and SSE framing #96

Merged
eleith merged 2 commits from feat/interactive-generation-web into main 2026-10-03 15:21:32 +00:00
24 changed files with 2858 additions and 669 deletions

No files matched your search

+17 -55
View File
@@ -10,41 +10,22 @@ import {
} from '@google/genai'
import type { File as GeminiFile, GenerateContentResponse, Part } from '@google/genai'
import type { PieceFrontmatter, PieceFrontmatterSchema } from '../../pieces/utils/frontmatter.js'
const MODEL_NAME = 'gemini-3.8-flash'
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000
const FILE_POLL_MS = 5000
const CLEANUP_TIMEOUT_MS = 10_000
import {
MODEL_NAME,
REQUEST_TIMEOUT_MS,
FILE_POLL_MS,
CLEANUP_TIMEOUT_MS,
DEFAULT_GENERATION_LIMITS,
} from './constants.js'
export type GenerationProgress = {
phase: 'preparation' | 'generation' | 'validation'
message: string
}
export type GenerationLimits = {
maxFiles: number
maxFileBytes: number
maxTotalFileBytes: number
/** Maximum accepted result text; checked after the SDK receives the response. */
maxOutputBytes: number
/** Stop starting new status checks after this wait; active SDK requests may finish. */
maxFileProcessingMs: number
}
/** Core safety budgets, not measured web deployment limits. */
export const DEFAULT_GENERATION_LIMITS: Readonly<GenerationLimits> = {
maxFiles: 10,
maxFileBytes: 50_000_000,
maxTotalFileBytes: 100_000_000,
maxOutputBytes: 1_000_000,
maxFileProcessingMs: 5 * 60 * 1000,
}
export type GenerationOptions = {
files?: Array<string | Buffer>
limits?: Partial<GenerationLimits>
onProgress?: (progress: GenerationProgress) => void | Promise<void>
onWarning?: (message: string) => void | Promise<void>
}
type GenerationTask<T> = {
@@ -56,7 +37,6 @@ type GenerationTask<T> = {
type GenerationContext = {
client: GoogleGenAI
limits: GenerationLimits
options: GenerationOptions
uploaded: Set<string>
}
@@ -76,16 +56,6 @@ export async function validateApiKey(
}
}
function generationLimits(options: GenerationOptions, fileCount: number): GenerationLimits {
const limits = { ...DEFAULT_GENERATION_LIMITS, ...options.limits }
for (const [key, value] of Object.entries(limits)) {
if (!Number.isSafeInteger(value) || value <= 0)
throw new Error(`Invalid generation limit: ${key}.`)
}
if (fileCount > limits.maxFiles) throw new Error('Too many generation attachments.')
return limits
}
function progress(context: GenerationContext, phase: GenerationProgress['phase'], message: string) {
// Losing a progress subscriber must not interrupt accepted work.
try {
@@ -132,7 +102,7 @@ async function uploadFile(
}
async function waitForFile(name: string, context: GenerationContext): Promise<Part> {
const stopPollingAt = Date.now() + context.limits.maxFileProcessingMs
const stopPollingAt = Date.now() + DEFAULT_GENERATION_LIMITS.maxFileProcessingMs
while (Date.now() < stopPollingAt) {
const file = await context.client.files.get({ name })
if (file.state === 'ACTIVE' && file.uri && file.mimeType)
@@ -147,7 +117,7 @@ async function waitForFile(name: string, context: GenerationContext): Promise<Pa
}
async function prepareAttachments(context: GenerationContext, files: Array<string | Buffer>) {
const { limits } = context
const limits = DEFAULT_GENERATION_LIMITS
const parts: Array<string | Part> = []
let totalBytes = 0
for (const [index, file] of files.entries()) {
@@ -173,16 +143,6 @@ async function prepareAttachments(context: GenerationContext, files: Array<strin
return parts
}
function warnAboutCleanup(options: GenerationOptions) {
const message = 'Could not delete a temporary generation file; provider expiry still applies.'
const warn = options.onWarning ?? console.warn
try {
void Promise.resolve(warn(message)).catch(() => console.warn(message))
} catch {
console.warn(message)
}
}
async function cleanupFiles(context: GenerationContext) {
await Promise.all(
[...context.uploaded].map(async (name) => {
@@ -193,13 +153,15 @@ async function cleanupFiles(context: GenerationContext) {
})
} catch {
// Provider errors may include credentials or user content; don't expose them.
warnAboutCleanup(context.options)
console.warn(
'Could not delete a temporary generation file; provider expiry still applies.'
)
}
})
)
}
function completedText(response: GenerateContentResponse, maxOutputBytes: number): string {
function completedText(response: GenerateContentResponse): string {
if (
response.promptFeedback?.blockReason &&
response.promptFeedback.blockReason !== 'BLOCKED_REASON_UNSPECIFIED'
@@ -225,7 +187,7 @@ function completedText(response: GenerateContentResponse, maxOutputBytes: number
text += part.text
}
if (!text.trim()) throw new Error('Generation returned empty output.')
if (Buffer.byteLength(text, 'utf8') > maxOutputBytes)
if (Buffer.byteLength(text, 'utf8') > DEFAULT_GENERATION_LIMITS.maxOutputBytes)
throw new Error('Generated output exceeds the byte limit.')
return text
}
@@ -257,7 +219,7 @@ async function generateText<T>(
],
},
})
return completedText(response, context.limits.maxOutputBytes)
return completedText(response)
}
export async function runGeneration<T>(
@@ -266,10 +228,10 @@ export async function runGeneration<T>(
options: GenerationOptions
): Promise<T> {
const files = [...(options.files ?? [])]
const limits = generationLimits(options, files.length)
if (files.length > DEFAULT_GENERATION_LIMITS.maxFiles)
throw new Error('Too many generation attachments.')
const context: GenerationContext = {
client: getClient(apiKey),
limits,
options,
uploaded: new Set(),
}
+12
View File
@@ -0,0 +1,12 @@
export const MODEL_NAME = 'gemini-3.8-flash'
export const REQUEST_TIMEOUT_MS = 5 * 60 * 1000
export const FILE_POLL_MS = 5000
export const CLEANUP_TIMEOUT_MS = 10_000
export const DEFAULT_GENERATION_LIMITS = {
maxFiles: 10,
maxFileBytes: 50_000_000,
maxTotalFileBytes: 100_000_000,
maxOutputBytes: 1_000_000,
maxFileProcessingMs: 5 * 60 * 1000,
} as const
+74 -68
View File
@@ -80,6 +80,8 @@ async function until(condition: () => boolean) {
}
throw new Error('Expected asynchronous operation did not start')
}
// Small byte budgets exercise boundaries without allocating deployment-sized fixtures.
const originalLimits = { ...DEFAULT_GENERATION_LIMITS }
const run = (options: GenerationOptions = {}) =>
generatePieceMetadata('test-key', schema(), 'My instructions', options)
@@ -98,6 +100,7 @@ beforeEach(() => {
mocks.list.mockResolvedValue({} as never)
})
afterEach(() => {
Object.assign(DEFAULT_GENERATION_LIMITS, originalLimits)
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -545,82 +548,62 @@ describe('input preparation and cleanup', () => {
expect(mocks.delete).toHaveBeenCalledTimes(1)
})
test('reports cleanup failures without exposing provider errors or changing the valid result', async () => {
test('logs safe cleanup warnings without changing the valid result', async () => {
binary()
mocks.delete.mockRejectedValue(new Error('API key secret; private prompt'))
const onWarning = vi.fn()
await expect(run({ files: [Buffer.from('one')], onWarning })).resolves.toEqual({
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(run({ files: [Buffer.from('one')] })).resolves.toEqual({
title: 'generated',
})
expect(onWarning).toHaveBeenCalledExactlyOnceWith(
'Could not delete a temporary generation file; provider expiry still applies.'
)
})
test('uses safe console warnings by default and if the warning callback throws', async () => {
binary()
mocks.delete.mockRejectedValue(new Error('secret'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await run({ files: [Buffer.from('one')] })
await run({
files: [Buffer.from('two')],
onWarning: () => {
throw new Error('observer failed')
},
})
expect(warn).toHaveBeenCalledTimes(2)
expect(JSON.stringify(warn.mock.calls)).not.toContain('secret')
})
test('falls back to a safe warning when an asynchronous warning callback rejects', async () => {
binary()
mocks.delete.mockRejectedValue(new Error('private provider error'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onWarning = vi.fn().mockRejectedValue(new Error('private observer error'))
await expect(run({ files: [Buffer.from('one')], onWarning })).resolves.toEqual({
title: 'generated',
})
expect(onWarning).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledExactlyOnceWith(
'Could not delete a temporary generation file; provider expiry still applies.'
)
})
test('does not wait for warning callbacks or skip cleanup of other known files', async () => {
test.each(['provider', 'validation'])(
'logs safe cleanup warnings without masking a %s error',
async (failure) => {
binary()
const providerError = new Error('provider unavailable')
if (failure === 'provider') mocks.generate.mockRejectedValue(providerError)
else respond('{"title":42}')
mocks.delete.mockRejectedValue(new Error('API key secret; private prompt'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const generating = run({ files: [Buffer.from('one')] })
if (failure === 'provider') await expect(generating).rejects.toBe(providerError)
else await expect(generating).rejects.toThrow('does not match schema')
expect(warn).toHaveBeenCalledExactlyOnceWith(
'Could not delete a temporary generation file; provider expiry still applies.'
)
}
)
test('does not skip cleanup of other known files after a deletion fails', async () => {
binary()
mocks.upload
.mockResolvedValueOnce({ name: 'files/first' })
.mockResolvedValueOnce({ name: 'files/second' })
mocks.delete.mockRejectedValueOnce(new Error('cleanup failed'))
const onWarning = vi.fn(() => new Promise<void>(() => {}))
await expect(
run({ files: [Buffer.from('one'), Buffer.from('two')], onWarning })
).resolves.toEqual({ title: 'generated' })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(run({ files: [Buffer.from('one'), Buffer.from('two')] })).resolves.toEqual({
title: 'generated',
})
expect(mocks.delete.mock.calls.map(([arg]) => arg.name)).toEqual([
'files/first',
'files/second',
])
expect(onWarning).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledExactlyOnceWith(
'Could not delete a temporary generation file; provider expiry still applies.'
)
})
})
describe('input and output bounds', () => {
test.each([0, -1, NaN, Infinity, 1.5, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid limits before provider work: %s',
async (maxFiles) => {
await expect(run({ limits: { maxFiles } })).rejects.toThrow('Invalid generation limit')
await expect(run({ limits: { maxFileProcessingMs: maxFiles } })).rejects.toThrow(
'Invalid generation limit'
)
expect(GoogleGenAI).not.toHaveBeenCalled()
}
)
test('rejects excess file counts and retains the default processing wait limit', async () => {
await expect(
run({ files: [Buffer.from('1'), Buffer.from('2')], limits: { maxFiles: 1 } })
).rejects.toThrow('Too many')
test('accepts the file count boundary and rejects excess files before provider work', async () => {
const files = Array.from({ length: DEFAULT_GENERATION_LIMITS.maxFiles }, () => Buffer.from('1'))
await expect(run({ files: [...files, Buffer.from('2')] })).rejects.toThrow('Too many')
expect(GoogleGenAI).not.toHaveBeenCalled()
expect(DEFAULT_GENERATION_LIMITS.maxFileProcessingMs).toBe(300000)
await expect(run({ files })).resolves.toEqual({ title: 'generated' })
})
test('rejects invalid schema or unknown field before provider work', async () => {
@@ -634,22 +617,34 @@ describe('input and output bounds', () => {
})
test('bounds buffers and combined input bytes before starting further uploads', async () => {
await expect(run({ files: [Buffer.from('123')], limits: { maxFileBytes: 2 } })).rejects.toThrow(
Object.assign(DEFAULT_GENERATION_LIMITS, { maxFileBytes: 2, maxTotalFileBytes: 3 })
await expect(run({ files: [Buffer.from('123')] })).rejects.toThrow('byte limit')
await expect(run({ files: [Buffer.from('12'), Buffer.from('34')] })).rejects.toThrow(
'byte limit'
)
await expect(
run({ files: [Buffer.from('12'), Buffer.from('34')], limits: { maxTotalFileBytes: 3 } })
).rejects.toThrow('byte limit')
expect(mocks.upload).not.toHaveBeenCalled()
expect(mocks.generate).not.toHaveBeenCalled()
await expect(run({ files: [Buffer.from('12'), Buffer.from('3')] })).resolves.toEqual({
title: 'generated',
})
})
test('cleans earlier uploads when a later attachment exceeds the total byte budget', async () => {
Object.assign(DEFAULT_GENERATION_LIMITS, { maxFileBytes: 2, maxTotalFileBytes: 3 })
binary()
await expect(run({ files: [Buffer.from('12'), Buffer.from('34')] })).rejects.toThrow(
'byte limit'
)
expect(mocks.upload).toHaveBeenCalledOnce()
expect(mocks.delete).toHaveBeenCalledOnce()
expect(mocks.generate).not.toHaveBeenCalled()
})
test('stops reading an oversized path rather than buffering the entire file', async () => {
Object.assign(DEFAULT_GENERATION_LIMITS, { maxFileBytes: 3 })
const stream = Readable.from([Buffer.from('12'), Buffer.from('34'), Buffer.from('56')])
mocks.readStream.mockReturnValue(stream as ReadStream)
await expect(run({ files: ['/file'], limits: { maxFileBytes: 3 } })).rejects.toThrow(
'byte limit'
)
await expect(run({ files: ['/file'] })).rejects.toThrow('byte limit')
expect(stream.destroyed).toBe(true)
expect(mocks.fileType).not.toHaveBeenCalled()
})
@@ -658,8 +653,10 @@ describe('input and output bounds', () => {
const text = '{"title":"🧩"}'
respond(text)
const bytes = Buffer.byteLength(text, 'utf8')
await expect(run({ limits: { maxOutputBytes: bytes } })).resolves.toEqual({ title: '🧩' })
await expect(run({ limits: { maxOutputBytes: bytes - 1 } })).rejects.toThrow('output exceeds')
Object.assign(DEFAULT_GENERATION_LIMITS, { maxOutputBytes: bytes })
await expect(run()).resolves.toEqual({ title: '🧩' })
Object.assign(DEFAULT_GENERATION_LIMITS, { maxOutputBytes: bytes - 1 })
await expect(run()).rejects.toThrow('output exceeds')
})
})
@@ -668,6 +665,8 @@ describe('disconnected progress observers', () => {
'finishes validation and cleanup when the progress sink disconnects (%s)',
async (failure) => {
binary()
mocks.delete.mockRejectedValue(new Error('API key secret; private prompt'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const pending = deferred<GenerateContentResponse>()
mocks.generate.mockReturnValue(pending.promise)
let disconnected = false
@@ -684,6 +683,9 @@ describe('disconnected progress observers', () => {
await expect(generating).resolves.toEqual({ title: 'finished' })
expect(onProgress.mock.calls.map(([event]) => event.phase)).toContain('validation')
expect(mocks.delete).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledExactlyOnceWith(
'Could not delete a temporary generation file; provider expiry still applies.'
)
}
)
@@ -705,6 +707,8 @@ describe('disconnected progress observers', () => {
'preserves a %s error after disconnect and still cleans known files',
async (failure) => {
binary()
mocks.delete.mockRejectedValue(new Error('API key secret; private prompt'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const pending = deferred<GenerateContentResponse>()
mocks.generate.mockReturnValue(pending.promise)
let disconnected = false
@@ -722,6 +726,9 @@ describe('disconnected progress observers', () => {
else pending.resolve(response('{"title":42}'))
await rejected
expect(mocks.delete).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledExactlyOnceWith(
'Could not delete a temporary generation file; provider expiry still applies.'
)
}
)
})
@@ -731,13 +738,15 @@ describe('bounded file processing polling', () => {
{ maxFileProcessingMs: 1, polls: 1 },
{ maxFileProcessingMs: 5000, polls: 1 },
{ maxFileProcessingMs: 6000, polls: 2 },
{ maxFileProcessingMs: DEFAULT_GENERATION_LIMITS.maxFileProcessingMs, polls: 60 },
])(
'stops polling at the wait limit: $maxFileProcessingMs ms',
async ({ maxFileProcessingMs, polls }) => {
vi.useFakeTimers()
binary()
mocks.get.mockResolvedValue({ state: FileState.PROCESSING })
const generating = run({ files: [Buffer.from('one')], limits: { maxFileProcessingMs } })
Object.assign(DEFAULT_GENERATION_LIMITS, { maxFileProcessingMs })
const generating = run({ files: [Buffer.from('one')] })
const rejected = expect(generating).rejects.toThrow(
'Attachment processing exceeded the wait limit.'
)
@@ -761,13 +770,10 @@ describe('bounded file processing polling', () => {
const pending = deferred<Awaited<ReturnType<typeof mocks.get>>>()
mocks.get.mockReturnValue(pending.promise)
const settled = vi.fn()
const generating = run({
files: [Buffer.from('one')],
limits: { maxFileProcessingMs: 100 },
})
const generating = run({ files: [Buffer.from('one')] })
void generating.then(settled, settled)
await until(() => mocks.get.mock.calls.length === 1)
await vi.advanceTimersByTimeAsync(101)
await vi.advanceTimersByTimeAsync(DEFAULT_GENERATION_LIMITS.maxFileProcessingMs + 1)
expect(settled).not.toHaveBeenCalled()
pending.resolve({ state, uri: 'https://provider/ready', mimeType: 'application/pdf' })
if (state === FileState.ACTIVE) {
+3 -2
View File
@@ -1,3 +1,4 @@
export * from './generation.js'
export { DEFAULT_GENERATION_LIMITS, validateApiKey } from './client.js'
export type { GenerationOptions, GenerationLimits, GenerationProgress } from './client.js'
export { DEFAULT_GENERATION_LIMITS } from './constants.js'
export { validateApiKey } from './client.js'
export type { GenerationOptions, GenerationProgress } from './client.js'
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, expect, test, vi } from 'vitest'
import type { Handle } from '@sveltejs/kit'
const fixture = vi.hoisted(() => ({
config: {
auth: { enabled: true, type: 'credentials', secret: 'test-secret' },
url: { app_assets: '' }
}
}))
vi.mock('$lib/server/config', () => ({ config: fixture.config }))
vi.mock('$lib/server/database', () => ({}))
// Exercise the application guard, not Auth.js or SvelteKit's request-store machinery.
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: (_auth: Handle, guard: Handle) => guard }))
vi.mock('@auth/sveltekit', () => ({
SvelteKitAuth: () => ({
handle: (({ event, resolve }) => resolve(event)) satisfies Handle
})
}))
afterEach(() => {
fixture.config.auth.enabled = true
vi.resetModules()
})
function event(session: object | null) {
return {
url: new URL('http://localhost/api/admin/generate'),
locals: { auth: vi.fn().mockResolvedValue(session) }
} as unknown as Parameters<Handle>[0]['event']
}
test('the generation namespace requires a session before resolving its handler', async () => {
const { handle } = await import('./hooks.server')
const resolve = vi.fn()
await expect(handle({ event: event(null), resolve })).rejects.toMatchObject({
status: 302,
location: '/signin?redirectTo=/api/admin/generate'
})
expect(resolve).not.toHaveBeenCalled()
})
test('generation remains inaccessible when admin authentication is disabled', async () => {
fixture.config.auth.enabled = false
const { handle } = await import('./hooks.server')
const resolve = vi.fn()
await expect(handle({ event: event({ user: { name: 'test' } }), resolve })).rejects.toMatchObject(
{
status: 302,
location: '/'
}
)
expect(resolve).not.toHaveBeenCalled()
})
test('an authenticated generation request reaches its handler', async () => {
const { handle } = await import('./hooks.server')
const response = new Response('resolved')
const resolve = vi.fn().mockResolvedValue(response)
await expect(handle({ event: event({ user: { name: 'test' } }), resolve })).resolves.toBe(
response
)
})
@@ -0,0 +1,478 @@
<script lang="ts">
import { tick } from 'svelte'
import type { ProgressPhase, ProgressLog, ProgressStatusText } from './progress.js'
import CheckCircleFill from 'virtual:icons/ph/check-circle-fill'
import XCircleFill from 'virtual:icons/ph/x-circle-fill'
import CircleNotchBold from 'virtual:icons/ph/circle-notch-bold'
import MinusCircle from 'virtual:icons/ph/minus-circle'
import LockSimpleFill from 'virtual:icons/ph/lock-simple-fill'
import LockSimpleOpenFill from 'virtual:icons/ph/lock-simple-open-fill'
interface Props {
status: string
statusText?: ProgressStatusText
errorMessage?: string
phases: ProgressPhase[]
logs: Record<string, ProgressLog[]>
}
let { status, statusText, errorMessage = '', phases, logs }: Props = $props()
const consoleId = $props.id()
let scrollLocks = $state<Record<string, boolean>>({})
function formatDuration(ms: number) {
if (ms < 0) return '0s'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const remS = s % 60
return `${m}m ${remS}s`
}
function handleScroll(phaseName: string, event: Event) {
const el = event.currentTarget as HTMLDivElement
if (!el) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10
const currentLock = scrollLocks[phaseName] ?? true
if (isAtBottom && !currentLock) {
scrollLocks[phaseName] = true
} else if (!isAtBottom && currentLock) {
scrollLocks[phaseName] = false
}
}
let overallDuration = $derived.by(() => {
if (phases.length === 0) return 0
const first = phases[0]
const last = phases[phases.length - 1]
if (!first.started_at) return 0
const end = last.finished_at || Date.now()
return end - first.started_at
})
let logLengths = $derived(
Object.fromEntries(Object.entries(logs).map(([phase, list]) => [phase, list.length]))
)
$effect(() => {
for (const [phase, _length] of Object.entries(logLengths)) {
const isLocked = scrollLocks[phase] ?? true
if (isLocked) {
tick().then(() => {
const el = document.getElementById(`${consoleId}-log-container-${phase}`)
if (el) el.scrollTop = el.scrollHeight
})
}
}
})
</script>
<div class="status-card">
<span class="status-icon-wrap status-{status}">
{#if status === 'completed'}
<CheckCircleFill class="status-icon" />
{:else if status === 'running'}
<CircleNotchBold class="status-icon spin" />
{:else if status === 'enqueued'}
<CircleNotchBold class="status-icon" />
{:else if status === 'failed' || status === 'expired'}
<XCircleFill class="status-icon" />
{:else}
<span class="status-dot"></span>
{/if}
</span>
<div class="status-text">
{#if statusText}
<div class="status-title">{statusText.title}</div>
<div class="status-sub">
{#if statusText.description}
{statusText.description}
{:else if statusText.durationLabel}
{formatDuration(overallDuration)} {statusText.durationLabel}
{/if}
</div>
{/if}
</div>
</div>
{#if errorMessage && status === 'failed'}
<div class="error-strip">
<XCircleFill class="error-icon" />
<span>{errorMessage}</span>
</div>
{/if}
{#if phases.length > 0}
<div class="timeline">
{#each phases as phase (phase.phase)}
{@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0}
<div class="phase">
<div class="phase-row">
<span class="phase-icon-wrap status-{phase.status}">
{#if phase.status === 'completed'}
<CheckCircleFill class="phase-icon" />
{:else if phase.status === 'running'}
<CircleNotchBold class="phase-icon spin" />
{:else if phase.status === 'failed'}
<XCircleFill class="phase-icon" />
{:else if phase.status === 'skipped'}
<MinusCircle class="phase-icon" />
{:else}
<span class="phase-dot"></span>
{/if}
</span>
<span class="phase-name">{phase.phase}</span>
{#if phase.message}
<span class="phase-message" class:is-error={phase.status === 'failed'}>
{phase.message}
</span>
{/if}
<span class="phase-aside">
{#if phase.started_at}
<span class="phase-time">
{formatDuration((phase.finished_at || Date.now()) - phase.started_at)}
</span>
{/if}
</span>
</div>
{#if hasLogs}
<div class="log-console">
<div class="log-header">
<span>{logs[phase.phase]?.length || 0} lines</span>
<div class="log-header-right">
{#if phase.started_at}
<span class="log-duration">
{formatDuration((phase.finished_at || Date.now()) - phase.started_at)}
</span>
{/if}
<button
type="button"
class="scroll-lock-btn"
onclick={() => {
const current = scrollLocks[phase.phase] ?? true
const next = !current
scrollLocks[phase.phase] = next
if (next) {
tick().then(() => {
const el = document.getElementById(
`${consoleId}-log-container-${phase.phase}`
)
if (el) el.scrollTop = el.scrollHeight
})
}
}}
title={(scrollLocks[phase.phase] ?? true)
? 'Auto-scroll locked'
: 'Auto-scroll unlocked'}
>
{#if scrollLocks[phase.phase] ?? true}
<LockSimpleFill class="lock-icon" />
{:else}
<LockSimpleOpenFill class="lock-icon" />
{/if}
</button>
</div>
</div>
<div
class="log-viewport"
id="{consoleId}-log-container-{phase.phase}"
onscroll={(e) => handleScroll(phase.phase, e)}
>
{#each logs[phase.phase] ?? [] as log (log.line_number)}
<div
class="log-row"
class:is-error={log.level === 'error' || log.level === 'stderr'}
>
<span class="log-timestamp">
{new Date(log.ts).toISOString().split('T')[1].slice(0, -5)}
</span>
<span class="log-body">{log.message}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.status-card {
display: flex;
align-items: center;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
background-color: var(--color-surface-container-lowest);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-medium);
margin-bottom: var(--space-5);
}
.status-icon-wrap {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-on-surface-variant);
}
.status-icon-wrap :global(.status-icon) {
font-size: 26px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--color-outline-variant);
}
.status-icon-wrap.status-completed {
color: var(--color-primary);
}
.status-icon-wrap.status-running {
color: var(--color-secondary);
}
.status-icon-wrap.status-enqueued {
color: var(--color-tertiary);
}
.status-icon-wrap.status-failed {
color: var(--color-error);
}
.status-title {
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface);
}
.status-sub {
margin-top: 2px;
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
}
.error-strip {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent);
color: var(--color-error);
font-size: var(--font-size-xxs);
border-radius: var(--radius-small);
margin-bottom: var(--space-4);
}
.error-strip :global(.error-icon) {
font-size: 14px;
flex-shrink: 0;
}
.timeline {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.phase {
display: flex;
flex-direction: column;
gap: 2px;
}
.phase-row {
padding: var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-3);
}
.phase-icon-wrap {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phase-icon-wrap :global(.phase-icon) {
font-size: 18px;
}
.phase-icon-wrap.status-completed {
color: var(--color-primary);
}
.phase-icon-wrap.status-running {
color: var(--color-secondary);
}
.phase-icon-wrap.status-failed {
color: var(--color-error);
}
.phase-icon-wrap.status-skipped {
color: var(--color-on-surface-variant);
}
.phase-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid var(--color-outline-variant);
}
.phase-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface);
flex-shrink: 0;
}
.phase-message {
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.phase-message.is-error {
color: var(--color-error);
}
.phase-aside {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.phase-time {
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
min-width: 36px;
text-align: right;
}
.log-console {
margin: 4px 0 0 0;
background-color: var(--color-surface);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-small);
overflow: hidden;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: 4px var(--space-3);
background-color: var(--color-surface-container-low);
border-bottom: 1px solid var(--color-outline-variant);
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface-variant);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.log-duration {
color: var(--color-on-surface);
}
.log-header-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.scroll-lock-btn {
background: none;
border: none;
padding: 2px;
margin: 0;
color: var(--color-on-surface-variant);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-small);
transition:
background-color 0.15s,
color 0.15s;
}
.scroll-lock-btn:hover {
background-color: var(--color-surface-container-highest);
color: var(--color-on-surface);
}
.scroll-lock-btn :global(.lock-icon) {
font-size: 14px;
}
.log-viewport {
padding: var(--space-2) var(--space-3);
max-height: 260px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
line-height: 1.6;
color: var(--color-on-surface-variant);
}
.log-row {
display: flex;
gap: var(--space-3);
}
.log-row.is-error {
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
margin: 0 calc(-1 * var(--space-3));
padding: 0 var(--space-3);
}
.log-timestamp {
color: var(--color-outline);
flex-shrink: 0;
opacity: 0.7;
}
.log-body {
white-space: pre-wrap;
word-break: break-all;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import ProgressConsole from './ProgressConsole.svelte'
import WorkflowConsole from '../../routes/admin/piece/[...path]/preview/[jobId]/WorkflowConsole.svelte'
import type { ProgressPhase, ProgressLog } from './progress.js'
const started = Date.parse('2026-01-02T03:04:00Z')
const phases: ProgressPhase[] = [
{
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: 'Rendered successfully'
}
]
const logs: Record<string, ProgressLog[]> = {
render: [
{ phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' }
]
}
function text(html: string) {
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(started + 75_000)
})
afterEach(() => vi.restoreAllMocks())
describe('preview compatibility wrapper', () => {
test.each([
[
'expired',
'Preview expired',
'Assets are purged after 2 days. Re-run the preview from the editor.'
],
['enqueued', 'Enqueued', 'Waiting for worker…'],
['running', 'Rendering preview', '1m 5s elapsed'],
['failed', 'Preview failed', '1m 5s stopped at'],
['completed', 'Preview completed', '1m 5s total time']
])('retains wording and duration for %s', (status, title, subtitle) => {
const result = render(WorkflowConsole, { props: { status, phases, logs } })
expect(text(result.body)).toContain(`${title} ${subtitle}`)
expect(result.body).toContain(`status-${status}`)
expect(result.body).toContain('phase-icon')
expect(result.body).toContain('log-viewport')
expect(text(result.body)).toContain('Rendered successfully')
expect(text(result.body)).toContain('03:04:00')
expect(text(result.body)).toContain('Output ready')
})
test('preserves an empty summary for an unrecognized status', () => {
const result = render(WorkflowConsole, { props: { status: 'unknown', phases: [], logs: {} } })
expect(result.body).not.toContain('status-title')
expect(result.body).not.toContain('status-sub')
})
test.each(['failed', 'running', 'completed', 'expired'])(
'shows the existing error strip only for failed status: %s',
(status) => {
const result = render(WorkflowConsole, {
props: { status, phases: [], logs: {}, errorMessage: 'Render failed' }
})
expect(result.body.includes('error-strip')).toBe(status === 'failed')
expect(text(result.body).includes('Render failed')).toBe(status === 'failed')
}
)
})
describe('shared progress presentation', () => {
test('accepts caller wording and progress/log rows without job IDs', () => {
const result = render(ProgressConsole, {
props: {
status: 'completed',
statusText: { title: 'Content ready', durationLabel: 'finished in' },
phases,
logs
}
})
expect(text(result.body)).toContain('Content ready 1m 5s finished in')
expect(text(result.body)).not.toContain('Preview')
expect(text(result.body)).not.toContain('worker')
expect(text(result.body)).toContain('1 lines')
expect(result.body).toContain('Auto-scroll locked')
})
test.each([
[[], '0s'],
[[{ ...phases[0], started_at: 0 }], '0s'],
[[{ ...phases[0], finished_at: null }], '1m 15s'],
[[{ ...phases[0], finished_at: started + 5000 }], '5s'],
[[{ ...phases[0], finished_at: started - 1000 }], '0s']
])('retains elapsed-time formatting for %j', (items, duration) => {
const result = render(ProgressConsole, {
props: {
status: 'running',
statusText: { title: 'Working', durationLabel: 'elapsed' },
phases: items,
logs: {}
}
})
expect(text(result.body)).toContain(`Working ${duration} elapsed`)
})
test('retains phase statuses and stderr/error highlighting', () => {
const items = ['running', 'failed', 'skipped', 'waiting'].map((status) => ({
...phases[0],
phase: status,
status
}))
const result = render(ProgressConsole, {
props: {
status: 'failed',
phases: items,
logs: {
failed: [
{ ...logs.render[0], phase: 'failed', level: 'stderr', message: 'Failure details' }
]
}
}
})
for (const { status } of items) expect(result.body).toContain(`status-${status}`)
expect(result.body).toContain('spin')
expect(result.body).toContain('is-error')
expect(text(result.body)).toContain('Failure details')
})
test('escapes caller titles, messages, and logs', () => {
const unsafe = '<script>not executable</script>'
const result = render(ProgressConsole, {
props: {
status: 'failed',
statusText: { title: unsafe, description: unsafe },
errorMessage: unsafe,
phases: [{ ...phases[0], message: unsafe }],
logs: { render: [{ ...logs.render[0], message: unsafe }] }
}
})
expect(result.body).not.toContain('<script>')
expect(result.body).toContain('&lt;script>')
})
})
+21
View File
@@ -0,0 +1,21 @@
export type ProgressPhase = {
phase: string
status: string
started_at: number
finished_at: number | null
message: string | null
}
export type ProgressLog = {
phase: string
line_number: number
ts: number
level: string
message: string
}
export type ProgressStatusText = {
title: string
description?: string
durationLabel?: string
}
+17
View File
@@ -0,0 +1,17 @@
import type { ProgressPhase } from '$lib/components/progress'
export type GenerationTarget = { kind: 'field'; key: string } | { kind: 'body' }
export type GenerationResult =
| { kind: 'creation'; markdown: string }
| { kind: 'field'; key: string; value: unknown }
| { kind: 'body'; value: string }
export type GenerationEvent =
| { type: 'state'; data: { state: 'running' } }
| { type: 'phase'; data: ProgressPhase[] }
| { type: 'error'; data: { message: string } }
| {
type: 'done'
data: { state: 'completed'; result: GenerationResult } | { state: 'failed' }
}
+6
View File
@@ -0,0 +1,6 @@
// Generation and progress-stream budgets, tuned in code rather than exposed as user settings.
export const MAX_SOURCE_CHARS = 1_000_000
export const MAX_INSTRUCTION_CHARS = 100_000
export const MAX_CONCURRENT_GENERATIONS = 1
export const HEARTBEAT_MS = 15_000
export const WORKFLOW_POLL_INTERVAL_MS = 350
+387
View File
@@ -0,0 +1,387 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import {
Piece,
extractFullMarkdown,
generateBody,
generateFieldValue,
generatePieceMetadata,
type GenerationOptions,
type PieceFrontmatter,
type PieceFrontmatterSchema
} from '@luzzle/core'
import type { GenerationEvent } from '$lib/generation/types'
import { POST } from '../../routes/api/admin/generate/+server'
const fixture = vi.hoisted(() => {
const limits = {
maxFiles: 2,
maxFileBytes: 50,
maxTotalFileBytes: 75,
maxOutputBytes: 1000,
maxFileProcessingMs: 300000
}
const ai = { provider: 'google' as const, api_key: 'server-secret' }
return {
limits,
ai,
config: {
ai: ai as typeof ai | undefined,
pieces: [{ type: 'books', fields: { title: 'title' } }]
},
storage: {
stat: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
createFile: vi.fn(),
makeDirectory: vi.fn(),
delete: vi.fn()
}
}
})
vi.mock('./config', () => ({ config: fixture.config }))
vi.mock('./storage', () => ({ getStorage: () => fixture.storage }))
vi.mock('./constants', () => ({
MAX_SOURCE_CHARS: 1000,
MAX_INSTRUCTION_CHARS: 100,
MAX_CONCURRENT_GENERATIONS: 1,
HEARTBEAT_MS: 15000
}))
vi.mock('@luzzle/core', async (original) => ({
...(await original<Record<string, unknown>>()),
DEFAULT_GENERATION_LIMITS: fixture.limits,
generatePieceMetadata: vi.fn(),
generateFieldValue: vi.fn(),
generateBody: vi.fn()
}))
vi.mock('$lib/server/database', () => {
throw new Error('Generation must not import the database')
})
vi.mock('@luzzle/web.jobs', () => {
throw new Error('Generation must not import durable jobs')
})
let schema: PieceFrontmatterSchema<PieceFrontmatter>
beforeEach(() => {
vi.clearAllMocks()
fixture.config.ai = fixture.ai
fixture.limits.maxOutputBytes = 1000
fixture.config.pieces = [{ type: 'books', fields: { title: 'title' } }]
schema = {
title: 'books',
type: 'object',
properties: {
title: { type: 'string', minLength: 2 },
body: { type: 'string' },
cover: { type: 'string' }
},
required: ['title'],
additionalProperties: false
}
fixture.storage.readFile.mockImplementation(async (file: string) => {
expect(file).toBe('.luzzle/schemas/books.json')
return JSON.stringify(schema)
})
fixture.storage.stat.mockImplementation(async (file: string) => {
if (file === '.' || file === 'books') return { type: 'directory' }
if (file === 'books/disk.books.md') return { type: 'file' }
throw new Error('Not in the archive')
})
vi.mocked(generatePieceMetadata).mockImplementation(async (_key, _schema, _prompt, options) => {
await options?.onProgress?.({ phase: 'generation', message: 'Generating metadata' })
await options?.onProgress?.({ phase: 'validation', message: 'Validating metadata' })
return { title: 'Model title', cover: 'https://invalid.example/asset.jpg' }
})
vi.mocked(generateFieldValue).mockResolvedValue('Generated field')
vi.mocked(generateBody).mockResolvedValue('# Generated body')
vi.spyOn(Piece.prototype, 'setField').mockRejectedValue(new Error('No asset downloads'))
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
fixture.limits.maxOutputBytes = 1000
for (const method of ['writeFile', 'createFile', 'makeDirectory', 'delete'] as const) {
expect(fixture.storage[method]).not.toHaveBeenCalled()
}
expect(Piece.prototype.setField).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
function form(mode: 'create' | 'edit' = 'create') {
const data = new FormData()
data.set('mode', mode)
data.set('instructions', 'Find accurate details')
if (mode === 'create') {
data.set('name', 'Entered title')
data.set('type', 'books')
data.set('directory', 'books')
} else {
data.set('file', 'books/disk.books.md')
data.set('source', JSON.stringify('---\ntitle: Unsaved\n---\n\nActual unsaved body\n'))
data.set('target', 'body')
}
return data
}
function request(data = form(), signal?: AbortSignal) {
return new Request('http://localhost/api/admin/generate', { method: 'POST', body: data, signal })
}
function post(req = request()) {
return POST({ request: req } as Parameters<typeof POST>[0])
}
async function events(response: Response): Promise<GenerationEvent[]> {
return (await response.text())
.trim()
.split('\n\n')
.filter((frame) => frame.startsWith('event:'))
.map((frame) => {
const [name, data] = frame.split('\n')
return { type: name.slice(7), data: JSON.parse(data.slice(6)) } as GenerationEvent
})
}
function noProviderCalls() {
expect(generatePieceMetadata).not.toHaveBeenCalled()
expect(generateFieldValue).not.toHaveBeenCalled()
expect(generateBody).not.toHaveBeenCalled()
}
test('creation returns validated metadata with the entered title and an empty body, without writes', async () => {
const data = form()
data.append('files', new File(['attachment'], 'source.txt'))
data.set('api_key', 'untrusted-browser-key')
data.set('schema', JSON.stringify({ type: 'object' }))
const response = await post(request(data))
expect(response.headers.get('content-type')).toBe('text/event-stream')
const frames = await events(response)
const done = frames.at(-1)
expect(done).toMatchObject({ type: 'done', data: { state: 'completed' } })
if (done?.type !== 'done' || done.data.state !== 'completed') throw new Error('Missing result')
const result = done.data.result
if (result.kind !== 'creation') throw new Error('Wrong result')
expect(await extractFullMarkdown(result.markdown)).toMatchObject({
frontmatter: { title: 'Entered title', cover: 'https://invalid.example/asset.jpg' },
markdown: ''
})
expect(generatePieceMetadata).toHaveBeenCalledWith(
'server-secret',
schema,
expect.stringContaining('Entered title: "Entered title"'),
{
files: [Buffer.from('attachment')],
onProgress: expect.any(Function)
}
)
expect(JSON.stringify(frames)).not.toContain('server-secret')
// No examples/defaults are needed: creation does not initialize a placeholder piece.
expect(schema.properties.title).not.toHaveProperty('examples')
})
test('body generation receives the raw unsaved source, never disk content', async () => {
const data = form('edit')
const frames = await events(await post(request(data)))
expect(generateBody).toHaveBeenCalledWith(
'server-secret',
{ source: JSON.parse(data.get('source') as string), instructions: data.get('instructions') },
{ files: [], onProgress: expect.any(Function) }
)
expect(frames.at(-1)).toEqual({
type: 'done',
data: { state: 'completed', result: { kind: 'body', value: '# Generated body' } }
})
expect(fixture.storage.readFile).toHaveBeenCalledTimes(1)
})
test('a metadata field named body is not the Markdown body target', async () => {
const data = form('edit')
data.set('target', 'field')
data.set('key', 'body')
const frames = await events(await post(request(data)))
expect(generateBody).not.toHaveBeenCalled()
expect(generateFieldValue).toHaveBeenCalledWith(
'server-secret',
{
key: 'body',
schema,
source: JSON.parse(data.get('source') as string),
instructions: data.get('instructions')
},
{ files: [], onProgress: expect.any(Function) }
)
expect(frames.at(-1)).toMatchObject({
type: 'done',
data: { result: { kind: 'field', key: 'body', value: 'Generated field' } }
})
})
test.each([null, '', 'delete', new File(['create'], 'mode.txt')])(
'rejects invalid mode %s before choosing a generator and releases capacity',
async (mode) => {
const data = form()
if (mode === null) data.delete('mode')
else data.set('mode', mode)
await expect(post(request(data))).rejects.toMatchObject({ status: 400 })
expect(fixture.storage.readFile).not.toHaveBeenCalled()
expect(fixture.storage.stat).not.toHaveBeenCalled()
noProviderCalls()
expect((await events(await post())).at(-1)).toMatchObject({ data: { state: 'completed' } })
}
)
test.each(['unknown', 'toString', '__proto__', 'title.nested'])(
'rejects field %s before provider work',
async (key) => {
const data = form('edit')
data.set('target', 'field')
data.set('key', key)
await expect(post(request(data))).rejects.toMatchObject({ status: 400 })
noProviderCalls()
}
)
test.each([
'missing.books.md',
'../outside.books.md',
'books/disk.unknown.md',
'assets/disk.books.md',
'file.books.txt'
])('rejects invalid existing identity %s', async (file) => {
const data = form('edit')
data.set('file', file)
await expect(post(request(data))).rejects.toMatchObject({ status: 400 })
noProviderCalls()
})
test.each([
['type', 'unknown'],
['directory', '../outside'],
['directory', 'books/disk.books.md']
])('rejects bad creation %s', async (field, value) => {
const data = form()
data.set(field, value)
await expect(post(request(data))).rejects.toMatchObject({ status: 400 })
noProviderCalls()
})
test('rejects a missing schema and releases capacity', async () => {
fixture.storage.readFile.mockRejectedValueOnce(new Error('missing schema'))
await expect(post()).rejects.toMatchObject({ status: 400 })
noProviderCalls()
expect((await events(await post())).at(-1)).toMatchObject({ data: { state: 'completed' } })
})
test.each(['missing', '__proto__.title'])(
'rejects unsupported title configuration %s',
async (title) => {
fixture.config.pieces[0].fields.title = title
await expect(post()).rejects.toMatchObject({ status: 400 })
noProviderCalls()
}
)
test('supports a configured nested title without asset-setting operations', async () => {
schema = {
title: 'books',
type: 'object',
properties: {
details: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
},
required: ['details']
}
fixture.config.pieces[0].fields.title = 'details.name'
vi.mocked(generatePieceMetadata).mockResolvedValue({ details: { name: 'Model name' } })
const frames = await events(await post())
expect(JSON.stringify(frames.at(-1))).toContain('Entered title')
expect(JSON.stringify(frames.at(-1))).not.toContain('Model name')
})
test('revalidates the entered title against the completed metadata', async () => {
const data = form()
data.set('name', 'X') // Too short for the schema.
const frames = await events(await post(request(data)))
expect(frames.at(-1)).toEqual({ type: 'done', data: { state: 'failed' } })
expect(frames.some((frame) => frame.type === 'error')).toBe(true)
expect(JSON.stringify(frames)).not.toContain('cover')
})
test('caps the final serialized result as well as the core completion', async () => {
fixture.limits.maxOutputBytes = 10
const frames = await events(await post())
expect(frames.at(-1)).toEqual({ type: 'done', data: { state: 'failed' } })
})
test('invalid core completions are terminal failures, never partial results', async () => {
vi.mocked(generateFieldValue).mockImplementation(async (_key, _request, options) => {
await options?.onProgress?.({ phase: 'validation', message: 'Validating generated field' })
throw new Error('Generated field is invalid; secret provider request details')
})
const data = form('edit')
data.set('target', 'field')
data.set('key', 'title')
const frames = await events(await post(request(data)))
expect(frames.filter((event) => event.type === 'done')).toEqual([
{ type: 'done', data: { state: 'failed' } }
])
expect(JSON.stringify(frames)).not.toContain('secret provider request details')
})
test('unconfigured AI rejects without reading the body', async () => {
fixture.config.ai = undefined
const req = request()
await expect(post(req)).rejects.toMatchObject({ status: 503 })
expect(req.bodyUsed).toBe(false)
noProviderCalls()
})
test('reserves capacity before multipart parsing and releases it after malformed input', async () => {
let stream!: ReadableStreamDefaultController<Uint8Array>
const req = new Request('http://localhost/api/admin/generate', {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data; boundary=test' },
body: new ReadableStream({
start(value) {
stream = value
}
}),
duplex: 'half'
} as RequestInit)
const pending = post(req)
const rejected = request()
await expect(post(rejected)).rejects.toMatchObject({ status: 429 })
expect(rejected.bodyUsed).toBe(false)
stream.error(new Error('Incomplete upload'))
await expect(pending).rejects.toThrow('Incomplete upload')
noProviderCalls()
expect((await events(await post())).at(-1)).toMatchObject({ data: { state: 'completed' } })
})
test.each(['cancel', 'abort', 'detached failure'] as const)(
'keeps the slot through provider cleanup after %s',
async (disconnect) => {
let settle!: (value: string) => void
let fail!: (cause: Error) => void
let received: GenerationOptions | undefined
vi.mocked(generateBody).mockImplementationOnce(async (_key, _context, options) => {
received = options
return new Promise<string>((resolve, reject) => {
settle = resolve
fail = reject
})
})
const abort = new AbortController()
const response = await post(request(form('edit'), abort.signal))
if (disconnect === 'abort') abort.abort()
else await response.body!.cancel()
await received?.onProgress?.({ phase: 'generation', message: 'Still working' })
await expect(post()).rejects.toMatchObject({ status: 429 })
// Settling this fake core promise represents generation AND provider-file cleanup finishing.
if (disconnect === 'detached failure') fail(new Error('provider failed after disconnect'))
else settle('Abandoned result')
await vi.waitFor(async () => {
expect((await events(await post())).at(-1)).toMatchObject({ data: { state: 'completed' } })
})
}
)
+36
View File
@@ -0,0 +1,36 @@
import { error } from '@sveltejs/kit'
import { config } from './config'
import { readCreateInput, readEditInput } from './generation/input'
import { createDraftGenerator, createEditorGenerator, type Generate } from './generation/generator'
import { generationStream } from './generation/stream'
import { MAX_CONCURRENT_GENERATIONS } from './constants'
let active = 0
export async function generateResponse(request: Request): Promise<Response> {
const ai = config.ai
if (!ai) error(503, 'AI is not configured.')
if (active >= MAX_CONCURRENT_GENERATIONS) error(429, 'Generation is busy. Please try again.')
// Reserve before consuming multipart data; disconnected accepted work still owns its slot.
active++
const release = () => active--
try {
const form = await request.formData()
const mode = form.get('mode')
let generate: Generate
if (mode === 'create') {
const input = await readCreateInput(form)
generate = await createDraftGenerator(input, ai.api_key)
} else if (mode === 'edit') {
const input = await readEditInput(form)
generate = await createEditorGenerator(input, ai.api_key)
} else {
error(400, 'Choose creation or editing.')
}
return generationStream(request, generate, release)
} catch (cause) {
release()
throw cause
}
}
@@ -0,0 +1,103 @@
import {
findFrontmatterField,
generateBody,
generateFieldValue,
generatePieceMetadata,
makePieceMarkdown,
makePieceMarkdownString,
setFrontmatterValue,
type GenerationProgress
} from '@luzzle/core'
import { error } from '@sveltejs/kit'
import type { GenerationResult } from '$lib/generation/types'
import { config } from '../config'
import { getPieces } from '../pieces'
import { getStorage } from '../storage'
import type { CreateGenerationInput, EditGenerationInput } from './input'
export type Generate = (progress: (event: GenerationProgress) => void) => Promise<GenerationResult>
export async function createDraftGenerator(
input: CreateGenerationInput,
apiKey: string
): Promise<Generate> {
const piece = await getConfiguredPiece(input.type)
await archiveEntry(input.directory || '.', 'directory')
const title = config.pieces.find((entry) => entry.type === input.type)?.fields.title
if (
!title ||
title.split('.').some((part) => ['__proto__', 'constructor', 'prototype'].includes(part)) ||
findFrontmatterField(piece.fields, title)?.type !== 'string'
) {
error(400, 'This piece type needs a string title field.')
}
return async (progress) => {
const frontmatter = await generatePieceMetadata(
apiKey,
piece.schema,
`${input.instructions}\n\nEntered title: ${JSON.stringify(input.name)}`,
{ files: input.files, onProgress: progress }
)
// Do not use setField(): generation must not download assets or persist anything.
setFrontmatterValue(frontmatter, title, input.name)
// The destination is derived afresh on explicit Create, not reserved during generation.
const draft = makePieceMarkdown('', input.type, '', frontmatter)
if (!piece.validate(draft).isValid) throw new Error('Invalid generated draft.')
return { kind: 'creation', markdown: makePieceMarkdownString(draft) }
}
}
export async function createEditorGenerator(
input: EditGenerationInput,
apiKey: string
): Promise<Generate> {
const pieces = getPieces()
const file = pieces.parseFilename(input.file)
if (file.format !== '.md' || pieces.isAsset(input.file)) {
error(400, 'Choose an archive piece.')
}
const piece = await getConfiguredPiece(file.type)
await archiveEntry(input.file, 'file')
const target = input.target
if (
target.kind === 'field' &&
(target.key === '__proto__' || !Object.hasOwn(piece.schema.properties, target.key))
) {
error(400, 'Choose a top-level metadata field.')
}
return async (progress) => {
const context = { source: input.source, instructions: input.instructions }
const generationOptions = { files: input.files, onProgress: progress }
if (target.kind === 'body') {
return { kind: 'body', value: await generateBody(apiKey, context, generationOptions) }
}
return {
kind: 'field',
key: target.key,
value: await generateFieldValue(
apiKey,
{ ...context, schema: piece.schema, key: target.key },
generationOptions
)
}
}
}
async function archiveEntry(path: string, type: 'file' | 'directory') {
try {
if ((await getStorage().stat(path)).type !== type) throw new Error('Wrong entry type')
} catch {
error(400, `Choose an existing archive ${type}.`)
}
}
async function getConfiguredPiece(type: string | null) {
if (!type || !config.pieces.some((entry) => entry.type === type)) {
error(400, 'Choose a configured piece type.')
}
try {
return await getPieces().getPiece(type)
} catch {
error(400, 'Could not prepare the piece schema.')
}
}
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readCreateInput, readEditInput } from './input'
import { MAX_SOURCE_CHARS, MAX_INSTRUCTION_CHARS } from '../constants'
vi.mock('@luzzle/core', () => ({
DEFAULT_GENERATION_LIMITS: { maxFiles: 2, maxFileBytes: 8, maxTotalFileBytes: 12 }
}))
vi.mock('../constants', () => ({ MAX_SOURCE_CHARS: 128, MAX_INSTRUCTION_CHARS: 32 }))
function createForm(): FormData {
const form = new FormData()
form.set('name', 'Dune')
form.set('type', 'book')
return form
}
function editForm(): FormData {
const form = new FormData()
form.set('file', 'library/dune.book.md')
form.set('source', JSON.stringify(''))
form.set('target', 'body')
return form
}
function attach(form: FormData, size: number, name = 'reference.bin') {
form.append('files', new File([new Uint8Array(size).fill(255)], name))
}
afterEach(() => vi.restoreAllMocks())
describe('readCreateInput', () => {
it('accepts create input with optional fields omitted', async () => {
await expect(readCreateInput(createForm())).resolves.toEqual({
name: 'Dune',
type: 'book',
directory: '',
instructions: '',
files: []
})
})
it('preserves create text, returns binary Buffers and ignores unrelated fields', async () => {
const form = createForm()
form.set('directory', 'library/science fiction')
form.set('instructions', ' Keep the original tone. ')
form.set('unrelated', 'ignored')
attach(form, 8)
attach(form, 4)
const result = await readCreateInput(form)
expect(result).toEqual({
name: 'Dune',
type: 'book',
directory: 'library/science fiction',
instructions: ' Keep the original tone. ',
files: [Buffer.alloc(8, 255), Buffer.alloc(4, 255)]
})
expect(result.files.every(Buffer.isBuffer)).toBe(true)
})
it.each(['name', 'type'])('requires nonblank text for %s', async (field) => {
const form = createForm()
form.delete(field)
await expect(readCreateInput(form)).rejects.toMatchObject({ status: 400 })
for (const value of [' \t ', new File(['value'], 'value.txt')]) {
form.set(field, value)
await expect(readCreateInput(form)).rejects.toMatchObject({ status: 400 })
}
})
it('requires optional directory to be text', async () => {
const form = createForm()
form.set('directory', new File(['value'], 'value.txt'))
await expect(readCreateInput(form)).rejects.toMatchObject({ status: 400 })
})
it.each([
['attachment type', [1], 400],
['per-file bytes', [1, 9], 413],
['combined bytes', [8, 5], 413],
['file count', [1, 1, 1], 413]
] as const)('checks %s before reading any attachment buffers', async (reason, sizes, status) => {
const form = createForm()
for (const size of sizes) attach(form, size)
const reads = form.getAll('files').map((file) => vi.spyOn(file as File, 'arrayBuffer'))
if (reason === 'attachment type') form.append('files', 'not a file')
await expect(readCreateInput(form)).rejects.toMatchObject({ status })
for (const bufferRead of reads) expect(bufferRead).not.toHaveBeenCalled()
})
it('ignores empty files without counting them toward attachment limits', async () => {
const form = createForm()
for (let i = 0; i < 3; i++) attach(form, 0, 'empty.txt')
attach(form, 0, '')
attach(form, 8)
attach(form, 4)
await expect(readCreateInput(form)).resolves.toMatchObject({
files: [Buffer.alloc(8, 255), Buffer.alloc(4, 255)]
})
})
})
describe('readEditInput', () => {
it.each(['', '---\ntitle: Dune\n---\n\n body\n', ' body\r\nsecond line\r\n'])(
'preserves JSON-encoded editor source exactly: %j',
async (source) => {
const form = editForm()
form.set('source', JSON.stringify(source))
await expect(readEditInput(form)).resolves.toEqual({
file: 'library/dune.book.md',
source,
target: { kind: 'body' },
instructions: '',
files: []
})
}
)
it.each(['title', 'body'])('keeps field target %s distinct from the body target', async (key) => {
const form = editForm()
form.set('target', 'field')
form.set('key', key)
form.set('unrelated', 'ignored')
await expect(readEditInput(form)).resolves.toMatchObject({ target: { kind: 'field', key } })
})
it.each(['file', 'source', 'target'])('requires text for %s', async (field) => {
const form = editForm()
form.delete(field)
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
form.set(field, new File(['value'], 'value.txt'))
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
})
it('rejects a blank file', async () => {
const form = editForm()
form.set('file', ' \t ')
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
})
it('rejects unsupported targets and requires a text key for field edits', async () => {
const form = editForm()
form.set('target', 'unknown')
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
form.set('target', 'field')
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
for (const key of ['', new File(['title'], 'key.txt')]) {
form.set('key', key)
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
}
})
it.each(['not json', 'null', '{}', '123'])(
'rejects nonstring source JSON: %s',
async (source) => {
const form = editForm()
form.set('source', source)
await expect(readEditInput(form)).rejects.toMatchObject({ status: 400 })
}
)
it('bounds decoded source characters, not UTF-8 bytes', async () => {
const form = editForm()
const source = 'é'.repeat(MAX_SOURCE_CHARS)
form.set('source', JSON.stringify(source))
await expect(readEditInput(form)).resolves.toMatchObject({ source })
form.set('source', JSON.stringify(source + 'é'))
await expect(readEditInput(form)).rejects.toMatchObject({ status: 413 })
})
})
describe('instructions in both readers', () => {
it('requires optional instructions to be text', async () => {
const create = createForm()
const edit = editForm()
create.set('instructions', new File(['value'], 'value.txt'))
edit.set('instructions', new File(['value'], 'value.txt'))
await expect(readCreateInput(create)).rejects.toMatchObject({ status: 400 })
await expect(readEditInput(edit)).rejects.toMatchObject({ status: 400 })
})
it('preserves whitespace and bounds characters, not UTF-8 bytes', async () => {
const create = createForm()
const edit = editForm()
const instructions = ' ' + 'é'.repeat(MAX_INSTRUCTION_CHARS - 2) + ' '
create.set('instructions', instructions)
edit.set('instructions', instructions)
await expect(readCreateInput(create)).resolves.toMatchObject({ instructions })
await expect(readEditInput(edit)).resolves.toMatchObject({ instructions })
create.set('instructions', instructions + 'é')
edit.set('instructions', instructions + 'é')
await expect(readCreateInput(create)).rejects.toMatchObject({ status: 413 })
await expect(readEditInput(edit)).rejects.toMatchObject({ status: 413 })
})
})
@@ -0,0 +1,84 @@
import { error } from '@sveltejs/kit'
import { DEFAULT_GENERATION_LIMITS } from '@luzzle/core'
import type { GenerationTarget } from '$lib/generation/types'
import { MAX_SOURCE_CHARS, MAX_INSTRUCTION_CHARS } from '../constants'
export type CreateGenerationInput = {
name: string
type: string
directory: string
instructions: string
files: Buffer[]
}
export type EditGenerationInput = {
file: string
source: string
target: GenerationTarget
instructions: string
files: Buffer[]
}
export async function readCreateInput(form: FormData): Promise<CreateGenerationInput> {
const name = form.get('name')
const type = form.get('type')
const directory = form.get('directory') ?? ''
const instructions = form.get('instructions') ?? ''
if (typeof name !== 'string' || !name.trim()) error(400, 'Name is required.')
if (typeof type !== 'string' || !type.trim()) error(400, 'Piece type is required.')
if (typeof directory !== 'string') error(400, 'Directory must be text.')
if (typeof instructions !== 'string') error(400, 'Instructions must be text.')
if (instructions.length > MAX_INSTRUCTION_CHARS) error(413, 'Instructions are too long.')
return { name, type, directory, instructions, files: await readAttachments(form) }
}
export async function readEditInput(form: FormData): Promise<EditGenerationInput> {
const file = form.get('file')
const encodedSource = form.get('source')
const targetKind = form.get('target')
const key = form.get('key')
const instructions = form.get('instructions') ?? ''
if (typeof file !== 'string' || !file.trim()) error(400, 'Piece file is required.')
if (typeof encodedSource !== 'string') error(400, 'Editor source is required.')
if (typeof instructions !== 'string') error(400, 'Instructions must be text.')
if (instructions.length > MAX_INSTRUCTION_CHARS) error(413, 'Instructions are too long.')
let target: GenerationTarget
if (targetKind === 'body') {
target = { kind: 'body' }
} else if (targetKind === 'field' && typeof key === 'string' && key) {
target = { kind: 'field', key }
} else {
error(400, 'Choose a field or the body.')
}
// JSON preserves the exact editor buffer; multipart text normalizes line endings.
let source: unknown
try {
source = JSON.parse(encodedSource)
} catch {
error(400, 'Invalid editor source.')
}
if (typeof source !== 'string') error(400, 'Editor source must be text.')
if (source.length > MAX_SOURCE_CHARS) error(413, 'Editor source is too long.')
return { file, source, target, instructions, files: await readAttachments(form) }
}
async function readAttachments(form: FormData): Promise<Buffer[]> {
const files: File[] = []
let bytes = 0
for (const file of form.getAll('files')) {
if (typeof file === 'string') error(400, 'Attachments must be files.')
if (file.size === 0) continue
if (file.size > DEFAULT_GENERATION_LIMITS.maxFileBytes) error(413, 'Attachment is too large.')
bytes += file.size
if (bytes > DEFAULT_GENERATION_LIMITS.maxTotalFileBytes)
error(413, 'Attachments are too large.')
files.push(file)
}
if (files.length > DEFAULT_GENERATION_LIMITS.maxFiles) error(413, 'Too many attachments.')
const buffers: Buffer[] = []
for (const file of files) buffers.push(Buffer.from(await file.arrayBuffer()))
return buffers
}
@@ -0,0 +1,372 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import type { GenerationEvent, GenerationResult } from '$lib/generation/types'
import type { Generate } from './generator'
import { generationStream } from './stream'
const result: GenerationResult = { kind: 'body', value: 'café\n🧩 日本語' }
const decoder = new TextDecoder()
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function events(text: string): GenerationEvent[] {
return text
.split('\n\n')
.filter((frame) => frame.startsWith('event:'))
.map((frame) => {
const [type, data] = frame.split('\n')
return { type: type.slice(7), data: JSON.parse(data.slice(6)) } as GenerationEvent
})
}
async function read(reader: ReadableStreamDefaultReader<Uint8Array>) {
const chunk = await reader.read()
expect(chunk.done).toBe(false)
expect(chunk.value).toBeInstanceOf(Uint8Array)
return decoder.decode(chunk.value)
}
async function drain(reader: ReadableStreamDefaultReader<Uint8Array>) {
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) return text
text += decoder.decode(value)
}
}
function setup(generate?: Generate, abort = new AbortController()) {
const completion = deferred<GenerationResult>()
let progress!: Parameters<Generate>[0]
const fake = vi.fn<Generate>((notify) => {
progress = notify
return generate ? generate(notify) : completion.promise
})
const request = new Request('http://localhost/api/admin/generate', { signal: abort.signal })
const removeListener = vi.spyOn(request.signal, 'removeEventListener')
const release = vi.fn()
const response = generationStream(request, fake, release)
return { completion, progress, fake, request, abort, removeListener, release, response }
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('generationStream', () => {
test('streams early state and progress, then exactly one validated UTF-8 result', async () => {
const s = setup()
const reader = s.response.body!.getReader()
expect(s.response).toBeInstanceOf(Response)
expect(Object.fromEntries(s.response.headers)).toEqual({
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-accel-buffering': 'no'
})
let text = await read(reader)
expect(events(text)).toEqual([{ type: 'state', data: { state: 'running' } }])
text += await read(reader)
expect(events(text)[1]).toEqual({
type: 'phase',
data: [
{
phase: 'preparation',
status: 'running',
started_at: 1_000,
finished_at: null,
message: 'Preparing generation'
}
]
})
vi.setSystemTime(1_100)
s.progress({ phase: 'preparation', message: 'Reading attachments' })
text += await read(reader)
vi.setSystemTime(1_200)
s.progress({ phase: 'generation', message: 'Waiting for provider' })
text += await read(reader)
vi.setSystemTime(1_300)
s.progress({ phase: 'validation', message: 'Checking output' })
text += await read(reader)
expect(events(text).map((event) => event.type)).toEqual([
'state',
'phase',
'phase',
'phase',
'phase'
])
expect(text).not.toMatch(/token|result|café/)
expect(s.release).not.toHaveBeenCalled()
expect(events(text).at(-1)).toEqual({
type: 'phase',
data: [
{
phase: 'preparation',
status: 'completed',
started_at: 1_000,
finished_at: 1_200,
message: 'Reading attachments'
},
{
phase: 'generation',
status: 'completed',
started_at: 1_200,
finished_at: 1_300,
message: 'Waiting for provider'
},
{
phase: 'validation',
status: 'running',
started_at: 1_300,
finished_at: null,
message: 'Checking output'
}
]
})
const next = reader.read()
const received = vi.fn()
void next.then(received)
await vi.advanceTimersByTimeAsync(0)
expect(received).not.toHaveBeenCalled()
vi.setSystemTime(1_400)
s.completion.resolve(result)
text += decoder.decode((await next).value)
text += await drain(reader)
const frames = events(text)
expect(frames.map((event) => event.type)).toEqual([
'state',
'phase',
'phase',
'phase',
'phase',
'phase',
'done'
])
expect(frames.filter((event) => event.type === 'done')).toEqual([
{ type: 'done', data: { state: 'completed', result } }
])
expect(frames.at(-2)).toMatchObject({
type: 'phase',
data: [
{ status: 'completed' },
{ status: 'completed' },
{ status: 'completed', started_at: 1_300, finished_at: 1_400 }
]
})
expect(frames.at(-1)?.type).toBe('done')
expect(s.release).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
expect(s.removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
})
test('supports native Response.text() with multibyte output', async () => {
const s = setup(async () => result)
const text = await s.response.text()
expect(text).toContain('café\\n🧩 日本語')
expect(events(text).at(-1)).toEqual({
type: 'done',
data: { state: 'completed', result }
})
expect(s.release).toHaveBeenCalledTimes(1)
})
test.each(['reject', 'throw'] as const)(
'%s sends one failed done, a safe error and no result',
async (mode) => {
const secret = 'api-key=secret prompt=private filename=/private/scan.pdf provider-stack'
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const s = setup(
mode === 'throw'
? () => {
throw new Error(secret)
}
: undefined
)
if (mode === 'reject') {
s.progress({ phase: 'validation', message: 'Checking output' })
vi.setSystemTime(1_200)
s.completion.reject(new Error(secret))
}
const text = await s.response.text()
const frames = events(text)
expect(frames.filter((event) => event.type === 'done')).toEqual([
{ type: 'done', data: { state: 'failed' } }
])
expect(frames.filter((event) => event.type === 'error')).toEqual([
{ type: 'error', data: { message: 'Generation failed. Please try again.' } }
])
const phase = frames.filter((event) => event.type === 'phase').at(-1)!
expect(phase.data.at(-1)).toMatchObject({
status: 'failed',
finished_at: mode === 'reject' ? 1_200 : 1_000
})
expect(text).not.toMatch(/result|api-key|private|provider-stack/)
expect(log.mock.calls).toEqual([['AI generation failed.']])
expect(s.release).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
}
)
test('heartbeats every 15 seconds through and beyond 300 seconds, not an operation deadline', async () => {
const s = setup()
const reader = s.response.body!.getReader()
await read(reader)
await read(reader)
for (let tick = 0; tick < 21; tick++) {
const next = reader.read()
const received = vi.fn()
void next.then(received)
await vi.advanceTimersByTimeAsync(14_999)
expect(received).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
const chunk = await next
expect(chunk.done).toBe(false)
expect(decoder.decode(chunk.value)).toBe(': heartbeat\n\n')
expect(s.release).not.toHaveBeenCalled()
}
s.completion.resolve(result)
expect(events(await drain(reader)).at(-1)).toEqual({
type: 'done',
data: { state: 'completed', result }
})
expect(vi.getTimerCount()).toBe(0)
})
test('does not accumulate queued heartbeats behind an unread response', async () => {
const s = setup()
await vi.advanceTimersByTimeAsync(300_000)
const reader = s.response.body!.getReader()
expect(events(await read(reader))[0].type).toBe('state')
expect(events(await read(reader))[0].type).toBe('phase')
// Once the initial frames drain, at most one unread heartbeat can queue.
await vi.advanceTimersByTimeAsync(300_000)
expect(await read(reader)).toBe(': heartbeat\n\n')
const received = vi.fn()
const next = reader.read()
void next.then(received)
await vi.advanceTimersByTimeAsync(14_999)
expect(received).not.toHaveBeenCalled()
s.completion.resolve(result)
expect(events(decoder.decode((await next).value))[0].type).toBe('phase')
expect(events(await drain(reader))).toEqual([
{ type: 'done', data: { state: 'completed', result } }
])
})
test.each([
['abort', 'success'],
['abort', 'provider failure'],
['abort', 'cleanup failure'],
['cancel', 'success'],
['cancel', 'provider failure'],
['cancel', 'cleanup failure']
] as const)(
'%s detaches but retains the slot through %s and cleanup',
async (disconnect, outcome) => {
const provider = deferred<GenerationResult>()
const cleanup = deferred<void>()
const cleaning = vi.fn()
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const unhandled = vi.fn()
process.on('unhandledRejection', unhandled)
try {
const s = setup(async (progress) => {
try {
const value = await provider.promise
progress({ phase: 'validation', message: 'Checking abandoned output' })
return value
} finally {
cleaning()
await cleanup.promise
}
})
const reader = s.response.body!.getReader()
await read(reader)
await read(reader)
if (disconnect === 'abort') s.abort.abort()
else await reader.cancel()
expect(s.request.signal.aborted).toBe(disconnect === 'abort')
expect(await reader.read()).toEqual({ done: true, value: undefined })
expect(s.removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(315_000)
expect(s.fake).toHaveBeenCalledTimes(1)
expect(cleaning).not.toHaveBeenCalled()
expect(s.release).not.toHaveBeenCalled()
if (outcome === 'provider failure') provider.reject(new Error('secret provider details'))
else provider.resolve(result)
await vi.advanceTimersByTimeAsync(0)
expect(cleaning).toHaveBeenCalledTimes(1)
expect(s.release).not.toHaveBeenCalled()
if (outcome === 'cleanup failure') cleanup.reject(new Error('secret cleanup details'))
else cleanup.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(s.release).toHaveBeenCalledTimes(1)
expect(await reader.read()).toEqual({ done: true, value: undefined })
expect(log.mock.calls).toEqual(outcome === 'success' ? [] : [['AI generation failed.']])
expect(unhandled).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
} finally {
process.off('unhandledRejection', unhandled)
}
}
)
test('an already-aborted accepted request still runs and releases only after settling', async () => {
const abort = new AbortController()
abort.abort()
const s = setup(undefined, abort)
expect(s.fake).toHaveBeenCalledTimes(1)
expect(await s.response.body!.getReader().read()).toEqual({ done: true, value: undefined })
expect(s.removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(vi.getTimerCount()).toBe(0)
expect(s.release).not.toHaveBeenCalled()
s.completion.resolve(result)
await vi.advanceTimersByTimeAsync(0)
expect(s.release).toHaveBeenCalledTimes(1)
})
test.each(['initial', 'heartbeat'] as const)(
'%s enqueue failure detaches listeners and timers, not work',
async (when) => {
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
const fail = () =>
enqueue.mockImplementationOnce(() => {
throw new Error('closed transport')
})
if (when === 'initial') fail()
const s = setup()
const reader = s.response.body!.getReader()
if (when === 'heartbeat') {
await read(reader)
await read(reader)
fail()
await vi.advanceTimersByTimeAsync(15_000)
}
expect(await reader.read()).toEqual({ done: true, value: undefined })
expect(s.removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(vi.getTimerCount()).toBe(0)
expect(s.release).not.toHaveBeenCalled()
s.completion.resolve(result)
await vi.advanceTimersByTimeAsync(0)
expect(s.release).toHaveBeenCalledTimes(1)
}
)
})
@@ -0,0 +1,75 @@
import { DEFAULT_GENERATION_LIMITS, type GenerationProgress } from '@luzzle/core'
import type { ProgressPhase } from '$lib/components/progress'
import type { GenerationEvent } from '$lib/generation/types'
import { createEventStream } from '../sse'
import type { Generate } from './generator'
export function generationStream(
request: Request,
generate: Generate,
release: () => void
): Response {
const events = createEventStream(request)
const phases: ProgressPhase[] = []
function emit(event: GenerationEvent) {
events.emit(event.type, event.data)
}
function finishPhase(status: 'completed' | 'failed') {
const current = phases.at(-1)
if (current?.status === 'running') {
current.status = status
current.finished_at = Date.now()
}
}
function progress(event: GenerationProgress) {
if (events.signal.aborted) return
const current = phases.at(-1)
if (current?.phase === event.phase) {
current.message = event.message
} else {
finishPhase('completed')
phases.push({
phase: event.phase,
status: 'running',
started_at: Date.now(),
finished_at: null,
message: event.message
})
}
emit({ type: 'phase', data: phases })
}
async function run() {
try {
const result = await generate(progress)
if (
Buffer.byteLength(JSON.stringify(result), 'utf8') > DEFAULT_GENERATION_LIMITS.maxOutputBytes
) {
throw new Error('Generated result is too large.')
}
finishPhase('completed')
emit({ type: 'phase', data: phases })
emit({ type: 'done', data: { state: 'completed', result } })
} catch {
// Provider exceptions may contain request details. Never send or log their raw text.
console.error('AI generation failed.')
finishPhase('failed')
emit({ type: 'phase', data: phases })
emit({ type: 'error', data: { message: 'Generation failed. Please try again.' } })
emit({ type: 'done', data: { state: 'failed' } })
} finally {
// The core promise settles only after provider-file cleanup, even without a reader.
release()
events.close()
}
}
emit({ type: 'state', data: { state: 'running' } })
progress({ phase: 'preparation', message: 'Preparing generation' })
// Accepted work runs even if the observer has already disconnected.
void run()
return events.response
}
+259
View File
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { HEARTBEAT_MS } from './constants.js'
import { createEventStream, encodeEvent } from './sse.js'
describe('encodeEvent', () => {
test('preserves unicode and JSON-escapes newlines in a single data line', () => {
expect(encodeEvent('log', { message: 'café 🧩\nnext\r\n"quoted"' })).toBe(
'event: log\ndata: {"message":"café 🧩\\nnext\\r\\n\\"quoted\\""}\n\n'
)
})
test('puts the cursor id between the event and data lines', () => {
expect(encodeEvent('cursor', { sync: 7, publish: 2 }, '{"sync":7,"publish":2}')).toBe(
'event: cursor\nid: {"sync":7,"publish":2}\ndata: {"sync":7,"publish":2}\n\n'
)
})
test.each([undefined, ''])('omits a falsy id (%s)', (id) => {
expect(encodeEvent('done', null, id)).toBe('event: done\ndata: null\n\n')
})
test('includes a truthy zero string id', () => {
expect(encodeEvent('state', [], '0')).toBe('event: state\nid: 0\ndata: []\n\n')
})
})
describe('createEventStream', () => {
const streams: ReturnType<typeof createEventStream>[] = []
function open(request = new Request('http://localhost/events')) {
const stream = createEventStream(request)
streams.push(stream)
return stream
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
for (const stream of streams.splice(0)) stream.close()
vi.restoreAllMocks()
vi.useRealTimers()
})
test('returns a native response with the existing SSE headers', () => {
const { response } = open()
expect(response).toBeInstanceOf(Response)
expect(response.status).toBe(200)
expect(Object.fromEntries(response.headers)).toEqual({
'cache-control': 'no-cache',
connection: 'keep-alive',
'content-type': 'text/event-stream',
'x-accel-buffering': 'no'
})
})
test('encodes unicode as bytes consumable by native Response.text()', async () => {
const { response, emit, close } = open()
emit('log', { message: 'café 🧩 日本語\nnext\r\n"quoted"' })
close()
await expect(response.text()).resolves.toBe(
'event: log\ndata: {"message":"café 🧩 日本語\\nnext\\r\\n\\"quoted\\""}\n\n'
)
})
test('preserves cursor ids and event framing unchanged on the wire', async () => {
const { response, emit, close } = open()
emit('cursor', { sync: 7, publish: 2 }, '{"sync":7,"publish":2}')
emit('state', [], '0')
emit('done', null)
close()
await expect(response.text()).resolves.toBe(
'event: cursor\nid: {"sync":7,"publish":2}\ndata: {"sync":7,"publish":2}\n\n' +
'event: state\nid: 0\ndata: []\n\n' +
'event: done\ndata: null\n\n'
)
})
test('keeps a quiet observer alive with a byte heartbeat every 15 seconds', async () => {
const { response, signal } = open()
const reader = response.body!.getReader()
const received = vi.fn()
const first = reader.read().then((chunk) => {
received(chunk)
return chunk
})
expect(HEARTBEAT_MS).toBe(15_000)
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS - 1)
expect(received).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await expect(first).resolves.toEqual({
done: false,
value: new TextEncoder().encode(': heartbeat\n\n')
})
const second = reader.read()
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS)
await expect(second).resolves.toEqual({
done: false,
value: new TextEncoder().encode(': heartbeat\n\n')
})
expect(signal.aborted).toBe(false)
await reader.cancel()
})
test('has no overall stream timeout even after a day without producer events', async () => {
const { response, emit, signal } = open()
const reader = response.body!.getReader()
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000)
expect(signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(1)
await expect(reader.read()).resolves.toEqual({
done: false,
value: new TextEncoder().encode(': heartbeat\n\n')
})
emit('done', { ok: true })
await expect(reader.read()).resolves.toEqual({
done: false,
value: new TextEncoder().encode('event: done\ndata: {"ok":true}\n\n')
})
await reader.cancel()
})
test('does not accumulate unread heartbeats behind events or other heartbeats', async () => {
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
const { response, emit } = open()
const reader = response.body!.getReader()
emit('state', { ready: true })
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 100)
expect(enqueue).toHaveBeenCalledTimes(1)
await expect(reader.read()).resolves.toEqual({
done: false,
value: new TextEncoder().encode('event: state\ndata: {"ready":true}\n\n')
})
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 100)
expect(enqueue).toHaveBeenCalledTimes(2)
await expect(reader.read()).resolves.toEqual({
done: false,
value: new TextEncoder().encode(': heartbeat\n\n')
})
// Draining the queue allows future heartbeats, not a backlog of old ones.
const received = vi.fn()
const next = reader.read().then(received)
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS - 1)
expect(received).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await next
expect(enqueue).toHaveBeenCalledTimes(3)
await reader.cancel()
})
test.each([
'explicit close',
'request abort',
'reader cancellation',
'event enqueue failure',
'heartbeat enqueue failure'
])('%s ends only observation and cleans up exactly once', async (cause) => {
const abort = new AbortController()
const request = new Request('http://localhost/events', { signal: abort.signal })
const removeListener = vi.spyOn(request.signal, 'removeEventListener')
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
const closeController = vi.spyOn(ReadableStreamDefaultController.prototype, 'close')
const { response, emit, close, signal } = open(request)
const reader = response.body!.getReader()
const observerClosed = vi.fn()
signal.addEventListener('abort', observerClosed)
const pending = reader.read()
expect(signal).not.toBe(request.signal)
expect(signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(1)
switch (cause) {
case 'explicit close':
close()
break
case 'request abort':
abort.abort()
break
case 'reader cancellation':
await reader.cancel()
break
default:
// Keep native Request/Response/streams; fail only the synchronous enqueue.
enqueue.mockImplementationOnce(() => {
throw new TypeError('observer is no longer readable')
})
if (cause === 'event enqueue failure') {
expect(() => emit('state', {})).not.toThrow()
} else {
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS)
}
}
expect(signal.aborted).toBe(true)
expect(request.signal.aborted).toBe(cause === 'request abort')
expect(observerClosed).toHaveBeenCalledTimes(1)
expect(removeListener).toHaveBeenCalledExactlyOnceWith('abort', close)
expect(closeController).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
await expect(pending).resolves.toEqual({ done: true, value: undefined })
const enqueueCalls = enqueue.mock.calls.length
const toJSON = vi.fn(() => {
throw new Error('closed observers must not serialize frames')
})
expect(() => emit('late', { toJSON }, 'late-cursor')).not.toThrow()
close()
close()
abort.abort()
await reader.cancel()
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 100)
expect(toJSON).not.toHaveBeenCalled()
expect(enqueue).toHaveBeenCalledTimes(enqueueCalls)
expect(observerClosed).toHaveBeenCalledTimes(1)
expect(removeListener).toHaveBeenCalledTimes(1)
expect(closeController).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
await expect(reader.read()).resolves.toEqual({ done: true, value: undefined })
})
test('an already-aborted request closes immediately without starting any heartbeat', async () => {
const abort = new AbortController()
abort.abort()
const request = new Request('http://localhost/events', { signal: abort.signal })
const removeListener = vi.spyOn(request.signal, 'removeEventListener')
const interval = vi.spyOn(globalThis, 'setInterval')
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
const { response, emit, close, signal } = open(request)
const toJSON = vi.fn(() => {
throw new Error('already-closed observers must not serialize frames')
})
expect(signal.aborted).toBe(true)
expect(removeListener).toHaveBeenCalledExactlyOnceWith('abort', close)
expect(interval).not.toHaveBeenCalled()
expect(() => emit('late', { toJSON })).not.toThrow()
close()
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 100)
expect(toJSON).not.toHaveBeenCalled()
expect(enqueue).not.toHaveBeenCalled()
expect(removeListener).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
await expect(response.text()).resolves.toBe('')
})
})
+70
View File
@@ -0,0 +1,70 @@
import { HEARTBEAT_MS } from './constants'
export const SSE_HEADERS = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
export function encodeEvent(event: string, data: unknown, id?: string): string {
const lines = [`event: ${event}`]
if (id) lines.push(`id: ${id}`)
lines.push(`data: ${JSON.stringify(data)}`)
return lines.join('\n') + '\n\n'
}
/** Owns the response observer, not the work producing its events. */
export function createEventStream(request: Request) {
const observer = new AbortController()
const encoder = new TextEncoder()
let controller: ReadableStreamDefaultController<Uint8Array>
let heartbeat: ReturnType<typeof setInterval> | undefined
function close() {
if (observer.signal.aborted) return
clearInterval(heartbeat)
request.signal.removeEventListener('abort', close)
observer.abort()
try {
controller.close()
} catch {
// Reader cancellation or an enqueue failure may already have closed it.
}
}
function send(text: string) {
if (observer.signal.aborted) return
try {
controller.enqueue(encoder.encode(text))
} catch {
close()
}
}
const stream = new ReadableStream<Uint8Array>({
start(value) {
controller = value
request.signal.addEventListener('abort', close, { once: true })
if (request.signal.aborted) {
close()
return
}
heartbeat = setInterval(() => {
// Don't accumulate heartbeats behind an unread response.
if ((controller.desiredSize ?? 0) > 0) send(': heartbeat\n\n')
}, HEARTBEAT_MS)
},
cancel: close
})
return {
response: new Response(stream, { headers: SSE_HEADERS }),
// Only observation ends here. Producers must not pass this signal to accepted work.
signal: observer.signal,
emit(event: string, data: unknown, id?: string) {
if (!observer.signal.aborted) send(encodeEvent(event, data, id))
},
close
}
}
@@ -0,0 +1,391 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { HEARTBEAT_MS, WORKFLOW_POLL_INTERVAL_MS } from '../constants.js'
import { streamJobProgress } from './stream.js'
const mocks = vi.hoisted(() => {
const query = {
selectAll: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
execute: vi.fn()
}
return {
query,
selectFrom: vi.fn(() => query),
getOpenWorkflowDb: vi.fn(() => ({})),
getOpenWorkflow: vi.fn(),
getWorkflowRun: vi.fn(),
getStepAttempts: vi.fn()
}
})
vi.mock('$lib/server/database/index.js', () => ({
db: { selectFrom: mocks.selectFrom }
}))
vi.mock('./index.js', () => ({
getOpenWorkflowDb: mocks.getOpenWorkflowDb,
getOpenWorkflow: mocks.getOpenWorkflow
}))
vi.mock('@luzzle/web.jobs', () => ({
getWorkflowRun: mocks.getWorkflowRun,
getStepAttempts: mocks.getStepAttempts
}))
const run = {
id: 'job-1',
workflow_name: 'Publish',
status: 'completed',
error: null
}
const step = {
phase: 'sync',
status: 'succeeded',
started_at: '2026-06-20T00:00:00Z',
finished_at: '2026-06-20T00:00:01Z',
message: null
}
const log = { job_id: 'job-1', phase: 'sync', line_number: 3, message: 'café\n🧩' }
const phaseFrame =
'event: phase\ndata: [{"job_id":"job-1","phase":"sync","status":"completed","started_at":1781913600000,"finished_at":1781913601000,"message":null}]\n\n'
function openStream({
header,
cursor,
jobClass = 'Publish',
signal
}: {
header?: string
cursor?: string
jobClass?: string | string[]
signal?: AbortSignal
} = {}) {
const url = new URL('http://localhost/jobs/job-1/stream')
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
const request = new Request(url, {
headers: header === undefined ? {} : { 'Last-Event-ID': header },
signal
})
return streamJobProgress({ jobId: 'job-1', jobClass, request, url })
}
async function readChunks(response: Response): Promise<string[]> {
const reader = response.body!.getReader()
const chunks: string[] = []
while (true) {
const { value, done } = await reader.read()
if (done) return chunks
expect(value).toBeInstanceOf(Uint8Array)
chunks.push(new TextDecoder().decode(value))
}
}
async function readFrame(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> {
const { value, done } = await reader.read()
expect(done).toBe(false)
expect(value).toBeInstanceOf(Uint8Array)
return new TextDecoder().decode(value)
}
function deferredLogs() {
let resolve!: (logs: (typeof log)[]) => void
const promise = new Promise<(typeof log)[]>((done) => {
resolve = done
})
return { promise, resolve }
}
beforeEach(() => {
vi.resetAllMocks()
mocks.selectFrom.mockReturnValue(mocks.query)
mocks.query.selectAll.mockReturnThis()
mocks.query.where.mockReturnThis()
mocks.query.orderBy.mockReturnThis()
mocks.getOpenWorkflowDb.mockReturnValue({})
mocks.getWorkflowRun.mockReturnValue(run)
mocks.getStepAttempts.mockReturnValue([step])
mocks.query.execute.mockResolvedValue([])
})
afterEach(() => {
// Observing or disconnecting must never acquire a worker client to cancel its job.
expect(mocks.getOpenWorkflow).not.toHaveBeenCalled()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('streamJobProgress', () => {
test('preserves headers and exact ordered job frames, including cursor ids', async () => {
mocks.query.execute.mockResolvedValue([log])
const response = openStream({ cursor: '{"sync":2,"other":8}' })
expect(Object.fromEntries(response.headers)).toEqual({
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-accel-buffering': 'no'
})
expect(await readChunks(response)).toEqual([
'event: state\ndata: {"state":"completed","result":"ok","errors":null}\n\n',
phaseFrame,
'event: log\ndata: [{"job_id":"job-1","phase":"sync","line_number":3,"message":"café\\n🧩"}]\n\n',
'event: cursor\nid: {"sync":3,"other":8}\ndata: {"sync":3,"other":8}\n\n',
'event: done\ndata: {"state":"completed","result":"ok","errors":null}\n\n'
])
expect(mocks.selectFrom).toHaveBeenCalledWith('job_progress_logs')
expect(mocks.query.where.mock.calls).toEqual([
['job_id', '=', 'job-1'],
['phase', '=', 'sync'],
['line_number', '>', 2]
])
expect(mocks.query.orderBy).toHaveBeenCalledWith('line_number', 'asc')
})
test.each([
{ header: '{"sync":4}', cursor: '{"sync":2}', after: 4 },
{ header: undefined, cursor: '{"sync":2}', after: 2 },
{ header: '', cursor: '{"sync":2}', after: 0 },
{ header: 'invalid', cursor: '{"sync":2}', after: 0 },
{ header: 'null', cursor: '{"sync":2}', after: 0 },
{ header: '7', cursor: '{"sync":2}', after: 0 },
{ header: undefined, cursor: undefined, after: 0 }
])('preserves cursor precedence and fallback: %j', async ({ header, cursor, after }) => {
const chunks = await readChunks(openStream({ header, cursor }))
expect(mocks.query.where).toHaveBeenCalledWith('line_number', '>', after)
expect(chunks).toHaveLength(3)
expect(chunks.some((chunk) => chunk.startsWith('event: cursor\n'))).toBe(false)
})
test('retains advanced cursors across polls without emitting unchanged cursors', async () => {
vi.useFakeTimers()
mocks.getWorkflowRun.mockReturnValueOnce({ ...run, status: 'running' })
mocks.query.execute.mockResolvedValueOnce([log])
const chunksPromise = readChunks(openStream())
await vi.advanceTimersByTimeAsync(WORKFLOW_POLL_INTERVAL_MS)
const chunks = await chunksPromise
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(2)
expect(mocks.query.where.mock.calls.filter(([column]) => column === 'line_number')).toEqual([
['line_number', '>', 0],
['line_number', '>', 3]
])
expect(chunks.map((chunk) => chunk.split('\n')[0])).toEqual([
'event: state',
'event: phase',
'event: log',
'event: cursor',
'event: state',
'event: phase',
'event: done'
])
expect(chunks[0]).toBe(
'event: state\ndata: {"state":"running","result":null,"errors":null}\n\n'
)
})
test('keeps a quiet, pending database read alive every 15 seconds and cleans up on completion', async () => {
vi.useFakeTimers()
expect(HEARTBEAT_MS).toBe(15_000)
expect(WORKFLOW_POLL_INTERVAL_MS).toBe(350)
const pending = deferredLogs()
mocks.query.execute.mockReturnValueOnce(pending.promise)
const reader = openStream().body!.getReader()
expect(await readFrame(reader)).toBe(
'event: state\ndata: {"state":"completed","result":"ok","errors":null}\n\n'
)
expect(await readFrame(reader)).toBe(phaseFrame)
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
for (let heartbeat = 0; heartbeat < 2; heartbeat++) {
const frame = readFrame(reader)
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS - 1)
expect(enqueue).toHaveBeenCalledTimes(heartbeat)
await vi.advanceTimersByTimeAsync(1)
expect(await frame).toBe(': heartbeat\n\n')
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(1)
expect(mocks.query.execute).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(1)
}
pending.resolve([])
expect(await readFrame(reader)).toBe(
'event: done\ndata: {"state":"completed","result":"ok","errors":null}\n\n'
)
expect(await reader.read()).toEqual({ done: true, value: undefined })
expect(vi.getTimerCount()).toBe(0)
})
test.each(['request abort', 'reader cancel'] as const)(
'%s stops polling and removes sleep listeners on both timeout and cancellation',
async (disconnect) => {
vi.useFakeTimers()
mocks.getWorkflowRun.mockReturnValue({ ...run, status: 'running' })
const abort = new AbortController()
const add = vi.spyOn(AbortSignal.prototype, 'addEventListener')
const remove = vi.spyOn(AbortSignal.prototype, 'removeEventListener')
const reader = openStream({ signal: abort.signal }).body!.getReader()
await readFrame(reader)
await readFrame(reader)
await vi.advanceTimersByTimeAsync(0)
// The last registration is the current poll sleep, on the observer's signal.
const sleepIndex = add.mock.calls.findLastIndex(([type]) => type === 'abort')
const observerSignal = add.mock.contexts[sleepIndex] as AbortSignal
expect(observerSignal).toBeInstanceOf(AbortSignal)
expect(observerSignal).not.toBe(abort.signal)
const addedListeners = () =>
add.mock.calls
.filter(
([type], index) => type === 'abort' && add.mock.contexts[index] === observerSignal
)
.map(([, listener]) => listener)
const removedListeners = () =>
remove.mock.calls
.filter(
([type], index) => type === 'abort' && remove.mock.contexts[index] === observerSignal
)
.map(([, listener]) => listener)
for (let poll = 1; poll <= 20; poll++) {
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(poll)
expect(addedListeners()).toHaveLength(poll)
expect(removedListeners()).toEqual(addedListeners().slice(0, -1))
expect(vi.getTimerCount()).toBe(2)
if (poll === 20) break
await vi.advanceTimersByTimeAsync(WORKFLOW_POLL_INTERVAL_MS - 1)
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(poll)
await vi.advanceTimersByTimeAsync(1)
await readFrame(reader)
await readFrame(reader)
}
if (disconnect === 'request abort') abort.abort()
else await reader.cancel()
expect(abort.signal.aborted).toBe(disconnect === 'request abort')
expect(observerSignal.aborted).toBe(true)
expect(removedListeners()).toEqual(addedListeners())
expect(vi.getTimerCount()).toBe(0)
expect(await reader.read()).toEqual({ done: true, value: undefined })
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 2)
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(20)
expect(mocks.query.execute).toHaveBeenCalledTimes(20)
expect(vi.getTimerCount()).toBe(0)
}
)
test('an enqueue failure during poll sleep clears both timers and stops future polls', async () => {
vi.useFakeTimers()
mocks.getWorkflowRun.mockReturnValue({ ...run, status: 'running' })
const abort = new AbortController()
const chunksPromise = readChunks(openStream({ signal: abort.signal }))
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS - 1)
const polls = Math.floor((HEARTBEAT_MS - 1) / WORKFLOW_POLL_INTERVAL_MS) + 1
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(polls)
expect(vi.getTimerCount()).toBe(2)
const enqueue = vi
.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
.mockImplementationOnce(() => {
throw new TypeError('Cannot enqueue into a closed stream')
})
await vi.advanceTimersByTimeAsync(1)
expect(vi.getTimerCount()).toBe(0)
expect(abort.signal.aborted).toBe(false)
const chunks = await chunksPromise
expect(chunks).toHaveLength(polls * 2)
expect(chunks.every((chunk) => /^(event: state|event: phase)\n/.test(chunk))).toBe(true)
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 2)
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(polls)
expect(mocks.query.execute).toHaveBeenCalledTimes(polls)
expect(enqueue).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
test.each(['request abort', 'reader cancel', 'enqueue failure'] as const)(
'%s closes immediately during an in-flight DB read, with no later polling or frames',
async (disconnect) => {
vi.useFakeTimers()
mocks.getWorkflowRun.mockReturnValue({ ...run, status: 'running' })
const pending = deferredLogs()
mocks.query.execute.mockReturnValueOnce(pending.promise)
const abort = new AbortController()
const reader = openStream({ signal: abort.signal }).body!.getReader()
await readFrame(reader)
await readFrame(reader)
expect(vi.getTimerCount()).toBe(1)
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
if (disconnect === 'request abort') abort.abort()
else if (disconnect === 'reader cancel') await reader.cancel()
else {
enqueue.mockImplementationOnce(() => {
throw new TypeError('Cannot enqueue into a closed stream')
})
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS)
}
// Neither closing the response nor clearing timers may wait for the DB promise.
expect(abort.signal.aborted).toBe(disconnect === 'request abort')
expect(vi.getTimerCount()).toBe(0)
expect(await reader.read()).toEqual({ done: true, value: undefined })
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 2)
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(1)
expect(mocks.query.execute).toHaveBeenCalledTimes(1)
pending.resolve([log])
await vi.advanceTimersByTimeAsync(0)
// A DB read finishing after disconnect must not start another polling wait.
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 2)
expect(mocks.getWorkflowRun).toHaveBeenCalledTimes(1)
expect(mocks.query.execute).toHaveBeenCalledTimes(1)
expect(enqueue).toHaveBeenCalledTimes(disconnect === 'enqueue failure' ? 1 : 0)
expect(vi.getTimerCount()).toBe(0)
expect(await reader.read()).toEqual({ done: true, value: undefined })
}
)
test('an already-aborted request never polls, emits, or installs timers', async () => {
vi.useFakeTimers()
const abort = new AbortController()
abort.abort()
const enqueue = vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue')
expect(await readChunks(openStream({ signal: abort.signal }))).toEqual([])
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(HEARTBEAT_MS * 2)
expect(mocks.getOpenWorkflowDb).not.toHaveBeenCalled()
expect(mocks.getWorkflowRun).not.toHaveBeenCalled()
expect(mocks.getStepAttempts).not.toHaveBeenCalled()
expect(mocks.query.execute).not.toHaveBeenCalled()
expect(enqueue).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
test.each([
['completed', 'completed', '"ok"'],
['succeeded', 'completed', '"ok"'],
['failed', 'failed', 'null'],
['canceled', 'canceled', 'null']
])('preserves terminal state mapping for %s', async (status, state, result) => {
mocks.getWorkflowRun.mockReturnValue({ ...run, status, error: 'detail' })
const chunks = await readChunks(openStream({ jobClass: ['PublishAudit', 'Publish'] }))
const data = `{"state":"${state}","result":${result},"errors":["detail"]}`
expect(chunks).toEqual([
`event: state\ndata: ${data}\n\n`,
phaseFrame,
`event: done\ndata: ${data}\n\n`
])
})
test('closes with the existing error when the job is missing', async () => {
mocks.getWorkflowRun.mockReturnValue(null)
expect(await readChunks(openStream())).toEqual([
'event: error\ndata: {"message":"Job not found"}\n\n'
])
expect(mocks.getStepAttempts).not.toHaveBeenCalled()
})
test('closes with the existing error for a disallowed job class', async () => {
expect(await readChunks(openStream({ jobClass: ['Audit', 'Preview'] }))).toEqual([
'event: error\ndata: {"message":"Job is not a Audit/Preview job"}\n\n'
])
expect(mocks.getStepAttempts).not.toHaveBeenCalled()
})
})
+27 -59
View File
@@ -1,8 +1,9 @@
import { db, type JobProgressRow } from '$lib/server/database/index.js'
import { createEventStream } from '../sse.js'
import { WORKFLOW_POLL_INTERVAL_MS } from '../constants.js'
import { getOpenWorkflowDb } from './index.js'
import { getWorkflowRun, getStepAttempts } from '@luzzle/web.jobs'
const POLL_INTERVAL_MS = 350
const TERMINAL_STATES = new Set(['completed', 'failed', 'canceled'])
type Cursors = Record<string, number>
@@ -24,24 +25,17 @@ function parseCursors(raw: string | null): Cursors {
}
}
function encodeEvent(event: string, data: unknown, id?: string): string {
const lines = [`event: ${event}`]
if (id) lines.push(`id: ${id}`)
lines.push(`data: ${JSON.stringify(data)}`)
return lines.join('\n') + '\n\n'
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
function waitForNextPoll(signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
signal.addEventListener(
'abort',
() => {
clearTimeout(timer)
resolve()
},
{ once: true }
)
// The observer may have closed while the previous database read was pending.
if (signal.aborted) return resolve()
const finish = () => {
clearTimeout(timer)
signal.removeEventListener('abort', finish)
resolve()
}
const timer = setTimeout(finish, WORKFLOW_POLL_INTERVAL_MS)
signal.addEventListener('abort', finish, { once: true })
})
}
@@ -168,49 +162,23 @@ export function streamJobProgress({
request.headers.get('last-event-id') ?? url.searchParams.get('cursor')
)
const stream = new ReadableStream<string>({
async start(controller) {
const { signal } = request
let closed = false
const events = createEventStream(request)
const close = () => {
if (closed) return
closed = true
try {
controller.close()
} catch {
// already closed
}
}
const emit: Emit = (event, data, id) => {
if (closed) return
try {
controller.enqueue(encodeEvent(event, data, id))
} catch {
close()
}
}
signal.addEventListener('abort', close, { once: true })
while (!closed && !signal.aborted) {
const terminal = await pollOnce(jobId, jobClass, cursors, emit)
if (terminal) {
close()
return
}
await sleep(POLL_INTERVAL_MS, signal)
async function poll() {
try {
while (!events.signal.aborted) {
if (await pollOnce(jobId, jobClass, cursors, events.emit)) break
await waitForNextPoll(events.signal)
}
} catch (error) {
console.error('SSE stream error:', error)
events.emit('error', { message: 'Internal stream error' })
} finally {
events.close()
}
})
}
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
}
})
// Only polling ends when the observer disconnects; the worker job is independent.
void poll()
return events.response
}
@@ -1,497 +1,26 @@
<script lang="ts">
import { tick } from 'svelte'
import CheckCircleFill from 'virtual:icons/ph/check-circle-fill'
import XCircleFill from 'virtual:icons/ph/x-circle-fill'
import CircleNotchBold from 'virtual:icons/ph/circle-notch-bold'
import MinusCircle from 'virtual:icons/ph/minus-circle'
import LockSimpleFill from 'virtual:icons/ph/lock-simple-fill'
import LockSimpleOpenFill from 'virtual:icons/ph/lock-simple-open-fill'
type PhaseProgress = {
phase: string
status: string
started_at: number
finished_at: number | null
message: string | null
}
type PhaseLog = {
job_id: string
phase: string
line_number: number
ts: number
level: string
message: string
}
import ProgressConsole from '$lib/components/ProgressConsole.svelte'
import type { ProgressPhase, ProgressLog, ProgressStatusText } from '$lib/components/progress.js'
interface Props {
status: string
errorMessage?: string
phases: PhaseProgress[]
logs: Record<string, PhaseLog[]>
phases: ProgressPhase[]
logs: Record<string, ProgressLog[]>
}
let { status, errorMessage = '', phases, logs }: Props = $props()
let scrollLocks = $state<Record<string, boolean>>({})
function formatDuration(ms: number) {
if (ms < 0) return '0s'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const remS = s % 60
return `${m}m ${remS}s`
const statusText: Record<string, ProgressStatusText> = {
expired: {
title: 'Preview expired',
description: 'Assets are purged after 2 days. Re-run the preview from the editor.'
},
enqueued: { title: 'Enqueued', description: 'Waiting for worker…' },
running: { title: 'Rendering preview', durationLabel: 'elapsed' },
failed: { title: 'Preview failed', durationLabel: 'stopped at' },
completed: { title: 'Preview completed', durationLabel: 'total time' }
}
function handleScroll(phaseName: string, event: Event) {
const el = event.currentTarget as HTMLDivElement
if (!el) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10
const currentLock = scrollLocks[phaseName] ?? true
if (isAtBottom && !currentLock) {
scrollLocks[phaseName] = true
} else if (!isAtBottom && currentLock) {
scrollLocks[phaseName] = false
}
}
let overallDuration = $derived.by(() => {
if (phases.length === 0) return 0
const first = phases[0]
const last = phases[phases.length - 1]
if (!first.started_at) return 0
const end = last.finished_at || Date.now()
return end - first.started_at
})
let logLengths = $derived(
Object.fromEntries(Object.entries(logs).map(([phase, list]) => [phase, list.length]))
)
$effect(() => {
for (const [phase, _length] of Object.entries(logLengths)) {
const isLocked = scrollLocks[phase] ?? true
if (isLocked) {
tick().then(() => {
const el = document.getElementById(`log-container-${phase}`)
if (el) el.scrollTop = el.scrollHeight
})
}
}
})
</script>
<div class="status-card">
<span class="status-icon-wrap status-{status}">
{#if status === 'completed'}
<CheckCircleFill class="status-icon" />
{:else if status === 'running'}
<CircleNotchBold class="status-icon spin" />
{:else if status === 'enqueued'}
<CircleNotchBold class="status-icon" />
{:else if status === 'failed' || status === 'expired'}
<XCircleFill class="status-icon" />
{:else}
<span class="status-dot"></span>
{/if}
</span>
<div class="status-text">
{#if status === 'expired'}
<div class="status-title">Preview expired</div>
<div class="status-sub">
Assets are purged after 2 days. Re-run the preview from the editor.
</div>
{:else if status === 'enqueued'}
<div class="status-title">Enqueued</div>
<div class="status-sub">Waiting for worker…</div>
{:else if status === 'running'}
<div class="status-title">Rendering preview</div>
<div class="status-sub">{formatDuration(overallDuration)} elapsed</div>
{:else if status === 'failed'}
<div class="status-title">Preview failed</div>
<div class="status-sub">{formatDuration(overallDuration)} stopped at</div>
{:else if status === 'completed'}
<div class="status-title">Preview completed</div>
<div class="status-sub">{formatDuration(overallDuration)} total time</div>
{/if}
</div>
</div>
{#if errorMessage && status === 'failed'}
<div class="error-strip">
<XCircleFill class="error-icon" />
<span>{errorMessage}</span>
</div>
{/if}
{#if phases.length > 0}
<div class="timeline">
{#each phases as phase (phase.phase)}
{@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0}
<div class="phase">
<div class="phase-row">
<span class="phase-icon-wrap status-{phase.status}">
{#if phase.status === 'completed'}
<CheckCircleFill class="phase-icon" />
{:else if phase.status === 'running'}
<CircleNotchBold class="phase-icon spin" />
{:else if phase.status === 'failed'}
<XCircleFill class="phase-icon" />
{:else if phase.status === 'skipped'}
<MinusCircle class="phase-icon" />
{:else}
<span class="phase-dot"></span>
{/if}
</span>
<span class="phase-name">{phase.phase}</span>
{#if phase.message}
<span class="phase-message" class:is-error={phase.status === 'failed'}>
{phase.message}
</span>
{/if}
<span class="phase-aside">
{#if phase.started_at}
<span class="phase-time">
{formatDuration((phase.finished_at || Date.now()) - phase.started_at)}
</span>
{/if}
</span>
</div>
{#if hasLogs}
<div class="log-console">
<div class="log-header">
<span>{logs[phase.phase]?.length || 0} lines</span>
<div class="log-header-right">
{#if phase.started_at}
<span class="log-duration">
{formatDuration((phase.finished_at || Date.now()) - phase.started_at)}
</span>
{/if}
<button
type="button"
class="scroll-lock-btn"
onclick={() => {
const current = scrollLocks[phase.phase] ?? true
const next = !current
scrollLocks[phase.phase] = next
if (next) {
tick().then(() => {
const el = document.getElementById(`log-container-${phase.phase}`)
if (el) el.scrollTop = el.scrollHeight
})
}
}}
title={(scrollLocks[phase.phase] ?? true)
? 'Auto-scroll locked'
: 'Auto-scroll unlocked'}
>
{#if scrollLocks[phase.phase] ?? true}
<LockSimpleFill class="lock-icon" />
{:else}
<LockSimpleOpenFill class="lock-icon" />
{/if}
</button>
</div>
</div>
<div
class="log-viewport"
id="log-container-{phase.phase}"
onscroll={(e) => handleScroll(phase.phase, e)}
>
{#each logs[phase.phase] ?? [] as log (log.line_number)}
<div
class="log-row"
class:is-error={log.level === 'error' || log.level === 'stderr'}
>
<span class="log-timestamp">
{new Date(log.ts).toISOString().split('T')[1].slice(0, -5)}
</span>
<span class="log-body">{log.message}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.status-card {
display: flex;
align-items: center;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
background-color: var(--color-surface-container-lowest);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-medium);
margin-bottom: var(--space-5);
}
.status-icon-wrap {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-on-surface-variant);
}
.status-icon-wrap :global(.status-icon) {
font-size: 26px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--color-outline-variant);
}
.status-icon-wrap.status-completed {
color: var(--color-primary);
}
.status-icon-wrap.status-running {
color: var(--color-secondary);
}
.status-icon-wrap.status-enqueued {
color: var(--color-tertiary);
}
.status-icon-wrap.status-failed {
color: var(--color-error);
}
.status-title {
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface);
}
.status-sub {
margin-top: 2px;
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
}
.error-strip {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent);
color: var(--color-error);
font-size: var(--font-size-xxs);
border-radius: var(--radius-small);
margin-bottom: var(--space-4);
}
.error-strip :global(.error-icon) {
font-size: 14px;
flex-shrink: 0;
}
.timeline {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.phase {
display: flex;
flex-direction: column;
gap: 2px;
}
.phase-row {
padding: var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-3);
}
.phase-icon-wrap {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phase-icon-wrap :global(.phase-icon) {
font-size: 18px;
}
.phase-icon-wrap.status-completed {
color: var(--color-primary);
}
.phase-icon-wrap.status-running {
color: var(--color-secondary);
}
.phase-icon-wrap.status-failed {
color: var(--color-error);
}
.phase-icon-wrap.status-skipped {
color: var(--color-on-surface-variant);
}
.phase-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid var(--color-outline-variant);
}
.phase-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface);
flex-shrink: 0;
}
.phase-message {
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.phase-message.is-error {
color: var(--color-error);
}
.phase-aside {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.phase-time {
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
min-width: 36px;
text-align: right;
}
.log-console {
margin: 4px 0 0 0;
background-color: var(--color-surface);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-small);
overflow: hidden;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: 4px var(--space-3);
background-color: var(--color-surface-container-low);
border-bottom: 1px solid var(--color-outline-variant);
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface-variant);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.log-duration {
color: var(--color-on-surface);
}
.log-header-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.scroll-lock-btn {
background: none;
border: none;
padding: 2px;
margin: 0;
color: var(--color-on-surface-variant);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-small);
transition:
background-color 0.15s,
color 0.15s;
}
.scroll-lock-btn:hover {
background-color: var(--color-surface-container-highest);
color: var(--color-on-surface);
}
.scroll-lock-btn :global(.lock-icon) {
font-size: 14px;
}
.log-viewport {
padding: var(--space-2) var(--space-3);
max-height: 260px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
line-height: 1.6;
color: var(--color-on-surface-variant);
}
.log-row {
display: flex;
gap: var(--space-3);
}
.log-row.is-error {
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
margin: 0 calc(-1 * var(--space-3));
padding: 0 var(--space-3);
}
.log-timestamp {
color: var(--color-outline);
flex-shrink: 0;
opacity: 0.7;
}
.log-body {
white-space: pre-wrap;
word-break: break-all;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<ProgressConsole {status} statusText={statusText[status]} {phases} {logs} {errorMessage} />
@@ -0,0 +1,4 @@
import type { RequestHandler } from './$types'
import { generateResponse } from '$lib/server/generation'
export const POST: RequestHandler = ({ request }) => generateResponse(request)
+5
View File
@@ -1,6 +1,11 @@
import { defineConfig } from 'vitest/config'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import Icons from 'unplugin-icons/vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [svelte({ configFile: false, hot: false }), Icons({ compiler: 'svelte' })],
resolve: { alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) } },
test: {
include: ['src/**/*.{test,spec}.{ts,js}', 'scripts/**/*.{test,spec}.{ts,js}'],
exclude: ['build/**', '.svelte-kit/**', 'node_modules/**']