From 1ccb6d727616caba8a2f6b6bae31edf9621c4fb0 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 17:39:32 -0700 Subject: [PATCH 1/2] [web] reset Preview state when switching jobs --- .changeset/fix-preview-job-switching.md | 5 +++++ .../piece/[...path]/preview/+layout@.svelte | 6 ++++-- .../[...path]/preview/[jobId]/layout.test.ts | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-preview-job-switching.md diff --git a/.changeset/fix-preview-job-switching.md b/.changeset/fix-preview-job-switching.md new file mode 100644 index 00000000..50fb959e --- /dev/null +++ b/.changeset/fix-preview-job-switching.md @@ -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. diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/+layout@.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/+layout@.svelte index d0ccb7e9..558f85e8 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/+layout@.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/+layout@.svelte @@ -6,11 +6,13 @@ import type { LayoutProps } from './$types' - let { data, children }: LayoutProps = $props() + let { data, params, children }: LayoutProps = $props() -{@render children()} +{#key params.jobId} + {@render children()} +{/key} diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts index fe42a31e..7bdb3fa2 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts @@ -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'") -- 2.54.0 From 9e5e21bce3990bdd79a576f00e705169ccca7236 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 17:39:32 -0700 Subject: [PATCH 2/2] [web.worker] copy attachments into Preview job outputs --- .changeset/fix-preview-attachments.md | 6 + .../pieces/preview/assemble.server.test.ts | 91 +++++++-- .../src/lib/pieces/preview/assemble.server.ts | 26 +-- .../web/worker/src/steps/preview-transform.ts | 9 +- .../src/workflows/preview-attachments.test.ts | 178 ++++++++++++++++++ 5 files changed, 269 insertions(+), 41 deletions(-) create mode 100644 .changeset/fix-preview-attachments.md create mode 100644 packages/web/worker/src/workflows/preview-attachments.test.ts diff --git a/.changeset/fix-preview-attachments.md b/.changeset/fix-preview-attachments.md new file mode 100644 index 00000000..9abb0c57 --- /dev/null +++ b/.changeset/fix-preview-attachments.md @@ -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. diff --git a/apps/web/src/lib/pieces/preview/assemble.server.test.ts b/apps/web/src/lib/pieces/preview/assemble.server.test.ts index e64ad38d..76c4bc3a 100644 --- a/apps/web/src/lib/pieces/preview/assemble.server.test.ts +++ b/apps/web/src/lib/pieces/preview/assemble.server.test.ts @@ -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' + ) +}) diff --git a/apps/web/src/lib/pieces/preview/assemble.server.ts b/apps/web/src/lib/pieces/preview/assemble.server.ts index a32ab977..6354bd7c 100644 --- a/apps/web/src/lib/pieces/preview/assemble.server.ts +++ b/apps/web/src/lib/pieces/preview/assemble.server.ts @@ -62,29 +62,6 @@ function imagePassthroughs( return records } -function attachmentPassthroughs( - frontmatter: PieceFrontmatter, - attachmentFields: string[] | undefined, - keyToPath: Map -): PublicWebPieceAsset[] { - if (!attachmentFields?.length) return [] - const records: PublicWebPieceAsset[] = [] - for (const field of attachmentFields) { - const keys = getFrontmatterValues(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 = { diff --git a/packages/web/worker/src/steps/preview-transform.ts b/packages/web/worker/src/steps/preview-transform.ts index 5c0b9fe5..7d1b0f4a 100644 --- a/packages/web/worker/src/steps/preview-transform.ts +++ b/packages/web/worker/src/steps/preview-transform.ts @@ -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 { diff --git a/packages/web/worker/src/workflows/preview-attachments.test.ts b/packages/web/worker/src/workflows/preview-attachments.test.ts new file mode 100644 index 00000000..74c2496b --- /dev/null +++ b/packages/web/worker/src/workflows/preview-attachments.test.ts @@ -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 + +// 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 + + beforeEach(async () => { + vi.clearAllMocks() + directory = await mkdtemp(path.join(tmpdir(), 'luzzle-preview-attachments-')) + ctx = { + db: (await setupDatabase()).withTables(), + 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([ + ...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' }]) + }) +}) -- 2.54.0