[web] fix Preview job switching and attachment downloads #119
No files matched your search
@@ -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.
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user