diff --git a/apps/core/src/llm/google/client.ts b/apps/core/src/llm/google/client.ts index f5b10385..6d15e822 100644 --- a/apps/core/src/llm/google/client.ts +++ b/apps/core/src/llm/google/client.ts @@ -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 = { - maxFiles: 10, - maxFileBytes: 50_000_000, - maxTotalFileBytes: 100_000_000, - maxOutputBytes: 1_000_000, - maxFileProcessingMs: 5 * 60 * 1000, -} - export type GenerationOptions = { files?: Array - limits?: Partial onProgress?: (progress: GenerationProgress) => void | Promise - onWarning?: (message: string) => void | Promise } type GenerationTask = { @@ -56,7 +37,6 @@ type GenerationTask = { type GenerationContext = { client: GoogleGenAI - limits: GenerationLimits options: GenerationOptions uploaded: Set } @@ -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 { - 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) { - const { limits } = context + const limits = DEFAULT_GENERATION_LIMITS const parts: Array = [] let totalBytes = 0 for (const [index, file] of files.entries()) { @@ -173,16 +143,6 @@ async function prepareAttachments(context: GenerationContext, files: Array 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( ], }, }) - return completedText(response, context.limits.maxOutputBytes) + return completedText(response) } export async function runGeneration( @@ -266,10 +228,10 @@ export async function runGeneration( options: GenerationOptions ): Promise { 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(), } diff --git a/apps/core/src/llm/google/constants.ts b/apps/core/src/llm/google/constants.ts new file mode 100644 index 00000000..1e20b385 --- /dev/null +++ b/apps/core/src/llm/google/constants.ts @@ -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 diff --git a/apps/core/src/llm/google/generation.test.ts b/apps/core/src/llm/google/generation.test.ts index 58838075..98556938 100644 --- a/apps/core/src/llm/google/generation.test.ts +++ b/apps/core/src/llm/google/generation.test.ts @@ -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(() => {})) - 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() 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() 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>>() 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) { diff --git a/apps/core/src/llm/google/index.ts b/apps/core/src/llm/google/index.ts index 7a74307f..4cec6330 100644 --- a/apps/core/src/llm/google/index.ts +++ b/apps/core/src/llm/google/index.ts @@ -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' diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts new file mode 100644 index 00000000..ded310e8 --- /dev/null +++ b/apps/web/src/hooks.server.test.ts @@ -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[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 + ) +}) diff --git a/apps/web/src/lib/components/ProgressConsole.svelte b/apps/web/src/lib/components/ProgressConsole.svelte new file mode 100644 index 00000000..4fc07c52 --- /dev/null +++ b/apps/web/src/lib/components/ProgressConsole.svelte @@ -0,0 +1,478 @@ + + +
+ + {#if status === 'completed'} + + {:else if status === 'running'} + + {:else if status === 'enqueued'} + + {:else if status === 'failed' || status === 'expired'} + + {:else} + + {/if} + + +
+ {#if statusText} +
{statusText.title}
+
+ {#if statusText.description} + {statusText.description} + {:else if statusText.durationLabel} + {formatDuration(overallDuration)} {statusText.durationLabel} + {/if} +
+ {/if} +
+
+ +{#if errorMessage && status === 'failed'} +
+ + {errorMessage} +
+{/if} + +{#if phases.length > 0} +
+ {#each phases as phase (phase.phase)} + {@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0} +
+
+ + {#if phase.status === 'completed'} + + {:else if phase.status === 'running'} + + {:else if phase.status === 'failed'} + + {:else if phase.status === 'skipped'} + + {:else} + + {/if} + + {phase.phase} + {#if phase.message} + + {phase.message} + + {/if} + + {#if phase.started_at} + + {formatDuration((phase.finished_at || Date.now()) - phase.started_at)} + + {/if} + +
+ + {#if hasLogs} +
+
+ {logs[phase.phase]?.length || 0} lines +
+ {#if phase.started_at} + + {formatDuration((phase.finished_at || Date.now()) - phase.started_at)} + + {/if} + +
+
+
handleScroll(phase.phase, e)} + > + {#each logs[phase.phase] ?? [] as log (log.line_number)} +
+ + {new Date(log.ts).toISOString().split('T')[1].slice(0, -5)} + + {log.message} +
+ {/each} +
+
+ {/if} +
+ {/each} +
+{/if} + + diff --git a/apps/web/src/lib/components/ProgressConsole.test.ts b/apps/web/src/lib/components/ProgressConsole.test.ts new file mode 100644 index 00000000..9ce8bd45 --- /dev/null +++ b/apps/web/src/lib/components/ProgressConsole.test.ts @@ -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 = { + 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 = '' + 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(' -
- - {#if status === 'completed'} - - {:else if status === 'running'} - - {:else if status === 'enqueued'} - - {:else if status === 'failed' || status === 'expired'} - - {:else} - - {/if} - - -
- {#if status === 'expired'} -
Preview expired
-
- Assets are purged after 2 days. Re-run the preview from the editor. -
- {:else if status === 'enqueued'} -
Enqueued
-
Waiting for worker…
- {:else if status === 'running'} -
Rendering preview
-
{formatDuration(overallDuration)} elapsed
- {:else if status === 'failed'} -
Preview failed
-
{formatDuration(overallDuration)} stopped at
- {:else if status === 'completed'} -
Preview completed
-
{formatDuration(overallDuration)} total time
- {/if} -
-
- -{#if errorMessage && status === 'failed'} -
- - {errorMessage} -
-{/if} - -{#if phases.length > 0} -
- {#each phases as phase (phase.phase)} - {@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0} -
-
- - {#if phase.status === 'completed'} - - {:else if phase.status === 'running'} - - {:else if phase.status === 'failed'} - - {:else if phase.status === 'skipped'} - - {:else} - - {/if} - - {phase.phase} - {#if phase.message} - - {phase.message} - - {/if} - - {#if phase.started_at} - - {formatDuration((phase.finished_at || Date.now()) - phase.started_at)} - - {/if} - -
- - {#if hasLogs} -
-
- {logs[phase.phase]?.length || 0} lines -
- {#if phase.started_at} - - {formatDuration((phase.finished_at || Date.now()) - phase.started_at)} - - {/if} - -
-
-
handleScroll(phase.phase, e)} - > - {#each logs[phase.phase] ?? [] as log (log.line_number)} -
- - {new Date(log.ts).toISOString().split('T')[1].slice(0, -5)} - - {log.message} -
- {/each} -
-
- {/if} -
- {/each} -
-{/if} - - + diff --git a/apps/web/src/routes/api/admin/generate/+server.ts b/apps/web/src/routes/api/admin/generate/+server.ts new file mode 100644 index 00000000..5e6cb237 --- /dev/null +++ b/apps/web/src/routes/api/admin/generate/+server.ts @@ -0,0 +1,4 @@ +import type { RequestHandler } from './$types' +import { generateResponse } from '$lib/server/generation' + +export const POST: RequestHandler = ({ request }) => generateResponse(request) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index ccd969f1..6cac2936 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -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/**']