[web] fix Preview job switching and attachment downloads #119

Merged
eleith merged 2 commits from fix/116-117-preview-lifecycle-attachments into main 2026-10-17 01:01:07 +00:00
8 changed files with 299 additions and 43 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@luzzle/web": patch
"@luzzle/web.worker": patch
---
Generate attachment copies during preview and use only worker-generated attachment references, while preserving image fallbacks.
+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Reset Preview state, navigation links, and progress-stream observation when the job changes during client-side navigation, while preserving state across tabs of the same job.
@@ -1,8 +1,10 @@
import { afterEach, expect, test, vi } from 'vitest'
import type { Config } from '@luzzle/web.config'
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import type { PreviewResult } from '@luzzle/web.jobs'
import { createPieceHelpers, type PublicWebPieceAsset } from '@luzzle/web.pieces'
import { makeConfig } from '$lib/server/config.fixture.js'
import { assemblePreview, type PreviewWorkerResult } from './assemble.server.js'
import { previewOutputUrl } from './output-url.js'
const pieceConfig: Config['pieces'][number] = makeConfig({
pieces: [
@@ -60,18 +62,6 @@ const passthroughs: PublicWebPieceAsset[] = [
transformation: 'image.original',
asset_path: 'archive/gallery.png',
mime_type: 'image/png'
},
{
asset_key: 'pdf-key',
transformation: 'attachment',
asset_path: 'archive/manual.pdf',
mime_type: 'application/pdf'
},
{
asset_key: 'unknown-key',
transformation: 'attachment',
asset_path: 'archive/data.unrecognized-extension',
mime_type: 'application/octet-stream'
}
]
@@ -138,7 +128,7 @@ test('projects every actual worker record to only the public output fields', ()
}
})
test('keeps actual outputs first and archive image/attachment fallbacks only in piece.assets', () => {
test('keeps actual outputs first and archive image fallbacks only in piece.assets', () => {
const output: PublicWebPieceAsset = {
asset_key: 'cover-key',
transformation: 'image.original',
@@ -190,11 +180,20 @@ test.each([
expect(assembled.piece.assets).toStrictEqual([{ ...inline, asset_path: null }, ...passthroughs])
})
test('does not invent generated outputs when only archive passthroughs exist', () => {
test('does not fabricate attachment references for retained results without attachment outputs', () => {
const assembled = assemblePreview(workerResult(), pieceConfig)
expect(assembled.outputs).toStrictEqual([])
expect(assembled.piece.assets).toStrictEqual(passthroughs)
const helpers = createPieceHelpers(
assembled.piece.assets,
(path) => path,
() => '/piece'
)
for (const key of ['pdf-key', 'unknown-key', 'missing-key']) {
expect(helpers.getPieceAssetUrl(key, 'attachment')).toBeUndefined()
}
expect(helpers.getPieceImageUrl('cover-key', 250, 'webp')).toBe('archive/cover.jpg')
})
test('leaves the worker result and configuration unchanged', () => {
@@ -276,3 +275,65 @@ test.each([
expect(assembled.tags.map(({ tag }) => tag)).toStrictEqual(expected)
expect(assembled.piece.keywords).toBe(expected.length ? JSON.stringify(expected) : undefined)
})
test('exposes generated attachment downloads and prioritizes generated images over archive fallbacks', () => {
const attachments: PublicWebPieceAsset[] = [
{
asset_key: 'pdf-key',
transformation: 'attachment',
asset_path: 'book/piece-key/manual.pdf',
mime_type: 'application/pdf'
},
{
asset_key: 'unknown-key',
transformation: 'attachment',
asset_path: 'book/piece-key/data.unrecognized-extension',
mime_type: 'application/octet-stream'
}
]
const image: PublicWebPieceAsset = {
asset_key: 'cover-key',
transformation: 'image.original',
asset_path: 'book/piece-key/cover.jpg',
mime_type: 'image/jpeg'
}
// Exercise the persisted shared worker contract, not archive-path stand-ins.
const result: PreviewResult = JSON.parse(
JSON.stringify(
workerResult({
transforms: [...attachments, image]
})
)
)
const assembled = assemblePreview(result, pieceConfig)
const helpers = createPieceHelpers(
assembled.piece.assets,
(path) => previewOutputUrl(path, 'preview-job')!,
() => '/piece'
)
expect(assembled.outputs.map((asset) => asset.asset_path)).toEqual([
'book/piece-key/manual.pdf',
'book/piece-key/data.unrecognized-extension',
'book/piece-key/cover.jpg'
])
expect(
assembled.piece.assets.filter((asset) => asset.transformation === 'attachment')
).toStrictEqual(assembled.outputs.filter((asset) => asset.transformation === 'attachment'))
expect(helpers.getPieceAssetUrl('pdf-key', 'attachment')).toBe(
'/admin/preview/preview-job/asset/book/piece-key/manual.pdf'
)
expect(helpers.getPieceAssetUrl('unknown-key', 'attachment')).toBe(
'/admin/preview/preview-job/asset/book/piece-key/data.unrecognized-extension'
)
expect(helpers.getPieceAssetUrl('missing-key', 'attachment')).toBeUndefined()
expect(helpers.getPieceAssetUrl('cover-key', 'image.original')).toBe(
'/admin/preview/preview-job/asset/book/piece-key/cover.jpg'
)
expect(helpers.getPieceImageUrl('cover-key', 250, 'webp')).toBe(
'/admin/preview/preview-job/asset/book/piece-key/cover.jpg'
)
expect(helpers.getPieceImageUrl('gallery-key', 250, 'webp')).toBe(
'/admin/preview/preview-job/asset/archive/gallery.png'
)
})
@@ -62,29 +62,6 @@ function imagePassthroughs(
return records
}
function attachmentPassthroughs(
frontmatter: PieceFrontmatter,
attachmentFields: string[] | undefined,
keyToPath: Map<string, string>
): PublicWebPieceAsset[] {
if (!attachmentFields?.length) return []
const records: PublicWebPieceAsset[] = []
for (const field of attachmentFields) {
const keys = getFrontmatterValues<string>(frontmatter, field).flat().filter(Boolean)
for (const key of keys) {
const path = keyToPath.get(key)
if (!path) continue
records.push({
asset_key: key,
transformation: 'attachment',
asset_path: path,
mime_type: mime.lookup(path) || 'application/octet-stream'
})
}
}
return records
}
export function assemblePreview(
result: PreviewWorkerResult,
pieceConfig: Config['pieces'][number]
@@ -123,8 +100,7 @@ export function assemblePreview(
}))
const assets: PublicWebPieceAsset[] = [
...outputs,
...imagePassthroughs(fm, pieceConfig.fields.media, keyToPath),
...attachmentPassthroughs(fm, pieceConfig.fields.attachments, keyToPath)
...imagePassthroughs(fm, pieceConfig.fields.media, keyToPath)
]
const piece: PublicWebPiece = {
@@ -6,11 +6,13 @@
import type { LayoutProps } from './$types'
let { data, children }: LayoutProps = $props()
let { data, params, children }: LayoutProps = $props()
</script>
<svelte:head>
<link rel="icon" href="{data.config.url.app_assets}/images/favicon.png" />
</svelte:head>
{@render children()}
{#key params.jobId}
{@render children()}
{/key}
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
import { beforeEach, afterEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { parse } from 'svelte/compiler'
import { makeConfig } from '$lib/server/config.fixture'
import { publishedPiece } from '$lib/pieces/viewer/fixtures/published'
import PreviewLayout from './+layout.svelte'
@@ -77,6 +78,26 @@ test.each(['/static', 'https://cdn.test/site-assets', ''])(
}
)
// Structural guard for the remount boundary; SSR cannot exercise client replacement or SSE cleanup.
test('Preview keys the entire job layout subtree by job, not the current tab', () => {
const source = readFileSync(new URL('../+layout@.svelte', import.meta.url), 'utf8')
const nodes = parse(source, { modern: true }).fragment.nodes.filter(
(node) => node.type !== 'Text' && node.type !== 'SvelteHead'
)
expect(nodes).toHaveLength(1)
const boundary = nodes[0]
if (boundary.type !== 'KeyBlock')
throw new Error('Preview children must be inside the identity key')
expect(boundary.expression).toMatchObject({
type: 'MemberExpression',
object: { type: 'Identifier', name: 'params' },
property: { type: 'Identifier', name: 'jobId' }
})
expect(boundary.fragment.nodes.filter((node) => node.type !== 'Text')).toMatchObject([
{ type: 'RenderTag', expression: { callee: { type: 'Identifier', name: 'children' } } }
])
})
test('Preview consumes the shared viewer bar instead of keeping separate banner styles', () => {
const source = readFileSync(new URL('./+layout.svelte', import.meta.url), 'utf8')
expect(source).toContain("ViewerBar.css'")
@@ -6,7 +6,14 @@ import type { AssetRecord } from '../transforms/utils/types.js'
import type { ParsedPreview } from './preview-parse.js'
// order matters: opengraph depends on image assets written by earlier transforms
export const PREVIEW_TRANSFORM_NAMES = ['markdown', 'highlight', 'palette', 'image', 'opengraph'] as const
export const PREVIEW_TRANSFORM_NAMES = [
'markdown',
'highlight',
'palette',
'image',
'attachment',
'opengraph',
] as const
export type PreviewTransformName = (typeof PREVIEW_TRANSFORM_NAMES)[number]
export interface PreviewTransformInput {
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { validateConfig } from '@luzzle/web.config'
import type { PreviewPayload, PreviewResult } from '@luzzle/web.jobs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { registerPreviewWorkflow } from './preview.js'
import { getWorkerContext, type WorkerContext } from '../services/context.js'
import { previewParseStep, type ParsedPreview } from '../steps/preview-parse.js'
import { getTransforms } from '../transforms/index.js'
import { run as attachment } from '../transforms/attachment.js'
import { generateAssetKey } from '../assets/key.js'
import { completed } from '../core/step.js'
import type { DurableStepApi } from '../core/run-progress-phase.js'
import type { AppDatabase } from '../services/db.js'
import { setupDatabase, teardownDatabase } from '../../test/db.js'
import { makeLogger } from '../../test/logger.js'
const { implementWorkflow } = vi.hoisted(() => ({ implementWorkflow: vi.fn() }))
vi.mock('@luzzle/web.jobs', () => ({ getOpenWorkflow: () => ({ implementWorkflow }) }))
vi.mock('../services/context.js', () => ({
getWorkerContext: vi.fn(),
createPhaseContext: (ctx: WorkerContext) => ctx,
}))
vi.mock('../steps/preview-parse.js', () => ({ previewParseStep: { run: vi.fn() } }))
vi.mock('../transforms/index.js', () => ({ getTransforms: vi.fn() }))
type PreviewWorkflow = (args: {
input: PreviewPayload
run: { id: string }
step: DurableStepApi
}) => Promise<PreviewResult>
// Keep this independent of PREVIEW_TRANSFORM_NAMES so omission from production is caught.
const phases = ['markdown', 'highlight', 'palette', 'image', 'attachment', 'opengraph']
describe('preview attachment production', () => {
let directory: string
let ctx: WorkerContext
let parsed: ParsedPreview
let workflow: PreviewWorkflow
let step: DurableStepApi
let opengraph: ReturnType<typeof vi.fn>
beforeEach(async () => {
vi.clearAllMocks()
directory = await mkdtemp(path.join(tmpdir(), 'luzzle-preview-attachments-'))
ctx = {
db: (await setupDatabase()).withTables<AppDatabase>(),
logger: makeLogger(),
rclone: {} as WorkerContext['rclone'],
config: validateConfig(
{
storage: { root: directory },
paths: { assets: path.join(directory, 'generated', 'pieces') },
assets: { salt: 'preview-test' },
pieces: [
{
type: 'snippets',
fields: { title: 'title', date_consumed: 'date', attachments: ['files[*].file'] },
},
],
},
{}
),
}
const source = '.assets/fibonacci.py'
const assetKey = generateAssetKey(source, ctx.config.assets.salt)
const frontmatter = {
title: 'Fibonacci',
files: [{ file: assetKey }, { file: 'unmapped-key' }],
}
parsed = {
type: 'snippets',
slug: 'fibonacci',
webPiece: {
id: 'preview',
key: 'piece-key',
type: 'snippets',
slug: 'fibonacci',
title: 'Fibonacci',
file_path: 'fibonacci.snippets.md',
date_added: 1,
json_metadata: JSON.stringify(frontmatter),
},
sanitizedFrontmatter: frontmatter,
note: '',
pathToKey: new Map([[source, assetKey]]),
keyToPath: new Map([[assetKey, source]]),
}
await mkdir(path.join(directory, '.assets'))
await writeFile(path.join(directory, source), 'def fibonacci(n):\n return n\n')
vi.mocked(getWorkerContext).mockReturnValue(ctx)
vi.mocked(previewParseStep.run).mockResolvedValue(completed(parsed))
opengraph = vi.fn().mockResolvedValue([])
vi.mocked(getTransforms).mockReturnValue(
new Map<string, { run: typeof attachment }>([
...phases.map((name) => [name, { run: vi.fn().mockResolvedValue([]) }] as const),
['attachment', { run: attachment }],
['opengraph', { run: opengraph }],
])
)
step = { run: vi.fn(async (_options, fn) => await fn()) } as DurableStepApi
registerPreviewWorkflow()
workflow = implementWorkflow.mock.calls[0][1] as PreviewWorkflow
})
afterEach(async () => {
vi.restoreAllMocks()
await teardownDatabase(ctx.db)
await rm(directory, { recursive: true, force: true })
})
test('copies the real attachment into the job directory and returns keyed shared output records', async () => {
const result = await workflow({
input: { filePath: parsed.webPiece.file_path },
run: { id: 'job-one' },
step,
})
const expected = {
piece_asset_path: '.assets/fibonacci.py',
asset_key: parsed.pathToKey.get('.assets/fibonacci.py'),
transformation: 'attachment',
asset_path: 'snippets/piece-key/fibonacci.py',
mime_type: 'application/octet-stream',
}
expect(vi.mocked(step.run).mock.calls.map(([options]) => options.name)).toEqual([
'parse',
...phases,
])
expect(result.transforms).toEqual([expected])
expect(result.pathToKey).toEqual(Object.fromEntries(parsed.pathToKey))
const persisted: PreviewResult = JSON.parse(JSON.stringify(result))
expect(persisted.transforms).toEqual([expected])
expect(
await readFile(path.join(directory, 'generated/previews/job-one', expected.asset_path))
).toEqual(await readFile(path.join(directory, expected.piece_asset_path)))
expect(opengraph).toHaveBeenCalledWith(
expect.objectContaining({
priorAssets: [
expect.objectContaining({
asset_key: expected.asset_key,
transformation: 'attachment',
asset_path: expected.asset_path,
}),
],
})
)
expect(
await ctx.db
.selectFrom('job_progress')
.select(['phase', 'status', 'message'])
.where('phase', '=', 'attachment')
.execute()
).toEqual([{ phase: 'attachment', status: 'completed', message: '1 record(s)' }])
})
test('does not report a generated attachment when the source cannot be copied', async () => {
await rm(path.join(directory, '.assets/fibonacci.py'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await workflow({
input: { filePath: parsed.webPiece.file_path },
run: { id: 'job-failed' },
step,
})
expect(result.transforms).toEqual([])
expect(opengraph).toHaveBeenCalledWith(expect.objectContaining({ priorAssets: [] }))
expect(
await ctx.db
.selectFrom('job_progress')
.select(['phase', 'status'])
.where('phase', '=', 'attachment')
.execute()
).toEqual([{ phase: 'attachment', status: 'failed' }])
})
})