From a2e23925644749e9d5b881febe85ef1f839106c3 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 4 Nov 2026 11:28:00 -0800 Subject: [PATCH] [web] focus viewer tests on meaningful regressions --- .../pieces/components/icon.default.test.ts | 58 - .../src/lib/pieces/components/icon.test.ts | 13 +- .../src/lib/pieces/components/page.test.ts | 29 - apps/web/src/lib/pieces/navigation.test.ts | 95 +- .../lib/pieces/viewer/AssetOutputs.test.ts | 1612 +---------------- .../lib/pieces/viewer/IconViewport.test.ts | 20 +- .../lib/pieces/viewer/PieceOverview.test.ts | 97 - .../lib/pieces/viewer/ShareViewport.test.ts | 65 - .../lib/pieces/viewer/SourceContent.test.ts | 85 +- .../lib/pieces/viewer/ViewerHeader.test.ts | 243 +-- apps/web/src/lib/pieces/viewer/assets.test.ts | 712 ++------ .../src/lib/pieces/viewer/disclosure.test.ts | 58 +- .../lib/pieces/viewer/presentation.test.ts | 71 - .../pieces/viewer/source-highlight.test.ts | 139 +- apps/web/src/lib/pieces/viewer/source.test.ts | 156 +- .../[...path]/preview/[jobId]/page.test.ts | 396 ++-- .../piece/[...path]/published/page.test.ts | 663 ++----- docs/development.md | 20 +- mise.toml | 2 +- 19 files changed, 635 insertions(+), 3899 deletions(-) delete mode 100644 apps/web/src/lib/pieces/components/icon.default.test.ts delete mode 100644 apps/web/src/lib/pieces/components/page.test.ts delete mode 100644 apps/web/src/lib/pieces/viewer/PieceOverview.test.ts delete mode 100644 apps/web/src/lib/pieces/viewer/ShareViewport.test.ts delete mode 100644 apps/web/src/lib/pieces/viewer/presentation.test.ts diff --git a/apps/web/src/lib/pieces/components/icon.default.test.ts b/apps/web/src/lib/pieces/components/icon.default.test.ts deleted file mode 100644 index 0e81d27c..00000000 --- a/apps/web/src/lib/pieces/components/icon.default.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { render } from 'svelte/server' -import { createPieceHelpers, type PublicWebPiece } from '@luzzle/web.pieces' -import { getIcon } from '@luzzle/web.templates/icons' - -const IconDefault = getIcon('default') - -const piece: PublicWebPiece = { - id: 'default-icon', - key: 'default-icon', - type: 'arbitrary-kind', - slug: 'default-icon', - title: '&', - date_added: 1, - metadata: { cover: 'cover' }, - keywords: '[]', - assets: [ - { - asset_key: 'cover', - transformation: 'image.original', - asset_path: 'cover.jpg', - mime_type: 'image/jpeg' - } - ] -} -const helpers = createPieceHelpers( - piece.assets, - (path) => `/assets/${path}`, - () => '/piece' -) - -describe('shipped static default icon', () => { - test('renders the poster UI with mapped fields and piece helpers and the default aspect ratio', () => { - const { body } = render(IconDefault, { - props: { piece, fields: { image: 'cover' }, size: { width: 80 }, active: false, helpers } - }) - expect(body).toContain('class="poster') - expect(body).toContain('<Default>&') - expect(body).toContain('--piece-icon-height: 120px;') - expect(body).toContain('src="/assets/cover.jpg" loading="eager"') - }) - - test('honors explicit height, lazy loading and active styling', () => { - const { body } = render(IconDefault, { - props: { - piece, - fields: { image: 'cover' }, - size: { width: 80, height: 55 }, - lazy: true, - active: true, - helpers - } - }) - expect(body).toContain('--piece-icon-height: 55px;') - expect(body).toMatch(/class="poster [^"]*\bactive\b/) - expect(body).toContain('loading="lazy" fetchpriority="auto"') - }) -}) diff --git a/apps/web/src/lib/pieces/components/icon.test.ts b/apps/web/src/lib/pieces/components/icon.test.ts index 584d5b0d..0e765a7e 100644 --- a/apps/web/src/lib/pieces/components/icon.test.ts +++ b/apps/web/src/lib/pieces/components/icon.test.ts @@ -228,14 +228,13 @@ describe('static default icon consumer SSR', () => { expect(body).not.toContain(' { - const current = piece('new-kind') - current.title = 'Next ' + test('a missing mapped image retains the title without emitting a broken image request', async () => { + const current = piece() current.templates.icon.fields = { image: 'missing' } - const body = await html({ piece: current, size: { width: 200 }, active: true }) - expect(body).toContain('Next <Piece>') - expect(body).toContain('--piece-icon-width: 200px;') - expect(body).toMatch(/class="poster [^"]*\bactive\b/) + + const body = await html({ piece: current }) + + expect(body).toContain('<Piece>&') expect(body).not.toContain(' { - const source = readFileSync(new URL(`./${role}.svelte`, import.meta.url), 'utf8') - expect(source).toContain(`from '@luzzle/web.templates/${catalog}'`) - expect(source).toContain(`const ${component} = $derived(${getter}(`) - expect(source).toContain('const fields = $derived(') - expect(source).toContain('const helpers = $derived(getPieceHelpers(piece))') - expect(source).toContain(`<${component} {piece} {fields}`) - expect(source).not.toMatch( - /import\.meta\.glob|\bimport\s*\(|\{@html|\{#(?:await|key|if)\b|presentation|prepared/i - ) - const content = parse(source, { modern: true }).fragment.nodes.filter( - (node) => node.type !== 'Text' && node.type !== 'SvelteHead' - ) - expect(content.length).toBeGreaterThan(0) - for (const node of content) { - expect(['Component', 'RegularElement']).toContain(node.type) - } - } -) diff --git a/apps/web/src/lib/pieces/navigation.test.ts b/apps/web/src/lib/pieces/navigation.test.ts index dc27cf57..5fe96329 100644 --- a/apps/web/src/lib/pieces/navigation.test.ts +++ b/apps/web/src/lib/pieces/navigation.test.ts @@ -1,90 +1,31 @@ -import { existsSync } from 'node:fs' -import { expect, test, vi } from 'vitest' -import { - editorHref, - encodeArchivePath, - previewHref, - publishedHref, - publishHref -} from './navigation' +import { expect, test } from 'vitest' +import { editorHref, previewHref, publishedHref, publishHref } from './navigation' -const { resolve } = vi.hoisted(() => ({ - resolve: vi.fn(() => { - throw new Error('Could not get the request store. This is an internal error.') - }) -})) -vi.mock('$app/paths', () => ({ resolve })) +test('archive filenames and preview jobs remain path segments, not URL queries or fragments', () => { + const file = 'books/้›ช #?%.books.md' + const root = '/admin/piece/books/%E9%9B%AA%20%23%3F%25.books.md' -test('Published replaces Live without keeping a legacy handler or redirect alias', () => { - const routes = new URL('../../routes/(auth)/admin/piece/[...path]/', import.meta.url) - expect(existsSync(new URL('live/', routes))).toBe(false) - for (const file of [ - 'published/+layout.server.ts', - 'published/+page.svelte', - 'published/page/+page.svelte', - 'published/icon/+page.svelte', - 'published/share/+page.svelte' - ]) - expect(existsSync(new URL(file, routes)), file).toBe(true) + expect(editorHref(file)).toBe(`${root}/source`) + expect(publishedHref(file)).toBe(`${root}/published`) + expect(previewHref(file)).toBe(`${root}/preview`) + expect(previewHref(file, 'job #?%')).toBe(`${root}/preview/job%20%23%3F%25`) }) -test.each([ - 'books/ๆบ #?%.books.md', - 'folder with spaces/a book.books.md', - 'nested/literal%2F.books.md', - 'books/๐Ÿ“š.books.md' -])( - 'editor navigation encodes raw file %s and job segments once without a request store', - (file) => { - const root = `/admin/piece/${file.split('/').map(encodeURIComponent).join('/')}` - const job = 'job #?%' - const links = [editorHref(file), publishedHref(file), previewHref(file), previewHref(file, job)] - expect(links).toEqual([ - `${root}/source`, - `${root}/published`, - `${root}/preview`, - `${root}/preview/job%20%23%3F%25` - ]) - for (const href of links) { - const url = new URL(href, 'https://app.test') - expect(url.search).toBe('') - expect(url.hash).toBe('') - expect(decodeURIComponent(url.pathname)).toContain(file) - } - expect(resolve).not.toHaveBeenCalled() - } -) +test('a percent-looking filename is encoded literally rather than becoming another path segment', () => { + expect(editorHref('books/literal%2F.books.md')).toBe( + '/admin/piece/books/literal%252F.books.md/source' + ) +}) + +test('publish preselection retains the complete filename as one query value', () => { + const file = 'nested/name?check=0&file=other#fragment + ้›ช.books.md' -test.each([ - 'books/ๆบ #?%&"+=.books.md', - 'folder with spaces/a book.books.md', - 'nested/literal%2F.books.md', - 'books/๐Ÿ“š.books.md', - 'nested/name?check=0&file=other#fragment.books.md' -])('publish navigation preselects the complete raw filename %s', (file) => { const url = new URL(publishHref(file), 'https://app.test') + expect(url.pathname).toBe('/admin/publish') expect(url.hash).toBe('') expect([...url.searchParams.entries()]).toEqual([ ['check', '1'], ['file', file] ]) - expect(resolve).not.toHaveBeenCalled() -}) - -test('publish navigation encodes a query value rather than a pathname', () => { - expect(publishHref('folder/a b+c.books.md')).toBe( - '/admin/publish?check=1&file=folder%2Fa+b%2Bc.books.md' - ) -}) - -test.each([ - ['books/ๆบ #?%.books.md', 'books/%E6%BA%90%20%23%3F%25.books.md'], - ['folder with spaces/a book.books.md', 'folder%20with%20spaces/a%20book.books.md'], - ['nested/literal%2F.books.md', 'nested/literal%252F.books.md'], - ['books/๐Ÿ“š.books.md', 'books/%F0%9F%93%9A.books.md'], - ['published/page/icon/share/source/file.md', 'published/page/icon/share/source/file.md'] -])('encodes raw piece path %s segment-wise without a request store', (file, encoded) => { - expect(encodeArchivePath(file)).toBe(encoded) - expect(resolve).not.toHaveBeenCalled() }) diff --git a/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts b/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts index 8467b1d7..c603d832 100644 --- a/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts +++ b/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts @@ -1,1549 +1,101 @@ -import { readFileSync } from 'node:fs' -import { expect, test, vi } from 'vitest' +import { expect, test } from 'vitest' import { render } from 'svelte/server' import type { PublicWebPieceAsset } from '@luzzle/web.pieces' import AssetOutputs from './AssetOutputs.svelte' -import { asset } from './fixtures/published' -import { groupAssetOutputs } from './assets' -import { assetPathToUrl } from '#lib/pieces/assets.js' -import { fileType, transformCaption, transformFamily } from './presentation' -function groupAssets(assets: Parameters[0], base: string) { - return groupAssetOutputs(assets, (path) => assetPathToUrl(path, base)) +function renderOutputs( + assets: PublicWebPieceAsset[], + fileUrl: (path: string) => string | undefined = () => undefined +) { + return render(AssetOutputs, { props: { assets, fileUrl } }).body.replace(//g, '') } -// Quoted attributes can contain literal > characters from recorded paths. -const tagPattern = /<\/?[a-z][\w-]*\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi - -function elements(html: string, tag: string) { - const stack: number[] = [] - const matches: { start: number; html: string }[] = [] - for (const match of html.matchAll(tagPattern)) { - if (!new RegExp(`^ { + const output: PublicWebPieceAsset = { + asset_key: 'rendered-note', + transformation: 'markdown', + asset_path: 'generated/note.html', + mime_type: 'text/html', + content: '

Recorded note

' } - return matches.sort((a, b) => a.start - b.start).map((match) => match.html) -} + const fileUrl = (path: string) => + path === output.asset_path ? '/admin/preview/current-job/asset/generated/note.html' : undefined -function cellValue(html: string) { - return html - .replace(/]*class="cell-label[^"]*"[^>]*>[\s\S]*?<\/span>/g, '') - .replace(/"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g, (link) => - (link.match(/\btitle="([^"]*)"/)?.[1] ?? '').replaceAll('"', '"') - ) - .replace(tagPattern, '') - .trim() -} + const html = renderOutputs([output], fileUrl) -function table(html: string, caption: string) { - const found = elements(html, 'table').find( - (candidate) => cellValue(elements(candidate, 'caption')[0] ?? '') === caption - ) - expect(found, `table caption: ${caption}`).toBeDefined() - return found! -} + const link = html.match(/]*>/)?.[0] + expect(link).toContain('href="/admin/preview/current-job/asset/generated/note.html"') + expect(link).toContain('target="_blank"') + expect(link).toMatch(/\brel="[^"]*\bnoopener\b[^"]*"/) + expect(link).toMatch(/\brel="[^"]*\bnoreferrer\b[^"]*"/) + expect(link).toContain('aria-label="View markdown: generated/note.html"') + expect(html).toContain('aria-label="Show Markdown: Asset 1, Content"') + expect(html).toContain('aria-expanded="false"') + expect(html).not.toContain('untrustedSource()') + expect(html).not.toContain('

Recorded note') +}) -function rows(html: string) { - // Metadata projections only; expectDisclosureRows separately checks EVERY detail row. - return elements(html, 'tr') - .filter((row) => !isSourceRow(row)) - .map((row) => - elements(row, 'th') - .concat(elements(row, 'td')) - .map((cell) => cellValue(cell)) - ) -} - -function allLinks(html: string) { - return elements(html, 'a') -} - -// Existing counts describe recorded files (View), not secondary actions. -function fileLinks(html: string) { - return allLinks(html).filter((link) => link.endsWith('>View')) -} - -function escapedText(value: string) { - return value.replaceAll('&', '&').replaceAll('<', '<') -} - -function escapedAttribute(value: string) { - return escapedText(value).replaceAll('"', '"') -} - -const columns = ['Output', 'Field', 'Type', 'Value'] - -function valueRow(output: string, field: string, mime: string, value: string) { - return [escapedText(output), escapedText(field), fileType(mime), value] -} - -function expectShowValue(html: string, output: string, content: string, ordinal = 1) { - const label = escapedAttribute(`Show ${transformCaption(output)}: Asset ${ordinal}, Content`) - const buttons = elements(html, 'button').filter((button) => - button.includes(`aria-label="${label}"`) - ) - expect(buttons).toHaveLength(1) - expect(buttons[0]).toContain('type="button"') - expect(buttons[0]).toMatch(/>Show<\/button>$/) - expect(buttons[0]).not.toContain('disabled') - expect(buttons[0]).toContain('aria-expanded="false"') - expect(buttons[0]).toContain('aria-controls=') - // Text-only comparison avoids mistaking aria-expanded="false" for JSON payload. - expect(cellValue(html)).not.toContain(escapedText(content)) -} - -function isSourceRow(row: string) { - return /^]*class="source-row(?:[ "\s])/.test(row) -} - -function expectDisclosureRows(html: string, assets: PublicWebPieceAsset[]) { - const expected = groupAssets(assets, '/cdn').transforms.flatMap((family) => - family.values.filter((value) => value.value !== null && value.value !== '') - ) - const detailRows = elements(html, 'tr').filter(isSourceRow) - const buttons = elements(html, 'button') - expect(detailRows).toHaveLength(expected.length) - expect(buttons).toHaveLength(expected.length) - const ids: string[] = [] - for (const body of elements(html, 'tbody')) { - const allRows = elements(body, 'tr') - for (const [index, row] of allRows.entries()) { - if (!isSourceRow(row)) continue - const opening = row.match(tagPattern)![0] - const id = opening.match(/\bid="([^"]+)"/)?.[1] - expect(id).toMatch(/-source-\d+-\d+-\d+$/) - ids.push(id!) - expect(opening).toContain('role="row"') - expect(opening).toMatch(/\shidden(?:="")?(?:\s|>)/) - expect(elements(row, 'th')).toHaveLength(0) - const cells = elements(row, 'td') - expect(cells).toHaveLength(1) - expect(cells[0]).toContain('role="cell"') - expect(cells[0]).toContain('colspan="4"') - expect(cells[0]).toMatch(/class="source-cell(?:[ "\s])/) - expect(cellValue(cells[0])).toBe('') - expect(cells[0]).not.toContain('cell-label') - expect(elements(row, 'table')).toHaveLength(0) - const trigger = elements(allRows[index - 1] ?? '', 'button') - expect(trigger).toHaveLength(1) - expect(trigger[0]).toContain(`aria-controls="${id}"`) - expect(trigger[0]).toContain('aria-expanded="false"') - expect(trigger[0]).toMatch(/aria-label="Show [^"]+: Asset \d+, Content"/) - expect(trigger[0]).toMatch(/>Show<\/button>$/) - } +test('different assets with the same output name have distinct, linked disclosure targets', () => { + const first: PublicWebPieceAsset = { + asset_key: 'first-snippet', + transformation: 'highlight', + mime_type: 'text/html', + content: '
First source
' } - expect(new Set(ids).size).toBe(ids.length) - for (const button of buttons) { - const id = button.match(/aria-controls="([^"]+)"/)?.[1] - expect(ids.filter((candidate) => candidate === id)).toHaveLength(1) + const second = { ...first, asset_key: 'second-snippet', content: '
Second source
' } + + const html = renderOutputs([first, second]) + const buttons = [...html.matchAll(/]*>/g)].map(([tag]) => tag) + const rows = [...html.matchAll(/]*>/g)].map(([tag]) => tag) + const targets = buttons.map((button) => button.match(/aria-controls="([^"]+)"/)?.[1]) + + expect(buttons).toHaveLength(2) + expect(buttons[0]).toContain('aria-label="Show Highlight: Asset 1, Content"') + expect(buttons[1]).toContain('aria-label="Show Highlight: Asset 2, Content"') + expect(new Set(targets).size).toBe(2) + for (const target of targets) { + expect(target).toBeTruthy() + const row = rows.find((row) => row.includes(`id="${target}"`)) + expect(row).toBeDefined() + expect(row).toMatch(/\shidden(?:\s|=|>)/) } - expect(html).not.toMatch(/\bdata-(?:source|value|payload)=/) - for (const tag of ['pre', 'code', 'script', 'style', 'iframe']) - expect(elements(html, tag)).toHaveLength(0) -} + expect(html).not.toContain('First source') + expect(html).not.toContain('Second source') +}) -function fileRow(output: string, path: string, type: string) { - return [escapedText(output), 'File', type, path] -} - -function expectFileRow(html: string, path: string, type: string, output: string) { - expect(rows(html)).toContainEqual(fileRow(output, path, type)) -} - -function expectUnavailableRow(html: string, path: string, type: string, output: string) { - const row = elements(html, 'tr').find((row) => - elements(row, 'td').some((cell) => cell.includes(`${path}`)) - ) - expect(row).toBeDefined() - expectFileRow(row!.replace(/]*>Unavailable<\/small>/g, ''), path, type, output) - expect(allLinks(row!)).toHaveLength(0) - expect(elements(row!, 'small').map(cellValue)).toEqual(['Unavailable']) - expect(row).toContain('title="No usable file link"') -} - -function expectFileViews(html: string) { - const views = fileLinks(html) - expect(allLinks(html)).toHaveLength(views.length) - expect(html).not.toMatch(/\sdownload(?:\s|=|>)/) - for (const link of views) { - expect(link).toMatch(/>View<\/a>$/) - expect(link).toContain('aria-label="View ') - expect(link).toContain('title=') - expect(link).not.toContain('truncated') - expect(link).toContain('target="_blank"') - expect(link).toContain('rel="external noopener noreferrer"') - expect(link).not.toMatch(/\sdownload(?:\s|=|>)/) - const href = link.match(/\bhref="([^"]*)"/)?.[1] - expect(href).toBeDefined() +test('missing or unavailable files remain visible without invented View links', () => { + const unavailable: PublicWebPieceAsset = { + asset_key: 'unavailable', + transformation: 'attachment', + asset_path: 'missing.pdf', + mime_type: 'application/pdf' } -} + const unsafe = { ...unavailable, asset_key: 'unsafe', asset_path: '../private.pdf' } + const unrecorded = { ...unavailable, asset_key: 'unrecorded', asset_path: null } -function renderAssets(assets: PublicWebPieceAsset[], base = '/cdn') { - const html = render(AssetOutputs, { - props: { - assets, - fileUrl: (path) => assetPathToUrl(path, base), - heading: 'Published assets' - } - }).body.replace(//g, '') - expect(html).toMatch(/]*>Published assets<\/h2>/) - expect(elements(html, 'caption').map(cellValue)).toEqual( - groupAssets(assets, base).transforms.map((family) => escapedText(transformCaption(family.name))) - ) - for (const candidate of elements(html, 'table')) { - const head = elements(candidate, 'thead')[0] - const headers = elements(head, 'th') - expect(headers.map(cellValue)).toEqual(columns) - for (const header of headers) expect(header).toContain('scope="col"') - const bodies = elements(candidate, 'tbody') - for (const body of bodies) { - const groupHeads = elements(body, 'th').filter((cell) => cell.includes('scope="rowgroup"')) - expect(groupHeads).toHaveLength(bodies.length > 1 ? 1 : 0) - for (const row of elements(body, 'tr')) { - const headers = elements(row, 'th') - const cells = elements(row, 'td') - if (isSourceRow(row)) { - expect(headers).toHaveLength(0) - expect(cells).toHaveLength(1) - expect(cells[0]).toContain('colspan="4"') - continue - } - expect(headers).toHaveLength(1) - if (headers[0].includes('scope="rowgroup"')) expect(cells).toHaveLength(0) - else { - expect(headers[0]).toContain('scope="row"') - expect(cells).toHaveLength(3) - } - } - for (const groupHead of groupHeads) { - expect(groupHead).toContain('role="rowheader"') - expect(groupHead).toContain(`colspan="${headers.length}"`) - expect(groupHead).not.toContain('cell-label') - expect(cellValue(groupHead)).toMatch(/^Asset \d+$/) - } - } - expect(elements(candidate, 'table')).toHaveLength(1) + const html = renderOutputs([unavailable, unsafe, unrecorded]) + + expect(html).toContain('missing.pdf') + expect(html).toContain('../private.pdf') + expect(html).toContain('Unavailable') + expect(html).toContain('Not recorded') + expect(html).not.toMatch(/ { + const embedded: PublicWebPieceAsset = { + asset_key: 'missing-value', + transformation: 'palette', + mime_type: 'application/json', + is_embedded: 1 } - for (const excluded of [ - 'Technical details', - '>Key<', - 'Role<', - '>Open<', - '>MIME<', - '>Status<', - '>Recorded<', - 'Original assets', - 'Generated files', - 'Image variants', - '>Variant<', - '>Images<', - 'Colors (palette)', - 'class="swatch', - ' style=', - 'Open recorded file', - '

Available<' - ]) - expect(html).not.toContain(excluded) - expectFileViews(html) - expectDisclosureRows(html, assets) - return html -} + const empty = { ...embedded, asset_key: 'empty-value', content: '' } + const jsonNull = { ...embedded, asset_key: 'json-null', content: 'null' } -test('every recorded family has one generic caption and retains each full output', () => { - const html = renderAssets( - [ - asset('image.original', 'cover #?.jpg'), - asset('attachment', 'cover.pdf', 'cover', 'application/pdf'), - asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp'), - asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp'), - asset('image.m.avif', 'cover/m.avif', 'cover', 'image/avif'), - asset('opengraph', 'piece/share.png', 'piece', 'image/png'), - asset('custom', 'files/other.zip', 'other', 'application/zip'), - { ...asset('markdown', null, 'cover', 'text/html'), content: '

PRIVATE INLINE

' }, - { ...asset('palette', null), content: 'PRIVATE PALETTE' }, - { ...asset('highlight', null), content: 'PRIVATE HIGHLIGHT', is_embedded: 1 } - ], - 'https://cdn.test/prefix/' - ) - for (const [output, path, type] of [ - ['image.original', 'cover #?.jpg', 'JPEG'], - ['image.s.webp', 'cover/s.webp', 'WebP'], - ['image.m.avif', 'cover/m.avif', 'AVIF'] - ]) { - const files = table(html, 'Image') - expectFileRow(files, path, type, output) - expect(elements(files, 'tbody')).toHaveLength(1) - expect(fileLinks(files)).toHaveLength(3) - expect(files).not.toContain('colspan=') - expect(files).not.toContain('cover.pdf') - } - expectFileRow(table(html, 'Attachment'), 'cover.pdf', 'PDF', 'attachment') - expectFileRow(table(html, 'Custom'), 'files/other.zip', 'ZIP', 'custom') - expect(html).toContain('title="files/other.zip"') - expectFileRow(table(html, 'Opengraph'), 'piece/share.png', 'PNG', 'opengraph') - expect(html).toContain('https://cdn.test/prefix/pieces/assets/cover%20%23%3F.jpg') - expect(fileLinks(html)).toHaveLength(6) - expect(rows(table(html, 'Markdown'))).toContainEqual( - valueRow('markdown', 'Content', 'text/html', 'Show') - ) - expect(html).not.toContain('PRIVATE INLINE') - expect(rows(table(html, 'Palette'))).toContainEqual( - valueRow('palette', 'Content', 'image/jpeg', 'Show') - ) - expect(rows(table(html, 'Highlight'))).toContainEqual( - valueRow('highlight', 'Content', 'image/jpeg', 'Show') - ) - expect(elements(html, 'caption').map(cellValue)).toEqual([ - 'Image', - 'Attachment', - 'Opengraph', - 'Custom', - 'Markdown', - 'Palette', - 'Highlight' - ]) - for (const excluded of ['/admin/asset', '/admin/preview', ' { - const html = renderAssets( - [ - asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp'), - asset('attachment', '../private.pdf', 'attachment', 'application/pdf') - ], - '' - ) - expectFileRow(table(html, 'Image'), 'cover/s.webp', 'WebP', 'image.s.webp') - expect(elements(html, 'table')).toHaveLength(2) - expect(html).not.toContain('Image.original') - expect(html).not.toContain('Not recorded') - expectUnavailableRow(table(html, 'Attachment'), '../private.pdf', 'PDF', 'attachment') - expect(html).not.toContain('href="/pieces/assets/..') - expect(fileLinks(html)).toHaveLength(1) - expect(html).not.toContain('Other published files') -}) - -test('no records renders only a simple None recorded marker without invented tables', () => { - const html = renderAssets([]) - expect(elements(html, 'table')).toHaveLength(0) - expect(elements(html, 'caption')).toHaveLength(0) - expect(elements(html, 'p')).toHaveLength(0) - expect(html).toMatch(/]*class="empty[^" ]*[^"]*"[^>]*>None recorded<\/span>/) - expect(html.match(/None recorded/g)).toHaveLength(1) - for (const caption of ['Attachment', 'Opengraph', 'Output', 'Images']) - expect(html).not.toContain(`>${caption}<`) - expect(fileLinks(html)).toHaveLength(0) -}) - -test.each([null, '', '../private.png', '/private.png', 'https://evil.test/file.png'])( - 'missing or unsafe PNG %s remains an actual Opengraph record without a link', - (path) => { - const html = renderAssets([asset('opengraph', path, 'piece', 'image/png')]) - if (path) expectUnavailableRow(table(html, 'Opengraph'), path, 'PNG', 'opengraph') - else expectFileRow(table(html, 'Opengraph'), 'Not recorded', 'PNG', 'opengraph') - expect(html).not.toContain('None recorded') - expect(fileLinks(html)).toHaveLength(0) - expect(html).not.toContain('Other published files') - } -) - -test('unknown transformation, file and raw MIME are escaped; malformed MIME displays File', () => { - const transformation = '' - const path = '.pdf' - const html = renderAssets([asset(transformation, path, 'other', '')]) - expectFileRow(table(html, escapedText(transformation)), escapedText(path), 'File', transformation) - expect(html).toContain(`aria-label="${escapedAttribute(`View ${transformation}: ${path}`)}"`) - expect(html).toContain(`title="${escapedAttribute(path)}"`) - expect(html).toContain('title="<svg>"') - for (const excluded of ['']) expect(html).not.toContain(excluded) -}) - -test('attachment and image variant records stay independent without invented originals', () => { - const html = renderAssets([ - asset('attachment', 'cover.pdf', 'cover', 'application/pdf'), - asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp') - ]) - expect(html).not.toContain('Image.original') - expect(html).not.toContain('Not recorded') - expectFileRow(table(html, 'Image'), 'cover/s.webp', 'WebP', 'image.s.webp') - expectFileRow(table(html, 'Attachment'), 'cover.pdf', 'PDF', 'attachment') -}) - -test('image.original and deduplicated same-path attachment have independent actual captions', () => { - const original = asset('image.original', 'shared.jpg') - const attachment = asset('attachment', 'shared.jpg', 'cover', 'application/octet-stream') - const html = renderAssets([attachment, original, original, attachment], '/app') - for (const caption of ['Image', 'Attachment']) { - const candidate = table(html, caption) - expectFileRow( - candidate, - 'shared.jpg', - caption === 'Image' ? 'JPEG' : 'Binary', - caption === 'Image' ? 'image.original' : 'attachment' - ) - expect(fileLinks(candidate)).toHaveLength(1) - } - expect(html.match(/href="\/app\/pieces\/assets\/shared.jpg"/g)).toHaveLength(2) - expect(html).not.toContain('Other published files') - expect(rows(html).filter((row) => row[0] === 'None recorded')).toHaveLength(0) -}) - -test('same-basename keys retain per-key bodies in one Image family table', () => { - const names = [ - 'image.original', - ...['s', 'm', 'l', 'xl'].flatMap((size) => - ['avif', 'webp'].map((format) => `image.${size}.${format}`) - ) - ] - const assets = ['first', 'second'].flatMap((key) => - names.map((name) => { - const format = name === 'image.original' ? 'jpeg' : name.split('.')[2] - return asset(name, `${key}/${name}`, key, `image/${format}`) - }) - ) - const html = renderAssets(assets) - expect(fileLinks(html)).toHaveLength(18) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Image']) - const bodies = elements(table(html, 'Image'), 'tbody') - expect(bodies).toHaveLength(2) - for (const [index, key] of ['first', 'second'].entries()) { - const expected = names.map((name) => { - const type = name === 'image.original' ? 'JPEG' : name.endsWith('avif') ? 'AVIF' : 'WebP' - expectFileRow(bodies[index], `${key}/${name}`, type, name) - return fileRow(name, `${key}/${name}`, type) - }) - expect(rows(bodies[index])).toEqual([[`Asset ${index + 1}`], ...expected]) - expect(fileLinks(bodies[index])).toHaveLength(9) - expect(bodies[index]).not.toContain(' { - const html = renderAssets( - ['image.original', 'attachment', 'image.s.webp', 'document.custom'].map((transformation) => - asset(transformation, null) - ) - ) - expectFileRow(table(html, 'Image'), 'Not recorded', 'JPEG', 'image.original') - // The recorded MIME, not the transformation's format, determines Type. - expectFileRow(table(html, 'Image'), 'Not recorded', 'JPEG', 'image.s.webp') - expectFileRow(table(html, 'Attachment'), 'Not recorded', 'JPEG', 'attachment') - expectFileRow(table(html, 'Document'), 'Not recorded', 'JPEG', 'document.custom') - expect(html).toContain('title="document.custom"') - expect(rows(html).filter((row) => row[0] === 'None recorded')).toHaveLength(0) - expect(html).not.toContain('Unavailable') - expect(fileLinks(html)).toHaveLength(0) -}) - -test('unsafe paths are not archive, preview, external or guessed links; actual non-PNG Opengraph is shown', () => { - const html = renderAssets( - [ - asset('image.original', '../archive/cover.jpg'), - asset('attachment', '/admin/asset/private.html', 'attachment', 'text/html'), - asset('image.s.webp', 'https://evil.test/cover.webp', 'cover', 'image/webp'), - asset('unknown', 'bad\\path.txt', 'other', 'text/plain'), - asset('opengraph', 'share.jpg', 'piece', 'image/jpeg') - ], - 'https://app.test/site/' - ) - expectUnavailableRow(table(html, 'Image'), '../archive/cover.jpg', 'JPEG', 'image.original') - expectUnavailableRow(table(html, 'Attachment'), '/admin/asset/private.html', 'HTML', 'attachment') - expectUnavailableRow(table(html, 'Image'), 'https://evil.test/cover.webp', 'WebP', 'image.s.webp') - expectUnavailableRow(table(html, 'Unknown'), 'bad\\path.txt', 'Text', 'unknown') - expect(html.match(/>Unavailable { - const html = renderAssets( - [ - asset('attachment', 'files/' - ], - [ - 'highlight', - 'text/html', - '' - ], - ['markdown.description', 'text/plain', 'javascript:alert(1) data:text/html,'], - ['custom', 'text/css', 'body{background:url(javascript:alert(1));} @import "https://evil.test";'], - [ - 'palette', - 'application/json', - '{"":"","style":"background:url(javascript:bad)"}' - ] -])( - 'untrusted %s values stay absent while collapsed, never active markup, CSS or links', - (name, mime, content) => { - const html = renderAssets([{ ...asset(name, null, 'unsafe', mime), content }]) - const caption = transformCaption(transformFamily(name)) - const values = table(html, caption) - expect(rows(values)).toContainEqual(valueRow(name, 'Content', mime, 'Show')) - expectShowValue(values, name, content) - for (const tag of ['script', 'img', 'iframe', 'svg', 'style', 'input', 'textarea']) - expect(html).not.toMatch(new RegExp(`<${tag}\\b`, 'i')) - expect(html).not.toMatch(/<(?:[^>"']|"[^"]*"|'[^']*')*\s(?:style|onload|onerror)=/i) - expect(fileLinks(values)).toHaveLength(0) - } -) - -test('huge Unicode JSON becomes Show without DOM payload or exposing metadata field names', () => { - const key = `${'key'.repeat(80)}้›ช "&` - const field = `${'field'.repeat(80)}้›ช "&` - const value = ` ${'้›ช๐Ÿ˜€ &\n'.repeat(4000)} ` - const html = renderAssets([ - { - ...asset('palette', null, key, 'application/json'), - content: JSON.stringify({ [field]: value }) - } - ]) - const palette = table(html, 'Palette') - expect(elements(palette, 'button')).toHaveLength(1) - expect(palette).toContain(`aria-label="${escapedAttribute(`Show Palette: Asset 1, Content`)}"`) - expect(palette).not.toContain(escapedText(value)) - expect(palette).not.toContain('raw</script>') - expect(html.length).toBeLessThan(12000) - const projected = groupAssets( - [ - { - ...asset('palette', null, key, 'application/json'), - content: JSON.stringify({ [field]: value }) - } - ], - '/cdn' - ) - expect(projected.transforms[0].values[0].value).toBe(JSON.stringify({ [field]: value })) - expect(palette).not.toContain(escapedText(key)) - expect(palette).not.toContain(escapedAttribute(key)) - expect(palette).not.toContain(escapedText(field)) - expect(palette).not.toContain(escapedAttribute(field)) -}) - -test('Markdown huge source, short JSON and plain content all use Show without SSR payload', () => { - const source = ` ${'้›ช๐Ÿ˜€ &\n'.repeat(4000)} ` - const html = renderAssets([ - asset('attachment', 'other.pdf', 'first', 'application/pdf'), - { ...asset('markdown', null, 'private-key', 'text/html; charset=utf-8'), content: source }, - { - ...asset('markdown.description', null, 'third', 'application/json'), - content: JSON.stringify({ description: '้›ช & raw', count: 42 }) - }, - { ...asset('markdown.description', null, 'private-key', 'text/plain'), content: 'different' } - ]) - const description = table(html, 'Markdown') - expect(elements(html, 'caption').map(cellValue)).toEqual(['Attachment', 'Markdown']) - expect(rows(description)).toEqual([ - columns, - ['Asset 2'], - valueRow('markdown', 'Content', 'text/html; charset=utf-8', 'Show'), - valueRow('markdown.description', 'Content', 'text/plain', 'Show'), - ['Asset 3'], - valueRow('markdown.description', 'Content', 'application/json', 'Show') - ]) - const buttons = elements(html, 'button') - expectShowValue( - description, - 'markdown.description', - JSON.stringify({ description: '้›ช & raw', count: 42 }), - 3 - ) - expect(buttons).toHaveLength(3) - expect(buttons.map((button) => button.match(/aria-label="([^"]*)"/)?.[1])).toEqual([ - 'Show Markdown: Asset 2, Content', - 'Show Markdown.description: Asset 2, Content', - 'Show Markdown.description: Asset 3, Content' - ]) - for (const button of buttons) { - expect(button).toContain('type="button"') - expect(button).toMatch(/>Show<\/button>$/) - expect(button).not.toContain('disabled') - } - expect(html).not.toContain('aria-live=') - expect(html.length).toBeLessThan(12000) - for (const excluded of ['data-', 'private-key', 'bad()', '<script>', ' { - const html = renderAssets([ - { ...asset('markdown', null, 'null', 'text/html'), is_embedded: 1 }, - { ...asset('markdown', null, 'empty', 'text/html'), content: '' }, - asset('markdown', 'actual/page.html', 'file', 'text/html'), - { ...asset('markdown.description', null, 'null', 'text/plain'), is_embedded: 1 }, - { ...asset('markdown.description', null, 'empty', 'text/plain'), content: '' } - ]) - expectFileRow(table(html, 'Markdown'), 'actual/page.html', 'HTML', 'markdown') - const values = table(html, 'Markdown') - expect(elements(html, 'caption').map(cellValue)).toEqual(['Markdown']) - for (const output of ['markdown', 'markdown.description']) { - const mime = output === 'markdown' ? 'text/html' : 'text/plain' - expect(rows(values)).toContainEqual(valueRow(output, 'Content', mime, 'Not recorded')) - expect(rows(values)).toContainEqual(valueRow(output, 'Content', mime, 'Empty')) - } - expect(elements(values, 'button')).toHaveLength(0) - expect(fileLinks(html)).toHaveLength(1) -}) - -test('worker-shaped palette retains one whole value without field or color projection', () => { - const record = { - ...asset('palette', null, 'cover', 'application/json; charset=utf-8'), - content: JSON.stringify({ - background: '#123456', - bodyText: '#fff', - titleText: '#000F', - accent: '#ABCDEF', - muted: '#12345678', - swatches: ['#abc', 'red; background:url(javascript:bad)', ''], - nested: { color: '#fff', values: [['#abc']] }, - empty: [], - missing: null, - blank: '', - active: false, - count: 0 - }) - } - const palette = table(renderAssets([record]), 'Palette') - expect(rows(palette)).toEqual([columns, valueRow('palette', 'Content', record.mime_type, 'Show')]) - expectShowValue(palette, 'palette', record.content) - expect(groupAssets([record], '/cdn').transforms[0].values).toEqual([ - { - key: 'cover', - output: 'palette', - field: 'Content', - value: record.content, - mime: record.mime_type - } - ]) - expect(palette).toContain('title="application/json; charset=utf-8"') - expect(fileLinks(palette)).toHaveLength(0) - expect(palette).not.toContain('Not recorded') -}) - -test('parent owns identity-keyed disclosure state and lazily delegates only opened source', () => { - // Source contract only: Node SSR cannot click or run the client effect. - const source = readFileSync(new URL('./AssetOutputs.svelte', import.meta.url), 'utf8') - expect(source).toContain("import { transformCaption, fileType } from './presentation'") - expect(source).toContain("import { toggleDisclosure } from './disclosure'") - expect(source).toContain('let expanded = $state.raw>(new Set())') - expect(source).toContain('expanded = toggleDisclosure(expanded, value)') - const effect = source.match(/\$effect\(\(\) => \{([\s\S]*?)\}\)/)?.[1] ?? '' - expect(effect).toContain('void grouped') - expect(effect).toContain('expanded = new Set()') - expect(effect).not.toContain('expanded.') - expect(effect).not.toContain('toggleDisclosure') - expect(source).toContain('{#each group.items as row, rowIndex (row)}') - expect(source).toContain('`${titleId}-source-${familyIndex}-${groupIndex}-${rowIndex}`') - expect(source).toContain('`${transformCaption(output)}: ${assetLabel(group.key)}, ${field}`') - expect(source).toContain('onclick={() => toggle(row)}') - expect(source).toMatch( - /\{#if expanded.has\(row\)\}\s*\s*\{\/if\}/ - ) - expect(source).toContain('hidden={!expanded.has(row)}') - for (const forbidden of [ - 'AssetValue', - 'CopyValue', - 'copyText', - 'copyFeedback', - 'fileActions', - 'download', - 'valuePresentation', - 'initialExpanded', - 'style:', - 'literal\n\tsecond line '], - [' TEXT/HTML ; charset=UTF-8', '

้›ช & raw

'], - ['text/css', 'body { background: url(javascript:bad); }'], - ['application/javascript', 'alert("raw")'], - ['application/xml', 'raw'], - ['image/svg+xml', ''], - ['text/x-python', 'print("raw")'], - ['text/markdown', '# Recorded source'], - ['text/json', '{broken'], - ['application/problem+json', 'null'], - [' TEXT/Custom+JSON ; charset=utf-8 ', '{ "id":9007199254740993,"same":1,"same":2 }'], - ['invalid', ' \r\n '] -])('nonempty %s content uses contextual Show without SSR payload', (mime, content) => { - const output = 'custom.source"&' - const values = table( - renderAssets([{ ...asset(output, null, 'private-key', mime), content }]), - 'Custom' - ) - expect(rows(values)).toEqual([columns, valueRow(output, 'Content', mime, 'Show')]) - expectShowValue(values, output, content) - expect(values).toContain(`title="${escapedAttribute(mime)}"`) - expect(allLinks(values)).toHaveLength(0) - expect(values).not.toMatch(/<(?:script|style|svg|h1|output)\b/) - expect(values).not.toContain('private-key') -}) - -test.each(['/app', '', 'https://app.test/site/', '//cdn.test/prefix/'])( - 'file actions for base %s retain actual View URLs and never offer Download', - (base) => { - const path = 'files/้›ช #?% "&.html' - const html = renderAssets( - [ - asset('image.original', 'original.jpg'), - asset('image.s.webp', 'variants/s.webp', 'cover', 'image/webp'), - asset('attachment', path, 'one', 'text/html'), - asset('opengraph', 'actual/share.webp', 'piece', 'image/webp'), - asset('renamed', 'custom.bin', 'other', 'application/octet-stream'), - asset('unsafe', '../private.html', 'unsafe', 'text/html'), - asset('missing', null, 'missing', 'text/plain') - ], - base - ) - expect(fileLinks(html)).toHaveLength(5) - expect(allLinks(html)).toHaveLength(5) - const prefix = base.replace(/\/$/, '') - const recordedPaths = [ - 'original.jpg', - 'variants/s.webp', - path, - 'actual/share.webp', - 'custom.bin' - ] - const expectedHrefs = recordedPaths.map( - (recordedPath) => - `${prefix}/pieces/assets/${recordedPath.split('/').map(encodeURIComponent).join('/')}` - ) - expect(fileLinks(html).map((link) => link.match(/\bhref="([^"]*)"/)![1])).toEqual(expectedHrefs) - const href = `${prefix}/pieces/assets/${path.split('/').map(encodeURIComponent).join('/')}` - const files = table(html, 'Attachment') - expect(fileLinks(files)[0]).toContain(`href="${href}"`) - expect(allLinks(table(html, 'Unsafe'))).toHaveLength(0) - expect(allLinks(table(html, 'Missing'))).toHaveLength(0) - } -) - -test.each([ - 'palette', - 'image.original', - 'image.s.webp', - 'palette.renamed', - 'image.renamed', - 'markdown-renamed', - 'Markdown', - 'custom.', - 'constructor', - '__proto__' -])('%s retains native View and whole inline values independently of producer identity', (name) => { - const path = 'files/้›ช #? .bin' - const key = 'raw-private-key' - for (const [mime, content, marker] of [ - ['text/plain', '#AbC', 'Show'], - ['image/png', 'BASE64', 'Show'], - ['application/json', '{ "__proto__":"own","same":1,"same":2,"escaped":"\\u96ea" }', 'Show'], - ['text/html', '', 'Empty'], - ['text/html', null, 'Not recorded'] - ] as const) { - const record = { - ...asset(name, path, key, mime), - content: content ?? undefined, - is_embedded: 1 as const - } - const html = renderAssets([record, record], '/app') - const caption = escapedText(transformCaption(transformFamily(name))) - const outputs = table(html, caption) - expect(elements(html, 'caption').map(cellValue)).toEqual([caption]) - expect(rows(outputs)).toEqual([ - columns, - fileRow(name, escapedText(path), fileType(mime)), - valueRow(name, 'Content', mime, marker) - ]) - // Count all links independently: an extra action must not hide behind a View filter. - expect(allLinks(outputs)).toHaveLength(1) - expect(fileLinks(outputs)).toHaveLength(1) - const view = fileLinks(outputs)[0] - expect(view).toContain('href="/app/pieces/assets/files/%E9%9B%AA%20%23%3F%20%3Cactual%3E.bin"') - expect(view).toContain(`aria-label="${escapedAttribute(`View ${name}: ${path}`)}"`) - expect(view).toContain(`title="${escapedAttribute(path)}"`) - expect(outputs).toContain(`title="${escapedAttribute(mime)}"`) - expect(elements(outputs, 'button')).toHaveLength(marker === 'Show' ? 1 : 0) - if (marker === 'Show') expectShowValue(outputs, name, content!) - expect(groupAssets([record, record], '/app').transforms[0].values).toEqual([ - { key, output: name, field: 'Content', value: content, mime } - ]) - expect(html).not.toContain(key) - for (const tag of ['img', 'svg', 'style', 'script', 'iframe']) - expect(html).not.toMatch(new RegExp(`<${tag}\\b`, 'i')) - } -}) - -test.each(['image.original', 'image.s.webp', 'palette', 'renamed'])( - 'actual %s records retain MIME, content and embedding differences; only exact repeats disappear', - (name) => { - const first = { ...asset(name, 'same/path.bin', 'first', 'text/plain'), content: '#abc' } - const secondKey = { ...first, asset_key: 'second' } - const html = renderAssets([ - first, - first, - { ...first, content: '#fff' }, - { ...first, mime_type: 'image/png' }, - { ...first, is_embedded: 1 }, - secondKey, - secondKey - ]) - const caption = transformCaption(transformFamily(name)) - const files = table(html, caption) - expect(fileLinks(files)).toHaveLength(5) - expect(elements(files, 'tbody')).toHaveLength(2) - expect(elements(html, 'caption').map(cellValue)).toEqual([caption]) - expect(rows(elements(files, 'tbody')[0])).toEqual([ - ['Asset 1'], - fileRow(name, 'same/path.bin', 'Text'), - fileRow(name, 'same/path.bin', 'Text'), - fileRow(name, 'same/path.bin', 'PNG'), - fileRow(name, 'same/path.bin', 'Text'), - valueRow(name, 'Content', 'text/plain', 'Show'), - valueRow(name, 'Content', 'text/plain', 'Show'), - valueRow(name, 'Content', 'image/png', 'Show'), - valueRow(name, 'Content', 'text/plain', 'Show') - ]) - expect(rows(elements(files, 'tbody')[1])).toEqual([ - ['Asset 2'], - fileRow(name, 'same/path.bin', 'Text'), - valueRow(name, 'Content', 'text/plain', 'Show') - ]) - expect(elements(files, 'button')).toHaveLength(5) - expect(html).not.toContain('style=') - } -) - -test('one Image family table retains file and inline capabilities for every full output', () => { - const original = asset('image.original', 'same/file.bin', 'cover', 'image/jpeg') - const variant = asset('image.s.webp', 'same/file.bin', 'cover', 'image/webp') - const note = { - ...asset('image.note', 'notes.txt', 'cover', 'text/plain'), - content: 'actual inline note' - } - const html = renderAssets([original, variant, original, note, note]) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Image']) - const images = table(html, 'Image') - expect(rows(images)).toEqual([ - columns, - fileRow('image.original', 'same/file.bin', 'JPEG'), - fileRow('image.s.webp', 'same/file.bin', 'WebP'), - fileRow('image.note', 'notes.txt', 'Text'), - valueRow('image.note', 'Content', 'text/plain', 'Show') - ]) - expect(fileLinks(images)).toHaveLength(3) - expect(elements(images, 'tbody')).toHaveLength(1) - expect(elements(images, 'tr').filter(isSourceRow)).toHaveLength(1) - expectShowValue(images, 'image.note', 'actual inline note') - for (const output of ['image.original', 'image.s.webp', 'image.note']) { - expect(images).toContain(`title="${output}"`) - expect(images).toContain(`${output}`) - } -}) - -test('Markdown file, note and schema outputs share a table but keep distinct Show context', () => { - const html = renderAssets([ - asset('markdown', 'page.html', 'piece', 'text/html'), - { ...asset('markdown.description', null, 'piece', 'text/html'), content: '

private A

' }, - { ...asset('markdown.note', null, 'piece', 'text/html'), content: '

private B

' }, - { - ...asset('markdown.schema', null, 'piece', 'application/json'), - content: '{"description":"short text","count":2}' - } - ]) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Markdown']) - const markdown = table(html, 'Markdown') - expect(rows(markdown)).toEqual([ - columns, - fileRow('markdown', 'page.html', 'HTML'), - valueRow('markdown.description', 'Content', 'text/html', 'Show'), - valueRow('markdown.note', 'Content', 'text/html', 'Show'), - valueRow('markdown.schema', 'Content', 'application/json', 'Show') - ]) - expect( - elements(markdown, 'button').map((button) => button.match(/aria-label="([^"]*)"/)![1]) - ).toEqual([ - 'Show Markdown.description: Asset 1, Content', - 'Show Markdown.note: Asset 1, Content', - 'Show Markdown.schema: Asset 1, Content' - ]) - expectShowValue(markdown, 'markdown.schema', '{"description":"short text","count":2}') - expect(fileLinks(markdown)).toHaveLength(1) - expect(markdown).not.toContain('private A') - expect(markdown).not.toContain('private B') -}) - -test('unfamiliar namespace collisions retain full outputs and whole own-key JSON records', () => { - const content = '{"__proto__":"own","constructor":"also own","color":"#AbC"}' - const first = { ...asset('custom.FIRST', 'same.json', 'one', 'application/json'), content } - const second = { ...first, transformation: 'custom.Second' } - const html = renderAssets([first, second, first, second]) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Custom']) - const custom = table(html, 'Custom') - expect(rows(custom)).toEqual([ - columns, - fileRow('custom.FIRST', 'same.json', 'JSON'), - fileRow('custom.Second', 'same.json', 'JSON'), - ...['custom.FIRST', 'custom.Second'].map((output) => - valueRow(output, 'Content', 'application/json', 'Show') - ) - ]) - expect(fileLinks(custom)).toHaveLength(2) - for (const output of ['custom.FIRST', 'custom.Second']) { - expect(custom).toContain(`title="${output}"`) - expectShowValue(custom, output, content) - } - expect(elements(custom, 'button')).toHaveLength(2) -}) - -test('combined file-only and inline-only keys share family bodies with root-order aliases', () => { - const html = renderAssets([ - asset('attachment', 'first.pdf', 'root-first', 'application/pdf'), - { ...asset('custom.note', null, 'inline-second', 'text/plain'), content: 'inline only' }, - asset('custom.file', 'third.txt', 'file-third', 'text/plain'), - { ...asset('custom.missing', null, 'embedded-fourth', 'text/plain'), is_embedded: 1 } - ]) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Attachment', 'Custom']) - const custom = table(html, 'Custom') - expect(rows(custom)).toEqual([ - columns, - ['Asset 2'], - valueRow('custom.note', 'Content', 'text/plain', 'Show'), - ['Asset 3'], - fileRow('custom.file', 'third.txt', 'Text'), - ['Asset 4'], - valueRow('custom.missing', 'Content', 'text/plain', 'Not recorded') - ]) - expect(elements(custom, 'tbody')).toHaveLength(3) - for (const body of elements(custom, 'tbody')) { - const heading = elements(body, 'th')[0] - expect(heading).toContain('scope="rowgroup"') - expect(heading).toContain('colspan="4"') - } - expectShowValue(custom, 'custom.note', 'inline only', 2) - expect(fileLinks(custom)).toHaveLength(1) - for (const key of ['root-first', 'inline-second', 'file-third', 'embedded-fourth']) - expect(html).not.toContain(key) -}) - -test('same displayed family caption does not merge case-distinct namespaces or raw output labels', () => { - const output = `Custom.${'long-output-'.repeat(30)}้›ช "&` - const html = renderAssets([ - { ...asset('custom.note', null, 'one', 'text/plain'), content: 'lower namespace' }, - { ...asset(output, null, 'one', 'text/plain'), content: 'upper namespace' } - ]) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Custom', 'Custom']) - const tables = elements(html, 'table') - expect(rows(tables[0])).toEqual([ - columns, - valueRow('custom.note', 'Content', 'text/plain', 'Show') - ]) - expect(rows(tables[1])).toEqual([columns, valueRow(output, 'Content', 'text/plain', 'Show')]) - const header = elements(elements(tables[1], 'tbody')[0], 'th')[0] - expect(header).toContain('scope="row"') - expect(header).toContain(`title="${escapedAttribute(output)}"`) - expect(header).toContain(`${escapedText(output)}`) - expectShowValue(tables[1], output, 'upper namespace') - expect(html).not.toContain('') -}) - -test.each([undefined, 'Published assets', 'Preview outputs', ' & "text"'])( - 'heading %j labels only its own section without exposing record keys', - (heading) => { - const html = render(AssetOutputs, { - props: { - assets: [asset('custom.file', 'file.txt', 'secret-key', 'text/plain')], - fileUrl: () => undefined, - ...(heading === undefined ? {} : { heading }) - } - }).body.replace(//g, '') - const section = elements(html, 'section')[0] - const title = elements(section, 'h2')[0] - const titleId = title.match(/\bid="([^"]+)"/)?.[1] - expect(titleId).toBeDefined() - expect(titleId).not.toBe('published-assets-title') - expect(section.match(/\baria-labelledby="([^"]+)"/)?.[1]).toBe(titleId) - expect(cellValue(title)).toBe(escapedText(heading ?? 'Outputs')) - expect(html.match(new RegExp(`id="${titleId}"`, 'g'))).toHaveLength(1) - expect(html).not.toContain('secret-key') - if (heading === undefined) { - expect(html).not.toContain('Published') - expect(html).not.toContain('Preview') - } - } -) - -test('section title IDs come from Svelte instance IDs, not headings or record keys', () => { - const source = readFileSync(new URL('./AssetOutputs.svelte', import.meta.url), 'utf8') - expect(source).toContain('const titleId = $props.id()') - expect(source).toContain('
') - expect(source).toContain('

{heading}

') - expect(source).not.toContain('published-assets-title') -}) - -test('generic preview-shaped callback URLs retain only native View, full outputs and inline family rows', () => { - const path = 'files/ๆบ #?%/file%20name.json' - const url = - '/admin/piece/book/preview/job-id/assets/files/%E6%BA%90%20%23%3F%25/file%2520name.json' - const record = { - ...asset('custom.full.output', path, 'secret-key', 'application/json'), - content: '{"__proto__":"own","value":"#abc"}' - } - const fileUrl = vi.fn((rawPath: string) => (rawPath === path ? url : undefined)) - const html = render(AssetOutputs, { - props: { - assets: [ - record, - record, - { ...asset('custom.note', null, 'secret-key', 'text/plain'), content: 'inline only' } - ], - fileUrl, - heading: 'Preview outputs' - } - }).body.replace(//g, '') - const custom = table(html, 'Custom') - expectFileViews(html) - expect(fileUrl).toHaveBeenCalledExactlyOnceWith(path) - expect(elements(html, 'h2').map(cellValue)).toEqual(['Preview outputs']) - expect(elements(html, 'caption').map(cellValue)).toEqual(['Custom']) - expect(rows(custom)).toEqual([ - columns, - fileRow('custom.full.output', path, 'JSON'), - valueRow('custom.full.output', 'Content', 'application/json', 'Show'), - valueRow('custom.note', 'Content', 'text/plain', 'Show') - ]) - expectShowValue(custom, 'custom.full.output', record.content) - expect(fileLinks(custom)).toHaveLength(1) - expect(allLinks(custom)).toHaveLength(1) - expectDisclosureRows(html, [ - record, - record, - { ...asset('custom.note', null, 'secret-key', 'text/plain'), content: 'inline only' } - ]) - const view = fileLinks(custom)[0] - for (const link of [view]) { - expect(link).toContain(`href="${url}"`) - expect(link).toContain(`title="${escapedAttribute(path)}"`) - expect(link).toContain('rel="external noopener noreferrer"') - } - expect(view).toContain(`aria-label="${escapedAttribute(`View custom.full.output: ${path}`)}"`) - expect(view).toContain('target="_blank"') - expect(view).not.toMatch(/\sdownload(?:\s|=|>)/) - expect(html).not.toContain('secret-key') - expect(html).not.toContain('/pieces/assets/') -}) - -test('invalid and callback-unavailable files stay visible alongside embedded values without bogus links', () => { - const fileUrl = vi.fn((_path: string) => undefined) - const html = render(AssetOutputs, { - props: { - assets: [ - asset('custom.unsafe', '../bad.txt', 'secret-key', 'text/plain'), - { - ...asset('custom.file', 'valid.txt', 'secret-key', 'text/plain'), - content: 'inline value' - }, - asset('custom.missing', null, 'secret-key', 'text/plain'), - { ...asset('custom.embedded', null, 'secret-key', 'text/plain'), is_embedded: 1 as const } - ], - fileUrl - } - }).body.replace(//g, '') - const custom = table(html, 'Custom') - expectFileViews(html) - expect(fileUrl).toHaveBeenCalledExactlyOnceWith('valid.txt') - expectUnavailableRow(custom, '../bad.txt', 'Text', 'custom.unsafe') - expectUnavailableRow(custom, 'valid.txt', 'Text', 'custom.file') - expect(rows(custom)).toContainEqual(valueRow('custom.file', 'Content', 'text/plain', 'Show')) - expect(rows(custom)).toContainEqual(fileRow('custom.missing', 'Not recorded', 'Text')) - expect(rows(custom)).toContainEqual( - valueRow('custom.embedded', 'Content', 'text/plain', 'Not recorded') - ) - expect(fileLinks(custom)).toHaveLength(0) - expect(allLinks(custom)).toHaveLength(0) - expect(html).not.toContain('No usable published link') - expect(html).not.toContain('secret-key') -}) - -test('detail rows are native full-width siblings only for nonempty inline values', () => { - const html = renderAssets([ - asset('custom.file', 'actual.txt', 'same-key', 'text/plain'), - { ...asset('custom.empty', null, 'same-key', 'text/plain'), content: '' }, - { ...asset('custom.missing', null, 'same-key', 'text/plain'), is_embedded: 1 }, - { ...asset('custom.note', null, 'same-key', 'text/plain'), content: 'raw private note' }, - { ...asset('custom.json', null, 'same-key', 'application/json'), content: '{ "raw": 1 }' } - ]) - const custom = table(html, 'Custom') - // Count ALL native rows before checking the metadata projection. - expect(elements(custom, 'tr')).toHaveLength(8) // header + 5 metadata + 2 detail - expect(elements(custom, 'tr').filter(isSourceRow)).toHaveLength(2) - expect(rows(custom)).toHaveLength(6) - expect(elements(custom, 'td')).toHaveLength(17) // 5 * 3 metadata + 2 colspan cells - expect(elements(custom, 'button').map(cellValue)).toEqual(['Show', 'Show']) - expectShowValue(custom, 'custom.note', 'raw private note') - expectShowValue(custom, 'custom.json', '{ "raw": 1 }') - expect(elements(custom, 'table')).toHaveLength(1) -}) - -test('disclosure IDs use instance and family/group/row ordinals even for equal labels and values', () => { - const first = { - ...asset('custom.same', 'first.txt', 'private-key-first', 'text/plain'), - content: 'private repeated bytes' - } - const html = renderAssets([ - first, - first, // Exact full records still deduplicate. - { ...first, mime_type: 'image/png' }, - { ...first, asset_key: 'private-key-second' }, - { ...first, transformation: 'other.same' } - ]) - const instanceId = elements(html, 'h2')[0].match(/\bid="([^"]+)"/)![1] - const ids = elements(html, 'tr') - .filter(isSourceRow) - .map((row) => row.match(/\bid="([^"]+)"/)![1]) - expect(ids).toEqual([ - `${instanceId}-source-0-0-2`, - `${instanceId}-source-0-0-3`, - `${instanceId}-source-0-1-1`, - `${instanceId}-source-1-0-1` - ]) - expect( - elements(html, 'button').map((button) => button.match(/aria-controls="([^"]+)"/)![1]) - ).toEqual(ids) - expect(html).not.toContain('private-key-first') - expect(html).not.toContain('private-key-second') - expect(html).not.toContain('private repeated bytes') - // All rows, including two group headings and four detail rows, remain visible to this count. - expect(elements(html, 'tr')).toHaveLength(16) // 2 headers + 2 group headings + 8 metadata + 4 detail -}) - -test('mobile source cells override the label grid and collapsed rows override mobile display', () => { - // CSS/source contract only; this does not claim browser layout or focus verification. - const source = readFileSync(new URL('./AssetOutputs.svelte', import.meta.url), 'utf8') - const mobile = source.slice(source.indexOf('@media')) - const cell = mobile.match(/tbody > tr > td\.source-cell\s*\{([^}]+)\}/)?.[1] ?? '' - for (const rule of ['display: block', 'width: 100%', 'padding: var(--space-2) var(--space-3)']) - expect(cell).toContain(rule) - for (const forbidden of [ - 'grid-template-columns', - 'margin', - 'padding-left', - 'padding-inline-start' - ]) - expect(cell).not.toContain(forbidden) - expect(mobile.indexOf('td.source-cell')).toBeGreaterThan(mobile.indexOf('tbody > tr > td {')) - expect(source.match(/\[hidden\]\s*\{([^}]+)\}/)?.[1]).toContain('display: none !important') - const button = source.match(/\n\tbutton\s*\{([^}]+)\}/)?.[1] ?? '' - for (const rule of [ - 'font: inherit', - 'font-weight: var(--font-weight-normal)', - 'var(--color-primary)', - 'var(--color-surface-container)' - ]) - expect(button).toContain(rule) - expect(source).toContain('button:focus-visible') - expect(source).toContain("expanded.has(row) ? 'Hide' : 'Show'") - expect(source).not.toContain('svelte-ignore') + expect(html).toContain('Not recorded') + expect(html).toContain('Empty') + expect(html.match(/ ({ resolve: (route: string, params: Record) => route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name]) })) -// The synchronous renderer stub checks the viewport's SSR contract and real asset helpers. -// Node SSR cannot verify zoom layout, natural template heights, 3D paint/shadows, -// resize/orientation updates, or ResizeObserver mounting and cleanup in a browser. +// SSR establishes the initial composition, not client resize behavior. vi.mock('#lib/pieces/components/icon.svelte', async () => ({ default: (await import('./fixtures/IconRenderer.svelte')).default })) -test.each([ - { preview: false, src: '/cdn/pieces/assets/published/cover.jpg' }, - { preview: true, src: '/admin/preview/preview-job/asset/published/cover.jpg' } -])('SSR keeps eager 500px rendering and resource URLs (preview=$preview)', (state) => { - page.data.preview = state.preview +test('starts with an eager 500px composition without imposing a height on the selected icon', () => { const html = render(IconViewport, { props: { piece } }).body - expect(html.match(/]*class="icon-frame\b[^>]*>/)?.[0] expect(frame).toBeDefined() expect(frame).toContain('width: 500px; zoom: 1') - // Height belongs to the selected template, not a shared 750px canvas. expect(frame).not.toContain('height:') }) diff --git a/apps/web/src/lib/pieces/viewer/PieceOverview.test.ts b/apps/web/src/lib/pieces/viewer/PieceOverview.test.ts deleted file mode 100644 index 2f38a68a..00000000 --- a/apps/web/src/lib/pieces/viewer/PieceOverview.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { readFileSync } from 'node:fs' -import { expect, test } from 'vitest' -import { render } from 'svelte/server' -import { createRawSnippet } from 'svelte' -import { assetPathToUrl } from '#lib/pieces/assets.js' -import { asset } from './fixtures/published' -import PieceOverview from './PieceOverview.svelte' - -test('shared overview owns title, facts slot and actual output inspector, not a main landmark or publication policy', () => { - const children = createRawSnippet(() => ({ - render: () => '

Caller facts fixture

' - })) - const html = render(PieceOverview, { - props: { - title: 'Piece ', - assets: [asset('image.original', 'cover.jpg')], - fileUrl: (path) => assetPathToUrl(path, '/cdn'), - children - } - }).body.replace(/<!--[^]*?-->/g, '') - expect(html).toContain('Piece <title & "text">') - expect(html).toContain('Caller facts fixture') - expect(html).toContain('Image</caption>') - expect(html).toContain('href="/cdn/pieces/assets/cover.jpg"') - expect(html).toContain('Outputs</h2>') - expect(html).not.toContain('<main') - expect(html).not.toContain('Last publish') - expect(html.indexOf('Caller facts fixture')).toBeLessThan(html.indexOf('Image</caption>')) -}) - -test('shared overview omits not-yet-produced assets and puts caller logs after its facts', () => { - const children = createRawSnippet(() => ({ - render: () => '<p>Pending facts fixture</p><section><h2>Preview logs</h2></section>' - })) - const html = render(PieceOverview, { - props: { - title: 'Preview', - fileUrl: () => { - throw new Error('No asset callback expected') - }, - children - } - }).body - expect(html).toContain('Pending facts fixture') - expect(html).toContain('Preview logs') - expect(html.indexOf('Pending facts fixture')).toBeLessThan(html.indexOf('Preview logs')) - expect(html).not.toContain('None recorded') - expect(html).not.toContain('Outputs</h2>') - expect(html).not.toContain('<main') -}) - -test('shared overview places caller facts and logs before actual output tables', () => { - const children = createRawSnippet(() => ({ - render: () => '<p>Facts fixture</p><section><h2>Preview logs</h2></section>' - })) - const html = render(PieceOverview, { - props: { - title: 'Preview', - assets: [asset('image.original', 'cover.jpg')], - fileUrl: (path) => assetPathToUrl(path, '/cdn'), - children - } - }).body - expect(html).toContain('Image</caption>') - expect(html.indexOf('Facts fixture')).toBeLessThan(html.indexOf('Preview logs')) - expect(html.indexOf('Preview logs')).toBeLessThan(html.indexOf('Image</caption>')) -}) - -test('Preview and Published share the same badge styles and success/pending color rules', () => { - const source = readFileSync(new URL('./PieceOverview.svelte', import.meta.url), 'utf8') - const success = source.match( - /\[data-status='matching'\]\),\s*\.overview :global\(\.overview-facts \.status\[data-status='completed'\]\)\s*\{([^}]+)\}/ - )?.[1] - expect(success).toContain('background: var(--color-primary-container)') - expect(success).toContain('color: var(--color-on-primary-container)') - const pending = source.match( - /\[data-status='changed'\]\),\s*\.overview :global\(\.overview-facts \.status\[data-status='running'\]\)\s*\{([^}]+)\}/ - )?.[1] - expect(pending).toContain('background: var(--color-secondary-container)') - expect(pending).toContain('color: var(--color-on-secondary-container)') -}) - -test('shared overview can show empty preview outputs without guessing URLs or requiring publication facts', () => { - const html = render(PieceOverview, { - props: { - title: 'Preview title', - assets: [], - fileUrl: () => undefined, - assetsHeading: 'Preview assets' - } - }).body - expect(html).toContain('Preview title') - expect(html).toContain('Preview assets</h2>') - expect(html).toContain('None recorded') - expect(html).not.toContain('<a ') - expect(html).not.toContain('Publication') -}) diff --git a/apps/web/src/lib/pieces/viewer/ShareViewport.test.ts b/apps/web/src/lib/pieces/viewer/ShareViewport.test.ts deleted file mode 100644 index 93acc18c..00000000 --- a/apps/web/src/lib/pieces/viewer/ShareViewport.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { render } from 'svelte/server' -import { OpengraphImageWidth, OpengraphImageHeight } from '@luzzle/web.pieces' -import ShareViewport from './ShareViewport.svelte' -import { publishedPiece as rawPublishedPiece, asset } from './fixtures/published' - -const publishedPiece = { - ...rawPublishedPiece, - templates: { - icon: { name: 'default', fields: { image: 'cover' } }, - page: { name: 'default', fields: { image: 'cover' } }, - opengraph: { name: 'default', fields: { image: 'cover' } } - } -} - -vi.mock('$app/state', () => ({ - page: { data: { config: { url: { app: '/garden', luzzle_assets: '/cdn' } } } } -})) -vi.mock('$app/paths', () => ({ - resolve: (route: string, params: Record<string, string>) => - route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name]) -})) -// This synchronous stub establishes viewport markup and helper URLs, -// not the actual static renderer or client prop updates. -vi.mock('#lib/pieces/components/opengraph.svelte', async () => ({ - default: (await import('./fixtures/Composition.svelte')).default -})) - -test('shows only the composition with published assets, without explanatory text', () => { - const html = render(ShareViewport, { props: { piece: publishedPiece } }).body - expect(html).toContain('aria-label="Share"') - expect(html).not.toContain('Rendered share composition') - expect(html).not.toContain('Reconstructed with') - expect(html).not.toContain('not the generated PNG') - expect(html).not.toContain('<h1') - expect(html).not.toContain('<p') - expect(html).toContain('data-renderer="composition"') - expect(html).toContain('src="/cdn/pieces/assets/published/cover.jpg"') - expect(html).not.toContain('/admin/asset') - expect(html).not.toContain('/admin/preview') - expect(html).not.toContain('Open generated share image') - expect(html).toContain(`max-width: ${OpengraphImageWidth}px; height: ${OpengraphImageHeight}px`) - expect(html).toContain(`width: ${OpengraphImageWidth}px; height: ${OpengraphImageHeight}px`) - expect(html.match(/<main /g)).toHaveLength(1) -}) - -test('recorded generated PNG remains separate from the composition', () => { - const piece = { - ...publishedPiece, - assets: [...publishedPiece.assets, asset('opengraph', 'actual-share.png', 'piece', 'image/png')] - } - const html = render(ShareViewport, { props: { piece } }).body - expect(html).toContain('data-renderer="composition"') - expect(html).not.toContain('actual-share.png') - expect(html).not.toContain('Open generated share image') -}) - -test('missing assets still permit reconstruction without claiming a generated file', () => { - const html = render(ShareViewport, { props: { piece: { ...publishedPiece, assets: [] } } }).body - expect(html).toContain('aria-label="Share"') - expect(html).toContain('data-renderer="composition"') - expect(html).not.toContain('Rendered share composition') - expect(html).not.toContain('Open generated share image') - expect(html).not.toContain('/pieces/assets/') -}) diff --git a/apps/web/src/lib/pieces/viewer/SourceContent.test.ts b/apps/web/src/lib/pieces/viewer/SourceContent.test.ts index ab0d6aef..f5255b4e 100644 --- a/apps/web/src/lib/pieces/viewer/SourceContent.test.ts +++ b/apps/web/src/lib/pieces/viewer/SourceContent.test.ts @@ -1,89 +1,36 @@ -import { readFileSync } from 'node:fs' import { expect, test } from 'vitest' import { render } from 'svelte/server' import SourceContent from './SourceContent.svelte' -function source(content: string, mime = 'text/plain') { +function renderSource(content: string, mime: string) { return render(SourceContent, { props: { value: { - key: 'private-key', - output: 'custom.output', + key: 'source', + output: 'markdown', field: 'Content', value: content, mime }, - label: 'Custom.output: Asset 1, Content' + label: 'Markdown: Asset 1, Content' } - }).body.replace(/<!--[^]*?-->/g, '') + }).body } -function escaped(text: string) { - return text.replaceAll('&', '&').replaceAll('<', '<') -} +test('recorded HTML is shown as source text, not executed or rendered as a document', () => { + const content = '<script>alert("x")</script> & <iframe src="https://bad.test"></iframe>' -function code(html: string) { - return html.match(/<code[^>]*>([\s\S]*?)<\/code>/)?.[1].replace(/<\/?span\b[^>]*>/g, '') -} + const html = renderSource(content, 'text/html') -test.each(['text/html', 'image/svg+xml', 'text/css', 'text/javascript', 'unknown'])( - '%s content stays inert and complete instead of executing as markup or CSS', - (mime) => { - const text = - ' <script>alert("x")</script>\r\n<iframe src="https://bad.test"></iframe> & <style>body{display:none}</style>\n ' - const html = source(text, mime) - expect(code(html)).toBe(escaped(text)) - for (const tag of ['<script', '<iframe', '<style', '<svg', '<img']) - expect(html).not.toContain(tag) - expect(html).not.toContain('private-key') - expect(html).toContain('role="region"') - expect(html).toContain('tabindex="0"') - expect(html).toContain('aria-label="Custom.output: Asset 1, Content source"') - } -) - -test('JSON source is pretty-printed without changing numeric lexemes, duplicate keys or string escapes', () => { - const html = source( - '{"id":9007199254740993,"id":1e400,"html":"<script>bad()</script>","literal":"\\u0041"}', - 'application/json' - ) - expect(code(html)).toBe( - escaped( - '{\n "id": 9007199254740993,\n "id": 1e400,\n "html": "<script>bad()</script>",\n "literal": "\\u0041"\n}' - ) - ) - expect(html).not.toContain('<script>') + expect(html).toContain('<script>alert("x")</script> & <iframe') + expect(html).not.toMatch(/<(?:script|iframe)\b/) + expect(html).toContain('role="region"') + expect(html).toContain('tabindex="0"') + expect(html).toContain('aria-label="Markdown: Asset 1, Content source"') }) -test('malformed JSON remains the full original text', () => { - const content = ' { broken <script>alert(1)</script>\r\n' - expect(code(source(content, 'application/json'))).toBe(escaped(content)) -}) +test('JSON source is passed through the lossless formatter before display', () => { + const html = renderSource('{"count":2}', 'application/json') -test('large source remains available in full rather than truncating the record', () => { - const content = ' snow ้›ช <tag> & \r\n'.repeat(12000) - expect(code(source(content))).toBe(escaped(content)) -}) - -test('source view uses the editor token palette with a bounded keyboard-scrollable viewport', () => { - const file = readFileSync(new URL('./SourceContent.svelte', import.meta.url), 'utf8') - expect(file).toContain("import '#lib/components/editor/themes/token-colors.css'") - expect(file).toContain('class="syntax-highlight"') - expect(file).toContain('max-height: 24rem') - expect(file).toContain('overflow: auto') - expect(file).toContain('var(--editor-foreground, var(--color-on-surface))') - expect(file).toContain('var(--editor-background, var(--color-surface))') - expect(file).not.toContain('{@html') - expect(file).not.toContain('innerHTML') -}) - -test('highlighting is loaded after expansion/mount with a current-source guard and safe plain fallback', () => { - const file = readFileSync(new URL('./SourceContent.svelte', import.meta.url), 'utf8') - expect(file).toContain("import('./source-highlight.js')") - expect(file).toContain('highlighted?.source === source && highlighted.mime === value.mime') - expect(file).toContain('if (active) highlighted =') - expect(file).toContain('active = false') - expect(file).toContain('{token.text}') - expect(file).not.toContain('fetch(') - expect(file).not.toContain('clipboard') + expect(html).toContain('{\n "count": 2\n}') }) diff --git a/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts b/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts index a7230b18..76a475b0 100644 --- a/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts +++ b/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts @@ -1,229 +1,48 @@ -import { readFileSync } from 'node:fs' -import { beforeEach, expect, test, vi } from 'vitest' +import { expect, test, vi } from 'vitest' import { render } from 'svelte/server' import ViewerHeader from './ViewerHeader.svelte' -const { page, mount, resolve } = vi.hoisted(() => { - const mount = { prefix: '' } - return { - mount, - page: { - url: new URL('https://app.test/'), - route: { id: '/(auth)/admin/piece/[...path]/published' } - }, - resolve: vi.fn( - (route: string, params: { path: string }) => - `${mount.prefix}${route.replace('/(auth)', '').replace('[...path]', params.path)}` - ) - } -}) +const { page } = vi.hoisted(() => ({ + page: { route: { id: '/(auth)/admin/piece/[...path]/published' } } +})) vi.mock('$app/state', () => ({ page })) -vi.mock('$app/paths', () => ({ resolve })) +vi.mock('$app/paths', () => ({ + resolve: (route: string, params: { path: string }) => + route.replace('/(auth)', '').replace('[...path]', () => params.path) +})) -// This resolver double tests delegation and encoded params, not native Kit/browser routing. -const file = 'books/ๆบ #?%.books.md' -const encodedPath = 'books/%E6%BA%90%20%23%3F%25.books.md' +const file = 'notes/icon/้›ช #?%.books.md' +const root = '/admin/piece/notes/icon/%E9%9B%AA%20%23%3F%25.books.md' const publishedRoute = '/(auth)/admin/piece/[...path]/published' -const tabSuffixes = { overview: '', page: '/page', icon: '/icon', share: '/share' } -function links(path = encodedPath, prefix = '') { - const root = `${prefix}/admin/piece/${path}` - return { - source: `${root}/source`, - overview: `${root}/published`, - page: `${root}/published/page`, - icon: `${root}/published/icon`, - share: `${root}/published/share` - } -} - -function expectNavigation(path = encodedPath) { - const routes = [ - '/(auth)/admin/piece/[...path]/source', - ...Object.values(tabSuffixes).map((suffix) => publishedRoute + suffix) +test('all views and the editor link refer to the current archive file', () => { + const destinations = [ + 'source', + 'published', + 'published/page', + 'published/icon', + 'published/share' ] - expect(resolve).toHaveBeenCalledTimes(routes.length) - for (const route of routes) expect(resolve).toHaveBeenCalledWith(route, { path }) -} -beforeEach(() => { - page.url = new URL('https://app.test/') - page.route.id = publishedRoute - mount.prefix = '' - vi.clearAllMocks() -}) + const html = render(ViewerHeader, { props: { file } }).body -test.each(['overview', 'page', 'icon', 'share'] as const)( - '%s is active by route ID and delegates navigation to resolve', - (tab) => { - const navigation = links() - page.url = new URL(navigation[tab], 'https://app.test') - page.route.id = publishedRoute + tabSuffixes[tab] - const html = render(ViewerHeader, { props: { file } }).body - expect(html).toContain(`href="${navigation.source}"`) - expect(html).toContain('aria-label="Back to editor"') - expect(html).toContain('title="Back to editor"') - expect(html).toContain('aria-label="Published views"') - expect(html).toContain(`href="${navigation[tab]}" aria-current="page"`) - expect(html.match(/aria-current="page"/g)).toHaveLength(1) - for (const label of ['Overview', 'Page', 'Icon', 'Share']) expect(html).toContain(label) - expect(html).not.toContain('Logs') - expect(html).not.toContain('<main') - expect(html).not.toContain('target="_blank"') - expectNavigation() + for (const destination of destinations) { + expect(html).toContain(`href="${root}/${destination}"`) } -) + expect(html).toContain('aria-label="Back to editor"') +}) test.each([ - [file, encodedPath], - ['folder with spaces/a book.books.md', 'folder%20with%20spaces/a%20book.books.md'], - ['nested/literal%2F.books.md', 'nested/literal%252F.books.md'], - ['books/๐Ÿ“š.books.md', 'books/%F0%9F%93%9A.books.md'], - ['published/page/icon/share/source/file.md', 'published/page/icon/share/source/file.md'] -])('raw file %s becomes an encoded-once path param for all five routes', (raw, encoded) => { - // A different URL identity must not be used to derive the file or its mount. - page.url = new URL('https://app.test/garden/admin/piece/other.md/published/share?x=1#old') - page.route.id = publishedRoute + '/share' - const html = render(ViewerHeader, { props: { file: raw } }).body - for (const href of Object.values(links(encoded))) expect(html).toContain(`href="${href}"`) - expectNavigation(encoded) - expect(html).not.toContain('/garden/') - expect(html).not.toContain('other.md') -}) + { view: 'overview', suffix: '' }, + { view: 'page', suffix: '/page' }, + { view: 'icon', suffix: '/icon' }, + { view: 'share', suffix: '/share' } +])('marks only the current $view tab as active', ({ suffix }) => { + page.route.id = publishedRoute + suffix -test.each(['overview', 'page', 'icon', 'share'] as const)( - 'controlled resolver prefix and trailing slash leave %s active by route ID', - (tab) => { - mount.prefix = '/garden' - const navigation = links(encodedPath, mount.prefix) - page.url = new URL(`${navigation[tab]}/?x=1#fragment`, 'https://app.test') - page.route.id = publishedRoute + tabSuffixes[tab] - const html = render(ViewerHeader, { props: { file } }).body - expect(html).toContain(`href="${navigation[tab]}" aria-current="page"`) - expect(html.match(/aria-current="page"/g)).toHaveLength(1) - expect(html).toContain(`href="${navigation.source}"`) - // Prefix handling belongs to resolve, not the raw file or its encoded param. - expectNavigation() - } -) - -test('active tab follows route ID rather than a coincidentally matching URL pathname', () => { - page.url = new URL(links().icon, 'https://app.test') - page.route.id = publishedRoute + '/page' const html = render(ViewerHeader, { props: { file } }).body - expect(html).toContain(`href="${links().page}" aria-current="page"`) - expect(html).not.toContain(`href="${links().icon}" aria-current="page"`) - expect(html.match(/aria-current="page"/g)).toHaveLength(1) -}) -test('replacement file renders do not retain a previous source identity', () => { - render(ViewerHeader, { props: { file: 'first.md' } }) - vi.clearAllMocks() - page.url = new URL(links('second.md').share, 'https://app.test') - page.route.id = publishedRoute + '/share' - const html = render(ViewerHeader, { props: { file: 'second.md' } }).body - expect(html).toContain('/second.md/published/share') - expect(html).toContain('aria-current="page"') - expect(html).not.toContain('first') - expectNavigation('second.md') -}) - -test('back link uses the same decorative arrow icon as Preview, not a visible text label', () => { - const html = render(ViewerHeader, { props: { file } }).body - const anchors = html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? [] - const back = anchors.find((anchor) => anchor.includes('aria-label="Back to editor"'))! - expect(back).toContain(`href="${links().source}"`) - expect(back).toContain('title="Back to editor"') - expect(back).toMatch(/<svg\b[^>]*aria-hidden="true"/) - expect(back).toMatch(/<svg\b[^>]*focusable="false"/) - expect( - back - .replace(/<!--[^]*?-->/g, '') - .replace(/<[^>]*>/g, '') - .trim() - ).toBe('') - expect(back).not.toContain('target=') - expectNavigation() -}) - -test('Published header consumes the narrowly scoped shared bar stylesheet', () => { - const published = readFileSync(new URL('./ViewerHeader.svelte', import.meta.url), 'utf8') - expect(published).toContain("ViewerBar.css'") - expect(published).toContain("'virtual:icons/ph/arrow-left'") - for (const name of [ - 'viewer-bar', - 'viewer-bar-back', - 'viewer-bar-links', - 'viewer-bar-link', - 'viewer-bar-label' - ]) - expect(published).toContain(`class="${name}`) - const css = readFileSync(new URL('./ViewerBar.css', import.meta.url), 'utf8') - for (const declaration of [ - 'position: sticky', - 'top: 0', - 'z-index: 100', - 'display: grid', - "grid-template-areas: 'back links label'", - 'align-items: center', - 'gap: var(--space-3)', - 'padding: var(--space-2) var(--space-4)', - 'font-size: var(--font-size-small)', - 'text-transform: uppercase', - 'font-size: 0.9em', - 'opacity: 0.85', - 'letter-spacing: 0.05em' - ]) - expect(css).toContain(declaration) - expect(published).not.toContain('<style>') -}) - -test('shared bar stacks mode/back above full-width wrapping tabs on mobile, with comfortable targets', () => { - const css = readFileSync(new URL('./ViewerBar.css', import.meta.url), 'utf8') - const mobile = css.slice(css.indexOf('@media (max-width: 600px)')) - for (const declaration of [ - "grid-template-areas: 'back label' 'links links'", - 'justify-self: stretch', - 'flex-wrap: wrap', - 'flex: 1 0 auto', - 'padding-inline: var(--space-1)', - 'overflow-x: visible' - ]) - expect(mobile).toContain(declaration) - expect(css).toContain('min-width: 44px') - expect(css).toContain('min-height: 44px') - expect(mobile).not.toContain('display: none') - expect(mobile).not.toContain('position: absolute') - const html = render(ViewerHeader, { props: { file } }).body - expect(html).toContain('<nav class="viewer-bar-links" aria-label="Published views"') - expect(html).toContain('class="viewer-bar-label">Published</span>') - for (const label of ['Overview', 'Page', 'Icon', 'Share']) expect(html).toContain(`>${label}</a>`) -}) - -test('shared active/hover/focus states are token-based, visibly distinguish selection and retain Preview disabled styling', () => { - const css = readFileSync(new URL('./ViewerBar.css', import.meta.url), 'utf8') - for (const declaration of [ - 'color-mix(in srgb, var(--color-on-tertiary-container) 10%, transparent)', - 'color-mix(in srgb, var(--color-on-tertiary-container) 20%, transparent)', - "[aria-current='page']", - '.viewer-bar-link.active', - 'font-weight: var(--font-weight-bold)', - 'text-decoration: underline', - 'a:focus-visible', - 'outline: 2px solid currentColor', - 'outline-offset: -2px', - '.viewer-bar-link.disabled', - 'opacity: 0.35', - 'pointer-events: none' - ]) - expect(css).toContain(declaration) - expect(css).not.toContain('var(--color-surface-container)') - expect(css).not.toContain('rgba(') -}) - -test('mocked SSR after an await still delegates encoded route params', async () => { - await Promise.resolve() - const html = render(ViewerHeader, { props: { file } }).body - expect(html).toContain('Back to editor') - expectNavigation() + const activeLinks = html.match(/<a\b[^>]*aria-current="page"[^>]*>/g) ?? [] + expect(activeLinks).toHaveLength(1) + expect(activeLinks[0]).toContain(`href="${root}/published${suffix}"`) }) diff --git a/apps/web/src/lib/pieces/viewer/assets.test.ts b/apps/web/src/lib/pieces/viewer/assets.test.ts index bdf7a9d2..b3c3f508 100644 --- a/apps/web/src/lib/pieces/viewer/assets.test.ts +++ b/apps/web/src/lib/pieces/viewer/assets.test.ts @@ -1,575 +1,183 @@ import { expect, test, vi } from 'vitest' -import { groupAssetOutputs, groupOutputRows } from './assets' -import { assetPathToUrl } from '#lib/pieces/assets.js' -import { asset } from './fixtures/published' +import type { PublicWebPieceAsset } from '@luzzle/web.pieces' +import { groupAssetOutputs, groupOutputRows, type OutputFile, type OutputValue } from './assets' -function groupAssets(assets: Parameters<typeof groupAssetOutputs>[0], base: string) { - return groupAssetOutputs(assets, (path) => assetPathToUrl(path, base)) -} +const fileUrl = (path: string) => `/files/${path}` -function transform(records: Parameters<typeof groupAssetOutputs>[0], name: string, base = '') { - const section = groupAssets(records, base).transforms.find((section) => section.name === name) - expect(section).toBeDefined() - return section! -} - -test('groups projected rows in asset order without replacing row identities or reordering input', () => { - const section = transform( - [ - asset('image.original', 'second.jpg', 'second'), - asset('image.original', 'first.jpg', 'first'), - { ...asset('image.palette', null, 'first', 'text/plain'), content: '#123456' }, - { ...asset('image.palette', null, 'second', 'text/plain'), content: '#abcdef' } - ], - 'image' - ) - const rows = [...section.files, ...section.values] - const before = [...rows] - const ordinals = new Map([ - ['first', 1], - ['second', 2] - ]) - const groups = groupOutputRows(rows, ordinals) - - expect(groups.map((group) => group.key)).toEqual(['first', 'second']) - expect(groups[0].items[0]).toBe(section.files[1]) - expect(groups[0].items[1]).toBe(section.values[0]) - expect(groups[1].items[0]).toBe(section.files[0]) - expect(groups[1].items[1]).toBe(section.values[1]) - expect(rows).toEqual(before) - expect(groupOutputRows([], ordinals)).toEqual([]) -}) - -test('groups every producer uniformly while retaining keys, originals, variants and attachments as recorded', () => { - const original = asset('image.original', 'first/cover.jpg', 'first') - const records = [ - asset('attachment', 'first/cover.jpg', 'first'), - original, - original, - asset('image.s.webp', 'first/s.webp', 'first', 'image/webp'), - asset('image.s.webp', 'first/s.webp', 'first', 'image/webp'), - asset('image.original', 'second/cover.jpg', 'second') - ] - const before = structuredClone(records) - const data = groupAssets(records, 'https://cdn.example/prefix/') - expect(records).toEqual(before) - expect(data.transforms.map((section) => section.name)).toEqual(['attachment', 'image']) - expect(transform(records, 'image').files).toMatchObject([ - { key: 'first', path: 'first/cover.jpg', label: 'image.original', mime: 'image/jpeg' }, - { key: 'first', path: 'first/s.webp', label: 'image.s.webp', mime: 'image/webp' }, - { key: 'second', path: 'second/cover.jpg', label: 'image.original', mime: 'image/jpeg' } - ]) - expect(transform(records, 'image', 'https://cdn.example/prefix/').files[2].url).toBe( - 'https://cdn.example/prefix/pieces/assets/second/cover.jpg' - ) - expect(transform(records, 'image').files).toHaveLength(3) - expect(transform(records, 'attachment').files).toMatchObject([ - { key: 'first', path: 'first/cover.jpg' } - ]) - expect(data).not.toHaveProperty('images') -}) - -test('renamed jobs retain actual file and value outputs in generic producer sections', () => { - const records = [ - asset('pictures.original', 'published/cover.jpg', 'cover'), - asset('pictures.s.webp', 'published/s.webp', 'cover', 'image/webp'), - { ...asset('rendered-note', null, 'piece', 'text/html'), content: '<h1>Serving HTML</h1>' }, - { - ...asset('colors.v2', null, 'piece', 'application/json'), - content: '{"background":"#123456"}' - }, - { ...asset('new-job', 'published/body.html', 'piece', 'text/html'), content: '<b>both</b>' } - ] - const before = structuredClone(records) - const data = groupAssets(records, '/cdn') - expect(records).toEqual(before) - expect(data).not.toHaveProperty('images') - expect(data.transforms.map((section) => section.name)).toEqual([ - 'pictures', - 'rendered-note', - 'colors', - 'new-job' - ]) - expect(data.transforms[0].files[0]).toMatchObject({ - path: 'published/cover.jpg', - label: 'pictures.original', - url: '/cdn/pieces/assets/published/cover.jpg', - mime: 'image/jpeg' - }) - expect(data.transforms[0].files[1]).toMatchObject({ - label: 'pictures.s.webp', - mime: 'image/webp', - url: '/cdn/pieces/assets/published/s.webp' - }) - expect(data.transforms[1].values[0].value).toBe('<h1>Serving HTML</h1>') - expect(data.transforms[2].values).toEqual([ - { - key: 'piece', - output: 'colors.v2', - field: 'Content', - value: records[3].content, - mime: 'application/json' - } - ]) - expect(data.transforms[3].files[0].url).toBe('/cdn/pieces/assets/published/body.html') - expect(data.transforms[3].values[0]).toMatchObject({ output: 'new-job', value: '<b>both</b>' }) -}) - -test('deduplicates exact projected records, retaining different keys, transforms, MIME and inline values', () => { - const records = [ - asset('image.s.webp', 'shared.webp', 'first'), - asset('image.s.webp', 'shared.webp', 'first'), - asset('image.m.webp', 'shared.webp', 'first'), - asset('image.s.webp', 'another.webp', 'first'), - asset('image.s.webp', 'shared.webp', 'second'), - asset('custom', 'export.pdf'), - asset('custom', 'export.pdf'), - asset('custom', 'export.pdf', 'cover', 'application/pdf'), - asset('custom.other', 'export.pdf'), - { ...asset('text', null, 'piece', 'text/plain'), content: 'one' }, - { ...asset('text', null, 'piece', 'text/plain'), content: 'one' }, - { ...asset('text', null, 'piece', 'text/plain'), content: 'two' } - ] - const data = groupAssets(records, '') - expect( - transform(records, 'image').files.map((file) => [file.key, file.label, file.path]) - ).toEqual([ - ['first', 'image.s.webp', 'shared.webp'], - ['first', 'image.m.webp', 'shared.webp'], - ['first', 'image.s.webp', 'another.webp'], - ['second', 'image.s.webp', 'shared.webp'] - ]) - expect( - data.transforms.map((section) => [section.name, section.files.length, section.values.length]) - ).toEqual([ - ['image', 4, 0], - ['custom', 3, 0], - ['text', 0, 2] - ]) -}) - -test.each(['image.original', 'attachment'])( - '%s retains every distinct missing/usable path without replacing a record', - (name) => { - const records = [ - asset(name, null), - asset(name, 'valid.jpg'), - asset(name, 'also-valid.jpg'), - asset(name, 'valid.jpg') - ] - const data = groupAssets(records, '') - const files = data.transforms[0].files - expect(files.map((file) => file.path)).toEqual([null, 'valid.jpg', 'also-valid.jpg']) - expect(files[0].url).toBeUndefined() +test('groups recorded outputs by producer namespace without hiding unfamiliar producers', () => { + const original: PublicWebPieceAsset = { + asset_key: 'cover', + transformation: 'image.original', + asset_path: 'cover.jpg', + mime_type: 'image/jpeg' } -) - -test('absent originals remain absent while all size/format variants remain associated with their key', () => { - const variants = ['s', 'm', 'l', 'xl'].flatMap((size) => - ['avif', 'webp'].map((format) => asset(`image.${size}.${format}`, `cover/${size}.${format}`)) - ) - const data = groupAssets(variants, '/garden') - expect(data.transforms.map((section) => section.name)).toEqual(['image']) - expect(data.transforms[0].files).toHaveLength(8) - expect(data.transforms[0].files.map((file) => file.label)).toEqual( - variants.map((record) => record.transformation) - ) - expect(data.transforms[0].files.some((file) => file.label === 'image.original')).toBe(false) - expect(data.transforms[0].files[7].url).toBe('/garden/pieces/assets/cover/xl.webp') - expect(data.transforms[0].files.every((file) => file.key === 'cover')).toBe(true) -}) - -test('groups all actual downstream transforms including content, unknown outputs and file-plus-inline content', () => { - const records = [ - asset('opengraph', 'piece/share.png', 'piece', 'image/png'), - { - ...asset('palette', null, 'piece', 'application/json'), - content: '{"background":"#123456","accent":"#abcdef"}' - }, - { ...asset('markdown', null, 'piece', 'text/html'), content: '<h1>inline</h1>' }, - { ...asset('markdown.description', null, 'piece', 'text/html'), content: '' }, - { ...asset('highlight', null), is_embedded: 1 as const }, - asset('document.custom', 'piece/export.pdf', 'piece', 'application/pdf'), - asset('image.custom', 'piece/custom.jpg', 'piece'), - { ...asset('markdown', 'piece/body.html', 'piece', 'text/html'), content: 'inline' } - ] - const data = groupAssets(records, '/cdn') - expect(data).not.toHaveProperty('images') - expect(data.transforms.map((section) => section.name)).toEqual([ - 'opengraph', - 'palette', - 'markdown', - 'highlight', - 'document', - 'image' - ]) - expect(transform(records, 'palette').values).toEqual([ - { - key: 'piece', - output: 'palette', - field: 'Content', - value: records[1].content, - mime: 'application/json' - } - ]) - expect(transform(records, 'markdown').values.map((row) => row.value)).toEqual([ - '<h1>inline</h1>', - '', - 'inline' - ]) - expect(transform(records, 'markdown', '/cdn').files[0].url).toBe( - '/cdn/pieces/assets/piece/body.html' - ) - expect(transform(records, 'highlight').values[0].value).toBeNull() - expect(transform(records, 'markdown').values[1]).toMatchObject({ - output: 'markdown.description', - value: '' - }) -}) - -test('retains every actual Opengraph output, not just the first usable PNG', () => { - const section = transform( - [ - asset('opengraph', '../bad.png', 'piece', 'image/png'), - asset('opengraph', 'share.jpg', 'piece', 'image/jpeg'), - asset('opengraph', 'valid.png', 'piece', 'image/png'), - asset('opengraph', 'valid.png', 'piece', 'image/png'), - asset('opengraph', 'second.png', 'piece', 'image/png') - ], - 'opengraph' - ) - expect(section.files.map((file) => file.path)).toEqual([ - '../bad.png', - 'share.jpg', - 'valid.png', - 'second.png' - ]) - expect(section.files[0].url).toBeUndefined() - expect(section.files[1].mime).toBe('image/jpeg') -}) - -test.each(['image/jpeg', 'application/octet-stream', 'text/html'])( - 'Opengraph MIME %s is inspectable as recorded, never reclassified by extension', - (mime) => { - const file = transform([asset('opengraph', 'share.png', 'piece', mime)], 'opengraph').files[0] - expect(file).toMatchObject({ mime, path: 'share.png', url: '/pieces/assets/share.png' }) + const thumbnail = { + ...original, + transformation: 'image.s.webp', + asset_path: 'small.webp', + mime_type: 'image/webp' } -) - -test.each(['image.original', 'attachment', 'image.s.webp', 'document.custom'])( - '%s with neither content nor path remains a missing file record', - (name) => { - const data = groupAssets([asset(name, null)], '') - const files = data.transforms.flatMap((section) => section.files) - expect(files).toHaveLength(1) - expect(files[0]).toMatchObject({ path: null, url: undefined }) + const exported: PublicWebPieceAsset = { + asset_key: 'document', + transformation: 'document.export', + asset_path: 'document.pdf', + mime_type: 'application/pdf' } -) -test('family grouping preserves full output identities, fields, MIME and every file-plus-inline capability', () => { - const records = [ - { - ...asset('custom.a', 'same/file.json', 'one', 'application/json'), - content: '{"first":"value","second":42}' - }, - { ...asset('custom.b', 'same/file.json', 'one', 'text/plain'), content: 'raw' }, - { ...asset('custom', null, 'two', 'text/plain'), is_embedded: 1 as const }, - { ...asset('other.a', 'same/file.json', 'one', 'text/plain'), content: 'raw' } - ] - const before = structuredClone(records) - const grouped = groupAssets([...records, ...records], '/cdn') - expect(records).toEqual(before) - expect(grouped.transforms.map((group) => group.name)).toEqual(['custom', 'other']) - expect(grouped.transforms[0].files.map((file) => [file.label, file.mime, file.path])).toEqual([ - ['custom.a', 'application/json', 'same/file.json'], - ['custom.b', 'text/plain', 'same/file.json'] + const grouped = groupAssetOutputs([original, exported, thumbnail], fileUrl) + + expect(grouped.transforms.map((family) => family.name)).toEqual(['image', 'document']) + expect(grouped.transforms[0].files).toMatchObject([ + { key: 'cover', label: 'image.original', path: 'cover.jpg' }, + { key: 'cover', label: 'image.s.webp', path: 'small.webp' } ]) - expect( - grouped.transforms[0].values.map((value) => [value.output, value.field, value.value, value.key]) - ).toEqual([ - ['custom.a', 'Content', records[0].content, 'one'], - ['custom.b', 'Content', 'raw', 'one'], - ['custom', 'Content', null, 'two'] + expect(grouped.transforms[1].files).toMatchObject([ + { key: 'document', label: 'document.export', path: 'document.pdf' } ]) - expect(grouped.transforms[1].files[0].label).toBe('other.a') - expect(grouped.transforms[1].values[0].output).toBe('other.a') }) -test('family grouping uses exact prefixes, first-seen order and Map-safe names rather than a known-producer registry', () => { - const names = [ - 'custom.more', - 'imagewriter.s.webp', - 'custom', - 'image.original', - 'Image.original', - '__proto__.one', - '__proto__.two', - '.leading' - ] - const result = groupAssets( - names.map((name) => asset(name, 'file.bin')), - '' - ) - expect(result.transforms.map((group) => group.name)).toEqual([ - 'custom', - 'imagewriter', - 'image', - 'Image', - '__proto__', - '.leading' - ]) - expect(result.transforms[0].files.map((file) => file.label)).toEqual(['custom.more', 'custom']) - expect(result.transforms[4].files.map((file) => file.label)).toEqual([ - '__proto__.one', - '__proto__.two' - ]) - expect(result.transforms.flatMap((group) => group.files)).toHaveLength(names.length) -}) - -test('empty input invents no downstream transform sections', () => { - expect(groupAssets([], '')).toEqual({ transforms: [] }) -}) - -// Projection is lossless, not a JSON parser or formatter. Exercise distinct raw-data -// risks once, with MIME variations, rather than repeating syntax classes per producer. -test.each([ - ['application/json', '{}'], - ['text/json', '[]'], - ['application/example+json', 'null'], - [' APPLICATION/JSON ; charset=utf-8', '{broken'], - ['text/plain', '{"html":"<script>alert(1)</script>"}'], - ['text/html', ' <h1>raw</h1>\r\n'], - ['image/svg+xml', '{"color":"#abc"}'], - ['application/json', ''], - [ - 'application/json', - ' \r\n { "__proto__":{"polluted":true}, "constructor":"own", "same":1, "same":2, "escaped":"\\u96ea\\n" } \r\n' - ], - ['application/json', '"\\u0041\\t\\n"'], - ['application/json', '{"id":9007199254740993,"nested":{"value":1e400}}'], - ['application/json', '['.repeat(12000) + '0' + ']'.repeat(12000)] -])( - '%s retains exactly one whole Content value with original bytes and metadata', - (mime, content) => { - const record = { ...asset('custom.raw', null, 'piece', mime), content } - const before = structuredClone(record) - const section = transform([record, record], 'custom') - expect(record).toEqual(before) - expect(section.files).toEqual([]) - expect(section.values).toEqual([ - { key: 'piece', output: 'custom.raw', field: 'Content', value: content, mime } - ]) +test('removes exact replayed records without merging distinct assets, outputs or source values', () => { + const first: PublicWebPieceAsset = { + asset_key: 'first-snippet', + transformation: 'highlight', + asset_path: 'source.js', + mime_type: 'text/plain', + content: 'first source' } -) + const secondAsset = { ...first, asset_key: 'second-snippet' } + const secondOutput = { ...first, transformation: 'highlight.metadata' } + const secondValue = { ...first, content: 'second source' } + const secondMime = { ...first, mime_type: 'text/html' } -test('inline images are inspectable values and path-plus-content images retain both views', () => { - const data = groupAssets( - [ - { ...asset('image.original', null, 'embedded'), is_embedded: 1, content: 'BASE64' }, - { ...asset('image.s.webp', 's.webp', 'cover'), content: 'DATA' } - ], - '' - ) - expect(data).not.toHaveProperty('images') - expect(data.transforms.map((section) => [section.name, section.files.length])).toEqual([ - ['image', 1] - ]) - expect(data.transforms[0].values.map((value) => [value.output, value.value])).toEqual([ - ['image.original', 'BASE64'], - ['image.s.webp', 'DATA'] - ]) -}) - -test.each(['https://cdn.test/prefix/', 'https://app.test/site/', '/prefix/', ''])( - 'base %s preserves prefixes and encodes raw paths once', - (base) => { - expect( - transform([asset('attachment', 'files/ๆบ #?%.pdf')], 'attachment', base).files[0].url - ).toBe(`${base.replace(/\/+$/, '')}/pieces/assets/files/%E6%BA%90%20%23%3F%25.pdf`) - } -) - -test('percent-looking filenames are literal paths, not decoded traversal or pre-encoded URLs', () => { - expect( - transform([asset('attachment', '%2e%2e/file%20name.pdf')], 'attachment', '/cdn').files[0].url - ).toBe('/cdn/pieces/assets/%252e%252e/file%2520name.pdf') -}) - -test('absolute public base preserves prefix but excludes query and fragment', () => { - expect( - transform([asset('attachment', 'file.pdf')], 'attachment', 'https://app.test/site/?q=1#hash') - .files[0].url - ).toBe('https://app.test/site/pieces/assets/file.pdf') -}) - -test.each([ - null, - undefined, - '', - ' ', - '../private.png', - 'piece/../private.png', - '/absolute.png', - '//cdn/file.png', - 'piece//share.png', - 'piece/./share.png', - 'https://evil.test/share.png', - 'javascript:alert(1)', - 'x\\share.png' -])( - 'invalid path %j stays inspectable for every producer without invoking the URL callback', - (path) => { - for (const [output, family] of [ - ['opengraph', 'opengraph'], - ['image.original', 'image'], - ['attachment', 'attachment'], - ['custom.file', 'custom'] - ]) { - const record = { ...asset(output, null, 'private-key', 'text/plain'), asset_path: path } - const fileUrl = vi.fn(() => '/must-not-be-used') - const data = groupAssetOutputs([record, record], fileUrl) - expect(fileUrl).not.toHaveBeenCalled() - expect(data).toEqual({ - transforms: [ - { - name: family, - files: [ - { - key: 'private-key', - path: path || null, - label: output, - mime: 'text/plain', - url: undefined - } - ], - values: [] - } - ] - }) - } - } -) - -test('the URL callback receives each deduplicated raw record path once, never an encoded path', () => { - const path = 'files/ๆบ #?%/%2e%2e/file%20name.json' - const record = { - ...asset('unknown.full.output', path, 'private-key', 'application/json'), - content: '{"__proto__":"own","number":42}' - } - const fileUrl = vi.fn((rawPath: string) => - rawPath === path ? '/admin/piece/book/preview/job/assets/output.json' : undefined - ) - const data = groupAssetOutputs([record, record], fileUrl) - expect(fileUrl).toHaveBeenCalledExactlyOnceWith(path) - expect(data).toEqual({ - transforms: [ - { - name: 'unknown', - files: [ - { - key: 'private-key', - path, - label: 'unknown.full.output', - mime: 'application/json', - url: '/admin/piece/book/preview/job/assets/output.json' - } - ], - values: [ - { - key: 'private-key', - output: 'unknown.full.output', - field: 'Content', - value: record.content, - mime: 'application/json' - } - ] - } - ] - }) -}) - -test('inline-only and embedded-only outputs never ask for file URLs or invent file rows', () => { - const records = [ - { ...asset('custom.note', null, 'first', 'text/plain'), content: 'inline' }, - { ...asset('custom.empty', '', 'first', 'text/plain'), content: '' }, - { ...asset('custom.embedded', null, 'second', 'text/plain'), is_embedded: 1 as const }, - { - ...asset('custom.json', null, 'second', 'application/json'), - content: '{"own":"value"}', - is_embedded: 1 as const - } - ] - const fileUrl = vi.fn(() => '/bogus-file') - const data = groupAssetOutputs(records, fileUrl) - expect(fileUrl).not.toHaveBeenCalled() - expect(data.transforms).toHaveLength(1) - expect(data.transforms[0].files).toEqual([]) - expect( - data.transforms[0].values.map(({ output, field, value }) => [output, field, value]) - ).toEqual([ - ['custom.note', 'Content', 'inline'], - ['custom.empty', 'Content', ''], - ['custom.embedded', 'Content', null], - ['custom.json', 'Content', records[3].content] - ]) -}) - -test('a valid path with no callback URL retains both its file and inline inspection data', () => { - const path = 'files/recorded.txt' - const fileUrl = vi.fn((_path: string) => undefined) - const data = groupAssetOutputs( - [{ ...asset('custom.full', path, 'private-key', 'text/plain'), content: 'inline' }], + const grouped = groupAssetOutputs( + [first, first, secondAsset, secondOutput, secondValue, secondMime], fileUrl ) - expect(fileUrl).toHaveBeenCalledExactlyOnceWith(path) - expect(data.transforms[0].files).toEqual([ - { key: 'private-key', path, label: 'custom.full', mime: 'text/plain', url: undefined } - ]) - expect(data.transforms[0].values).toEqual([ + + expect(grouped.transforms[0].files).toHaveLength(5) + expect(grouped.transforms[0].values).toMatchObject([ + { key: 'first-snippet', output: 'highlight', value: 'first source', mime: 'text/plain' }, + { key: 'second-snippet', output: 'highlight', value: 'first source', mime: 'text/plain' }, { - key: 'private-key', - output: 'custom.full', - field: 'Content', - value: 'inline', + key: 'first-snippet', + output: 'highlight.metadata', + value: 'first source', mime: 'text/plain' + }, + { key: 'first-snippet', output: 'highlight', value: 'second source', mime: 'text/plain' }, + { key: 'first-snippet', output: 'highlight', value: 'first source', mime: 'text/html' } + ]) +}) + +test('retains both the recorded file and whole inline source without parsing the source', () => { + const output: PublicWebPieceAsset = { + asset_key: 'metadata', + transformation: 'markdown.metadata', + asset_path: 'metadata.json', + mime_type: 'application/json', + content: '{broken JSON <script>literal source</script>' + } + + const grouped = groupAssetOutputs([output], fileUrl) + + expect(grouped.transforms[0].files).toMatchObject([ + { path: 'metadata.json', url: '/files/metadata.json' } + ]) + expect(grouped.transforms[0].values).toEqual([ + { + key: 'metadata', + output: 'markdown.metadata', + field: 'Content', + mime: 'application/json', + value: output.content } ]) }) -test('dedup identity preserves delimiter-looking records and missing, empty and literal null content', () => { - const records = [ - { ...asset('custom.a:b', null, 'key', 'application/json'), is_embedded: 1 as const }, - { - ...asset('custom.a:b', null, 'key', 'application/json'), - is_embedded: 1 as const, - content: '' - }, - { - ...asset('custom.a:b', null, 'key', 'application/json'), - is_embedded: 1 as const, - content: 'null' - }, - { ...asset('custom.a:b', null, 'key', 'application/json'), content: 'null' }, - { ...asset('custom.a', null, 'key', 'b:application/json'), content: 'null' }, - { ...asset('custom.a:b', null, 'key:', 'application/json'), content: 'null' }, - { ...asset('custom.a:b', null, 'key', 'application/json'), content: 'null ' } - ] - const fileUrl = vi.fn(() => '/not-a-file') - const before = structuredClone(records) - const data = groupAssetOutputs([...records, ...records], fileUrl) - expect(records).toEqual(before) - expect(fileUrl).not.toHaveBeenCalled() - expect(data.transforms).toEqual([ - { - name: 'custom', - files: [], - values: records.map((record) => ({ - key: record.asset_key, - output: record.transformation, - field: 'Content', - value: record.content ?? null, - mime: record.mime_type - })) - } +test('distinguishes missing files, missing embedded values, empty source and literal JSON null', () => { + const missingFile: PublicWebPieceAsset = { + asset_key: 'missing-file', + transformation: 'palette', + asset_path: null, + mime_type: 'application/json' + } + const missingValue = { ...missingFile, asset_key: 'missing-value', is_embedded: 1 as const } + const empty = { ...missingValue, asset_key: 'empty', content: '' } + const jsonNull = { ...missingValue, asset_key: 'json-null', content: 'null' } + const whitespace = { ...missingValue, asset_key: 'whitespace', content: ' \r\n ' } + + const grouped = groupAssetOutputs( + [missingFile, missingValue, empty, jsonNull, whitespace], + fileUrl + ) + + expect(grouped.transforms[0].files).toMatchObject([ + { key: 'missing-file', path: null, url: undefined } + ]) + expect(grouped.transforms[0].values.map(({ key, value }) => ({ key, value }))).toEqual([ + { key: 'missing-value', value: null }, + { key: 'empty', value: '' }, + { key: 'json-null', value: 'null' }, + { key: 'whitespace', value: ' \r\n ' } + ]) +}) + +test('only usable raw paths reach the URL callback, without discarding unavailable records', () => { + const available: PublicWebPieceAsset = { + asset_key: 'available', + transformation: 'attachment', + asset_path: 'files/้›ช #?%.pdf', + mime_type: 'application/pdf' + } + const unavailable = { ...available, asset_key: 'unavailable', asset_path: 'unavailable.pdf' } + const unsafe = { ...available, asset_key: 'unsafe', asset_path: '../private.pdf' } + const previewUrl = '/admin/preview/current-job/asset/recorded.pdf' + const resolveFile = vi.fn((path: string) => + path === available.asset_path ? previewUrl : undefined + ) + + const grouped = groupAssetOutputs([available, unavailable, unsafe], resolveFile) + + expect(resolveFile).toHaveBeenCalledWith(available.asset_path) + expect(resolveFile).toHaveBeenCalledWith(unavailable.asset_path) + expect(resolveFile).not.toHaveBeenCalledWith(unsafe.asset_path) + expect(grouped.transforms[0].files).toMatchObject([ + { key: 'available', path: available.asset_path, url: previewUrl }, + { key: 'unavailable', path: 'unavailable.pdf', url: undefined }, + { key: 'unsafe', path: '../private.pdf', url: undefined } + ]) +}) + +test('file and value rows follow the original asset order rather than file-first projection order', () => { + const firstFile: OutputFile = { + key: 'first-cover', + label: 'image.original', + path: 'first.jpg', + mime: 'image/jpeg', + url: '/files/first.jpg' + } + const secondFile = { + ...firstFile, + key: 'second-cover', + path: 'second.jpg', + url: '/files/second.jpg' + } + const firstValue: OutputValue = { + key: 'first-cover', + output: 'image.note', + field: 'Content', + mime: 'text/plain', + value: 'First cover note' + } + const assetOrder = new Map([ + ['first-cover', 1], + ['second-cover', 2] + ]) + + const groups = groupOutputRows([secondFile, firstFile, firstValue], assetOrder) + + expect(groups).toEqual([ + { key: 'first-cover', items: [firstFile, firstValue] }, + { key: 'second-cover', items: [secondFile] } ]) }) diff --git a/apps/web/src/lib/pieces/viewer/disclosure.test.ts b/apps/web/src/lib/pieces/viewer/disclosure.test.ts index 0e02517a..ee92b133 100644 --- a/apps/web/src/lib/pieces/viewer/disclosure.test.ts +++ b/apps/web/src/lib/pieces/viewer/disclosure.test.ts @@ -2,62 +2,38 @@ import { expect, test } from 'vitest' import type { OutputValue } from './assets' import { toggleDisclosure } from './disclosure' -function value(): OutputValue { +function sourceRow(key: string): OutputValue { return { - key: 'private', - output: 'custom.note', + key, + output: 'highlight', field: 'Content', mime: 'text/plain', - value: 'raw' + value: 'identical source text' } } -test('opens and closes one row without mutating its input set', () => { - const row = value() +test('opening and closing a row returns new state without changing the previous state', () => { + const row = sourceRow('first-snippet') const initial: ReadonlySet<OutputValue> = new Set() + const opened = toggleDisclosure(initial, row) + const closed = toggleDisclosure(opened, row) + expect(opened).not.toBe(initial) expect([...opened]).toEqual([row]) expect(initial.size).toBe(0) - const closed = toggleDisclosure(opened, row) expect(closed).not.toBe(opened) expect(closed.size).toBe(0) - expect(opened.has(row)).toBe(true) }) -test('multiple rows stay independently open and close in any order', () => { - const first = value() - const second = value() - const third = value() - const both = toggleDisclosure(toggleDisclosure(new Set(), first), second) - expect(both.size).toBe(2) - const justSecond = toggleDisclosure(both, first) - expect(justSecond.has(second)).toBe(true) - expect(justSecond.has(first)).toBe(false) - expect(both.has(first)).toBe(true) - const secondAndThird = toggleDisclosure(justSecond, third) - expect([...secondAndThird]).toEqual([second, third]) - expect([...toggleDisclosure(secondAndThird, second)]).toEqual([third]) -}) +test('closing one asset does not close another asset with identical source text', () => { + const first = sourceRow('first-snippet') + const second = sourceRow('second-snippet') + const firstOpened = toggleDisclosure(new Set(), first) -test('identical fields and raw keys do not merge different projected row identities', () => { - const first = value() - const sameFields = { ...first } - const opened = toggleDisclosure(new Set(), first) - expect(opened.has(sameFields)).toBe(false) - const both = toggleDisclosure(opened, sameFields) - expect(both.size).toBe(2) - expect([...toggleDisclosure(both, first)]).toEqual([sameFields]) -}) + const bothOpened = toggleDisclosure(firstOpened, second) + const firstClosed = toggleDisclosure(bothOpened, first) -test('a fresh model starts from an empty set, including replaced rows with identical fields', () => { - // Parent reset wiring is a source contract in AssetOutputs.test.ts, not a mounted effect test. - const previous = value() - const previousState = toggleDisclosure(new Set(), previous) - const replacement = { ...previous } - const reset: ReadonlySet<OutputValue> = new Set() - expect(reset.has(previous)).toBe(false) - expect(reset.has(replacement)).toBe(false) - expect([...toggleDisclosure(reset, replacement)]).toEqual([replacement]) - expect([...previousState]).toEqual([previous]) + expect([...bothOpened]).toEqual([first, second]) + expect([...firstClosed]).toEqual([second]) }) diff --git a/apps/web/src/lib/pieces/viewer/presentation.test.ts b/apps/web/src/lib/pieces/viewer/presentation.test.ts deleted file mode 100644 index d3a7d7f4..00000000 --- a/apps/web/src/lib/pieces/viewer/presentation.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { expect, test } from 'vitest' -import { fileType, transformCaption, transformFamily } from './presentation' - -test.each([ - ['image', 'image'], - ['image.original', 'image'], - ['image.s.webp', 'image'], - ['markdown.description.nested', 'markdown'], - ['pictures.original', 'pictures'], - ['colors.v2', 'colors'], - ['custom.output', 'custom'], - ['imagewriter.s.webp', 'imagewriter'], - ['Image.original', 'Image'], - ['__proto__.output', '__proto__'], - ['constructor.output', 'constructor'], - ['image..webp', 'image'], - ['image.', 'image'], - ['.leading', '.leading'], - ['.', '.'], - ['', ''], - ['no-dot', 'no-dot'] -])('output %s uses only its namespace prefix to group into family %s', (name, family) => { - expect(transformFamily(name)).toBe(family) -}) - -test.each([ - ['attachment', 'Attachment'], - ['opengraph', 'Opengraph'], - ['palette', 'Palette'], - ['rendered-markdown.note', 'Rendered-markdown.note'], - ['pictures.original', 'Pictures.original'], - ['image..webp', 'Image..webp'], - ['Image.original', 'Image.original'], - ['colors.v2', 'Colors.v2'], - ['toString', 'ToString'], - ['__proto__', '__proto__'], - ['', ''] -])('caption %s preserves the actual name with generic capitalization', (name, expected) => { - expect(transformCaption(name)).toBe(expected) -}) - -test.each([ - ['image/jpeg', 'JPEG'], - ['image/png', 'PNG'], - ['image/webp', 'WebP'], - ['image/avif', 'AVIF'], - ['image/svg+xml', 'SVG'], - ['application/pdf', 'PDF'], - ['application/zip', 'ZIP'], - ['text/html', 'HTML'], - ['application/json', 'JSON'], - ['text/plain', 'Text'], - ['text/markdown', 'Markdown'], - ['application/octet-stream', 'Binary'], - [' TEXT/HTML; charset=utf-8 ', 'HTML'], - ['application/future+json', 'future+json'], - ['image/unknown', 'unknown'], - ['<svg>', 'File'], - ['text/bad subtype', 'File'], - ['application/' + 'x'.repeat(25), 'File'], - ['__proto__', 'File'], - ['constructor', 'File'], - ['toString', 'File'], - ['', 'File'] -])('recorded MIME %s supplies a label or conservative File fallback', (mime, expected) => { - expect(fileType(mime)).toBe(expected) -}) - -test('absent MIME has the same File fallback', () => { - expect(fileType()).toBe('File') -}) diff --git a/apps/web/src/lib/pieces/viewer/source-highlight.test.ts b/apps/web/src/lib/pieces/viewer/source-highlight.test.ts index 85fb9ebc..5e6da9d8 100644 --- a/apps/web/src/lib/pieces/viewer/source-highlight.test.ts +++ b/apps/web/src/lib/pieces/viewer/source-highlight.test.ts @@ -1,147 +1,64 @@ import { afterEach, expect, test, vi } from 'vitest' import { languages } from '@codemirror/language-data' import { highlightSource } from './source-highlight' -import { formatSource, SOURCE_SIZE_LIMIT, type SourceToken } from './source' +import { SOURCE_SIZE_LIMIT } from './source' afterEach(() => vi.restoreAllMocks()) -function expectReconstruction(tokens: SourceToken[], content: string) { +test('highlighting adds token classes without changing source text or whitespace', async () => { + const content = ' \r\n{"id":9007199254740993,"id":1e400,"text":"<script>literal</script>"}\t ' + + const tokens = await highlightSource(content, 'application/json') + expect(tokens.map((token) => token.text).join('')).toBe(content) - for (const token of tokens) { - expect(token.classes).toMatch(/^(?:tok-[\w-]+(?: tok-[\w-]+)*)?$/) - expect(Object.keys(token).sort()).toEqual(['classes', 'text']) - } -} - -function expectClass(tokens: SourceToken[], text: string, name: string) { - expect( - tokens.some((token) => token.text.includes(text) && token.classes.split(' ').includes(name)), - `${text}: ${name}` - ).toBe(true) -} - -test('highlights already formatted JSON with existing editor classes and exact reconstruction', async () => { - const content = formatSource( - '{"name":"value","count":9007199254740993,"ok":true,"none":null}', - 'application/json' - ) - const tokens = await highlightSource(content, ' Application/LD+JSON; charset=UTF-8 ') - expectReconstruction(tokens, content) - expectClass(tokens, '"name"', 'tok-name') - expectClass(tokens, '"value"', 'tok-string') - expectClass(tokens, '9007199254740993', 'tok-number') - expectClass(tokens, 'true', 'tok-bool') - expectClass(tokens, 'null', 'tok-keyword') - expectClass(tokens, '{', 'tok-punctuation') -}) - -test('highlights HTML tags, attributes, values and comments without rendering markup', async () => { - const content = '<!-- comment -->\r\n<div class="hello">literal <text></div>\r\n' - const tokens = await highlightSource(content, 'text/html') - expectReconstruction(tokens, content) - expectClass(tokens, 'div', 'tok-name') - expectClass(tokens, 'class', 'tok-name') - expectClass(tokens, '"hello"', 'tok-string') - expectClass(tokens, '<!-- comment -->', 'tok-comment') -}) - -test.each([ - ['text/css', 'a { color: red; }\r\n'], - ['text/javascript', 'const count = 42;\r\n// comment\r\n'], - ['text/xml', '<?xml version="1.0"?>\r\n<root count="1" />\r\n'], - ['image/svg+xml', '<svg><path d="M0 0" /></svg>\r\n'], - ['text/markdown', '# Heading\r\n\r\n> quote\r\n\r\n`code` and **strong**\r\n'], - ['application/yaml', 'title: "value"\r\n# comment\r\n'] -])('uses installed %s parser while preserving CRLF and all whitespace', async (mime, content) => { - const tokens = await highlightSource(content, mime) - expectReconstruction(tokens, content) expect(tokens.some((token) => token.classes)).toBe(true) }) -test('shares the editor extra Markdown classes without importing editor chrome', async () => { - const content = '> quote\n\n`code`\n\n---\n' - const tokens = await highlightSource(content, 'text/markdown') - expectReconstruction(tokens, content) - expectClass(tokens, 'quote', 'tok-quote') - expectClass(tokens, 'code', 'tok-monospace') - expectClass(tokens, '---', 'tok-contentSeparator') +test('unknown MIME remains complete plaintext rather than guessing from its contents', async () => { + const content = ' \r\n<div>{"value":1}</div>\t ' + + expect(await highlightSource(content, 'application/octet-stream')).toEqual([ + { text: content, classes: '' } + ]) }) -test.each([ - [ - 'text/html', - '<img src=x onerror="alert(1)" class="tok-attacker user-css"><script>alert(2)</script>' - ], - [ - 'application/json', - String.raw`{"</script>":"<img src=x onerror=alert(1)>","class":"tok-attacker user-css","escape":"\u003c"}` - ] -])( - 'keeps XSS-shaped %s payloads literal and never derives classes from content', - async (mime, content) => { - const tokens = await highlightSource(content, mime) - expectReconstruction(tokens, content) - expect(tokens.map((token) => token.classes).join(' ')).not.toMatch( - /attacker|user-css|alert|onerror/ - ) - } -) +test('oversized source bypasses the parser and remains complete', async () => { + const json = languages.find((language) => language.name === 'JSON')! + const load = vi.spyOn(json, 'load') + const content = '{"value":1}'.padEnd(SOURCE_SIZE_LIMIT + 1, ' ') -test.each(['', 'text/plain', 'application/octet-stream', 'unknown.html', 'constructor'])( - 'returns one complete plaintext token for unknown MIME %j', - async (mime) => { - const content = ' \r\n<div>{"key":1}</div>\t\r\n' - expect(await highlightSource(content, mime)).toEqual([{ text: content, classes: '' }]) - } -) - -test.each(['', ' \r\n\t', '{"incomplete":', '{"raw":1,"raw":2}'])( - 'reconstructs empty, whitespace, malformed and unformatted JSON exactly: %j', - async (content) => { - expectReconstruction(await highlightSource(content, 'application/json'), content) - } -) - -test('returns all original plaintext above the highlighting budget without loading a parser', async () => { - const description = languages.find((language) => language.name === 'JSON')! - const load = vi.spyOn(description, 'load') - const content = '{"key":1}'.padEnd(SOURCE_SIZE_LIMIT + 1, ' ') + '\r\n' expect(await highlightSource(content, 'application/json')).toEqual([ { text: content, classes: '' } ]) expect(load).not.toHaveBeenCalled() }) -test('can highlight exactly at the input size budget', async () => { - const content = '{"key":1}'.padEnd(SOURCE_SIZE_LIMIT, ' ') - const tokens = await highlightSource(content, 'application/json') - expectReconstruction(tokens, content) - expectClass(tokens, '"key"', 'tok-name') -}) - -test('falls back to complete plaintext when a known language is unavailable', async () => { +test('a missing language leaves the original source readable', async () => { vi.spyOn(languages, 'find').mockReturnValueOnce(undefined) - const content = '{"key":1}\r\n' + const content = '{"value":1}\r\n' + expect(await highlightSource(content, 'application/json')).toEqual([ { text: content, classes: '' } ]) }) -test('falls back to complete plaintext when language loading rejects', async () => { - const description = languages.find((language) => language.name === 'JSON')! - vi.spyOn(description, 'load').mockRejectedValueOnce(new Error('language unavailable')) - const content = '{"key":1}\r\n' +test('language-loading failure leaves the original source readable', async () => { + const json = languages.find((language) => language.name === 'JSON')! + vi.spyOn(json, 'load').mockRejectedValueOnce(new Error('Language unavailable')) + const content = '{"value":1}\r\n' + expect(await highlightSource(content, 'application/json')).toEqual([ { text: content, classes: '' } ]) }) -test('falls back to complete plaintext when parsing fails', async () => { +test('parser failure leaves the original source readable', async () => { const support = await languages.find((language) => language.name === 'JSON')!.load() vi.spyOn(support.language.parser, 'parse').mockImplementationOnce(() => { - throw new Error('parse failed') + throw new Error('Parser failed') }) - const content = '{"key":1}\r\n' + const content = '{"value":1}\r\n' + expect(await highlightSource(content, 'application/json')).toEqual([ { text: content, classes: '' } ]) diff --git a/apps/web/src/lib/pieces/viewer/source.test.ts b/apps/web/src/lib/pieces/viewer/source.test.ts index 73981d74..bb0e8297 100644 --- a/apps/web/src/lib/pieces/viewer/source.test.ts +++ b/apps/web/src/lib/pieces/viewer/source.test.ts @@ -1,70 +1,21 @@ import { expect, test } from 'vitest' -import { formatSource, SOURCE_SIZE_LIMIT, sourceLanguage } from './source' +import { formatSource, SOURCE_SIZE_LIMIT } from './source' -test.each([ - ['application/json', 'JSON'], - [' TEXT/JSON ; charset=utf-8 ', 'JSON'], - ['application/ld+json', 'JSON'], - ['model/gltf+json', 'JSON'], - ['text/vendor+json', 'JSON'], - ['text/html', 'HTML'], - [' Application/XHTML+XML; charset=UTF-8', 'HTML'], - ['text/css', 'CSS'], - ['text/javascript', 'JavaScript'], - ['application/javascript', 'JavaScript'], - ['text/ecmascript', 'JavaScript'], - ['application/ecmascript', 'JavaScript'], - ['text/xml', 'XML'], - ['application/xml', 'XML'], - ['application/atom+xml', 'XML'], - ['image/svg+xml', 'XML'], - ['text/custom+xml', 'XML'], - ['text/markdown', 'Markdown'], - ['application/yaml', 'YAML'], - ['application/x-yaml', 'YAML'], - ['text/yaml', 'YAML'], - ['text/x-yaml', 'YAML'] -])('maps only normalized MIME %s to installed language %s', (mime, language) => { - expect(sourceLanguage(mime)).toBe(language) -}) +test('formats structured JSON MIME types without changing nested values', () => { + const content = ' {"items":[null,{"active":true}],"empty":{}} ' -test.each([ - '', - 'text/plain', - 'application/octet-stream', - 'application/json5', - 'application/not-json', - 'image/json', - 'json', - 'index.html', - 'javascript', - '__proto__', - 'constructor', - 'text/html/other', - 'application/bad type+json' -])('does not guess a language from %s', (mime) => { - expect(sourceLanguage(mime)).toBeUndefined() - expect(formatSource(' {"key":1}\r\n', mime)).toBe(' {"key":1}\r\n') -}) - -test('pretty prints only JSON, including nested nulls, arrays and empty containers', () => { - expect( - formatSource(' \r\n{"a":[null,{"b":true,"c":false},[],{}],"empty":{ }}\t', 'application/json') - ).toBe(`{ - "a": [ + expect(formatSource(content, ' Application/Problem+JSON; charset=utf-8 ')).toBe(`{ + "items": [ null, { - "b": true, - "c": false - }, - [], - {} + "active": true + } ], "empty": {} }`) }) -test('preserves unsafe integer, decimal, exponent and negative-zero lexemes and duplicate keys', () => { +test('formatting preserves numeric lexemes and duplicate keys rather than serializing parsed data', () => { const values = [ '9007199254740993', '-9007199254740993', @@ -74,80 +25,53 @@ test('preserves unsafe integer, decimal, exponent and negative-zero lexemes and '-0', '1.00' ] - const input = `{${values.map((value) => `"duplicate":${value}`).join(',')}}` - expect(formatSource(input, 'application/problem+json')).toBe( + const content = `{${values.map((value) => `"duplicate":${value}`).join(',')}}` + + expect(formatSource(content, 'application/json')).toBe( `{\n${values.map((value) => ` "duplicate": ${value}`).join(',\n')}\n}` ) }) -test('retains escaped strings, whitespace inside strings and HTML as literal text', () => { - const input = String.raw`{"\u006b":"\u003cscript\u003e\r\n\t\/\\\"", "k":"a b,:{}[]", "html":"</script><img src=x onerror=alert(1)>"}` +test('formatting preserves escaped strings and whitespace inside values', () => { + const content = String.raw`{"\u006b":"\u003cscript\u003e\r\n\t\/\\\"", "k":"a b,:{}[]"}` const expected = String.raw`{ "\u006b": "\u003cscript\u003e\r\n\t\/\\\"", - "k": "a b,:{}[]", - "html": "</script><img src=x onerror=alert(1)>" + "k": "a b,:{}[]" }` - expect(formatSource(input, 'application/json')).toBe(expected) + + expect(formatSource(content, 'application/json')).toBe(expected) }) -test.each(['null', 'true', 'false', '42', '-0', '1e400', '"a\\n b"', '[]', '{}'])( - 'formats the top-level JSON value %s without changing its lexeme', - (value) => { - expect(formatSource(` \r\n${value}\t `, 'text/json')).toBe(value) +test('invalid JSON remains exactly as recorded instead of being repaired or discarded', () => { + const content = ' \r\n {"items":[1,]}\t ' + + expect(formatSource(content, 'application/json')).toBe(content) +}) + +test.each(['text/plain', 'source.json'])( + 'JSON-looking content with MIME %s remains unformatted', + (mime) => { + const content = ' \r\n { "items": [1,2] }\t ' + + expect(formatSource(content, mime)).toBe(content) } ) -test.each([ - '', - ' \r\n\t', - '{', - '{"a":1,}', - '[1,]', - '{"a":undefined}', - '{"a":NaN}', - '{"a":01}', - '{"a":"\\x01"}', - '{"a":"unescaped\nline"}', - '{"a":1} trailing', - 'null null', - '/* comment */ {}', - '{"a":1}\u00a0' -])('returns malformed JSON exactly as supplied: %j', (input) => { - expect(formatSource(input, 'application/json')).toBe(input) +test('oversized source remains complete without formatting', () => { + const content = '{"value":1}'.padEnd(SOURCE_SIZE_LIMIT + 1, ' ') + '\r\n' + + expect(formatSource(content, 'application/json')).toBe(content) }) -test.each([ - 'text/html', - 'application/xhtml+xml', - 'text/css', - 'text/javascript', - 'application/xml', - 'text/markdown', - 'application/yaml', - 'text/plain' -])('never formats non-JSON %s, even if its contents look like JSON', (mime) => { - const input = ' \r\n { "a": [1,2] }\r\n\t' - expect(formatSource(input, mime)).toBe(input) +test('pathological nesting falls back to the complete original source', () => { + const content = '['.repeat(1000) + 'null' + ']'.repeat(1000) + + expect(formatSource(content, 'application/json')).toBe(content) }) -test('formats nested JSON iteratively, with a finite depth fallback', () => { - const nested = '['.repeat(30) + 'null' + ']'.repeat(30) - const formatted = formatSource(nested, 'application/json') - expect(formatted).not.toBe(nested) - expect(formatted.replace(/\s/g, '')).toBe(nested) - const deep = '['.repeat(101) + 'null' + ']'.repeat(101) - expect(formatSource(deep, 'application/json')).toBe(deep) -}) +test('formatting that would exceed the output budget leaves the original intact', () => { + const content = '[' + Array(30_000).fill('0').join(',') + ']' -test('formats at the input budget, but keeps every original character above it', () => { - const atLimit = '{}'.padEnd(SOURCE_SIZE_LIMIT, ' ') - expect(formatSource(atLimit, 'application/json')).toBe('{}') - const oversized = atLimit + '\r\n' - expect(formatSource(oversized, 'application/json')).toBe(oversized) -}) - -test('returns the entire original when pretty output would exceed the budget', () => { - const input = '[' + Array(20_000).fill('0').join(',') + ']' - expect(input.length).toBeLessThan(SOURCE_SIZE_LIMIT) - expect(formatSource(input, 'application/json')).toBe(input) + expect(content.length).toBeLessThan(SOURCE_SIZE_LIMIT) + expect(formatSource(content, 'application/json')).toBe(content) }) diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts index 823d8dec..3644e8bb 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/page.test.ts @@ -1,321 +1,145 @@ -import { beforeEach, expect, test, vi } from 'vitest' +import { expect, test, vi } from 'vitest' import { createRawSnippet, type ComponentProps } from 'svelte' import { render } from 'svelte/server' +import type { PublicWebPieceAsset } from '@luzzle/web.pieces' import { makePublicConfig } from '#lib/server/config.fixture.js' -import { - publishedPiece as rawPublishedPiece, - asset -} from '#lib/pieces/viewer/fixtures/published.js' +import { publishedPiece } from '#lib/pieces/viewer/fixtures/published.js' import Overview from './+page.svelte' import PreviewLayout from './+layout.svelte' -const publishedPiece = { - ...rawPublishedPiece, - templates: { - icon: { name: 'default', fields: { image: 'cover' } }, - page: { name: 'default', fields: { image: 'cover' } }, - opengraph: { name: 'default', fields: { image: 'cover' } } - } -} - -const { page, resolve } = vi.hoisted(() => ({ +vi.mock('$app/state', () => ({ page: { - url: new URL('https://app.test/admin/piece/books/book.md/preview/job'), + url: new URL('https://app.test/admin/piece/books/book.md/preview/current-job'), route: { id: '/(auth)/admin/piece/[...path]/preview/[jobId]' } - }, - resolve: vi.fn((route: string, params: { path: string; jobId?: string }) => + } +})) +vi.mock('$app/paths', () => ({ + resolve: (route: string, params: { path: string; jobId?: string }) => route .replace('/(auth)', '') .replace('[...path]', () => params.path) .replace('[jobId]', () => params.jobId ?? '') - ) })) -vi.mock('$app/state', () => ({ page })) -vi.mock('$app/paths', () => ({ resolve })) - -// Only the layout's five app-navigation destinations may use this mocked resolver. -// Resource/helper URLs must remain independent; this is not native Kit resolution evidence. -function expectAppNavigation(jobs = ['job']) { - const calls = jobs.flatMap((job) => { - const route = '/(auth)/admin/piece/[...path]/preview/[jobId]' - const path = 'books/book.md' - const jobId = encodeURIComponent(job) - return [ - ['/(auth)/admin/piece/[...path]/source', { path }], - [route, { path, jobId }], - [`${route}/page`, { path, jobId }], - [`${route}/icon`, { path, jobId }], - [`${route}/share`, { path, jobId }] - ] - }) - for (const call of resolve.mock.calls) expect(calls).toContainEqual(call) -} type Data = ComponentProps<typeof Overview>['data'] -type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired' -function data(status: Status = 'completed'): Data { - const common = { - pieceTypes: ['books'], - config: makePublicConfig({ - url: { - app: 'https://public.test/site', - app_assets: '/static', - luzzle_assets: 'https://cdn.test/published' - } - }), - meta: { title: 'Garden', description: 'Description' }, - preview: true as const, - file: 'books/book.md', - job: 'job', - createdAt: '1970-01-01T00:00:00.000Z', - finishedAt: - status === 'completed' || status === 'failed' || status === 'expired' - ? '1970-01-01T00:00:02.000Z' - : null, - phases: [ - { - job_id: 'job', - phase: 'transform', - status: 'completed', - started_at: 1000, - finished_at: 2000, - message: null - } - ], - logs: [ - { - job_id: 'job', - phase: 'transform', - line_number: 1, - ts: 1000, - level: 'info', - message: 'Preview log <script>escaped()</script>' - } - ] - } - if (status === 'failed') return { ...common, status, errorMessage: 'Preview failed fixture' } - if (status !== 'completed') return { ...common, status } - const outputs = [ - asset('image.original', 'generated/cover.jpg', 'image'), - asset('image.s.webp', 'generated/s.webp', 'image', 'image/webp'), + +const job = { + pieceTypes: ['books'], + config: makePublicConfig({ + url: { + app: 'https://public.test/site', + app_assets: '/static', + luzzle_assets: 'https://cdn.test/published' + } + }), + meta: { title: 'Garden', description: 'Description' }, + preview: true as const, + file: 'books/book.md', + job: 'current-job', + createdAt: '2026-11-01T12:00:00.000Z', + finishedAt: null, + phases: [ { - ...asset('markdown', 'generated/body.html', 'piece', 'text/html'), - content: '<script>privateBody()</script><h1>Body</h1>' - }, + job_id: 'current-job', + phase: 'transform', + status: 'completed', + started_at: Date.UTC(2026, 10, 1, 12), + finished_at: Date.UTC(2026, 10, 1, 12, 0, 1), + message: null + } + ], + logs: [ { - ...asset('markdown.description', null, 'piece', 'text/html'), - content: '<p>privateDescription()</p>' - }, - { - ...asset('palette', null, 'piece', 'application/json'), - content: '{"color":"#abc","count":42}' - }, - asset('custom.invalid', '../private.pdf', 'piece', 'application/pdf'), - asset('custom.missing', null, 'piece', 'application/pdf') + job_id: 'current-job', + phase: 'transform', + line_number: 1, + ts: Date.UTC(2026, 10, 1, 12), + level: 'info', + message: 'Preview seed line' + } ] - return { - ...common, - status, - outputs, - piece: { - ...publishedPiece, - title: 'Preview <Draft>', - assets: [...outputs, asset('attachment', 'archive/manual.pdf')] +} +const generatedCover: PublicWebPieceAsset = { + asset_key: 'cover', + transformation: 'image.original', + asset_path: 'generated/cover.jpg', + mime_type: 'image/jpeg' +} +const archiveAttachment: PublicWebPieceAsset = { + asset_key: 'manual', + transformation: 'attachment', + asset_path: 'archive/manual.pdf', + mime_type: 'application/pdf' +} +const completedPreview = { + ...job, + status: 'completed', + finishedAt: '2026-11-01T12:00:02.000Z', + outputs: [generatedCover], + piece: { + ...publishedPiece, + title: 'Preview <Draft>', + templates: { + icon: { name: 'default', fields: { image: 'cover' } }, + page: { name: 'default', fields: { image: 'cover' } }, + opengraph: { name: 'default', fields: { image: 'cover' } } }, - tags: [] - } -} + assets: [generatedCover, archiveAttachment] + }, + tags: [] +} satisfies Data -function tree(props: Data) { +function renderPreview(data: Data) { + const params = { path: data.file, jobId: data.job } const children = createRawSnippet(() => ({ - render: () => - render(Overview, { props: { data: props, params: { path: props.file, jobId: props.job } } }) - .body + render: () => render(Overview, { props: { data, params } }).body })) - return render(PreviewLayout, { - props: { data: props, params: { path: props.file, jobId: props.job }, children } - }).body.replace(/<!--[^]*?-->/g, '') + return render(PreviewLayout, { props: { data, params, children } }).body } -beforeEach(() => { - page.url = new URL('https://app.test/admin/piece/books/book.md/preview/job') - page.route.id = '/(auth)/admin/piece/[...path]/preview/[jobId]' - vi.clearAllMocks() -}) - test.each(['running', 'completed'] as const)( - '%s Preview renders duplicated seed logs once and retains new lines', + '%s Preview deduplicates replayed seed logs while retaining distinct lines', (status) => { - const props = data(status) - const first = { ...props.logs[0], message: 'Duplicated seed line' } - props.logs = [first, { ...first }, { ...first, line_number: 2, message: 'New seed line' }] - const html = tree(props) - expect(html.match(/Duplicated seed line/g)).toHaveLength(1) - expect(html.match(/New seed line/g)).toHaveLength(1) - expect(html).toContain('2 lines') + const replayed = { ...job.logs[0], message: 'Replayed seed line' } + const logs = [replayed, { ...replayed }, { ...replayed, line_number: 2, message: 'Later line' }] + let data: Data = { ...job, status: 'running', logs } + if (status === 'completed') data = { ...completedPreview, logs } + + const html = renderPreview(data) + + expect(html.match(/Replayed seed line/g)).toHaveLength(1) + expect(html.match(/Later line/g)).toHaveLength(1) } ) -test('completed Preview reads title, consolidated facts, Preview logs, then assets with a single main/title', () => { - const html = tree(data()) - expect(html.match(/<main\b/g)).toHaveLength(1) - expect(html.match(/<h1\b/g)).toHaveLength(1) +test('completed Preview inspects only generated outputs in the current job namespace', () => { + const html = renderPreview(completedPreview) + expect(html).toContain('Preview <Draft>') - expect(html).toContain('Preview assets</h2>') - expect(html).toContain('Preview logs</h2>') - expect(html).toContain('Preview log <script>escaped()</script>') - expect(html.indexOf('Preview <Draft>')).toBeLessThan(html.indexOf('Preview</caption>')) - expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>')) - expect(html.indexOf('Preview logs</h2>')).toBeLessThan(html.indexOf('Preview assets</h2>')) - const facts = html.slice(html.indexOf('<caption'), html.indexOf('</table>')) - const logSection = html.slice( - html.indexOf('Preview logs</h2>'), - html.indexOf('Preview assets</h2>') - ) - expect(facts).toContain('books/book.md</bdi>') - expect(facts).toContain('data-status="completed"') - expect(facts).toContain('>Completed</span>') - expect(facts).toContain('1s total time') - expect(facts).not.toContain('<svg') - expect(logSection).not.toContain('books/book.md') - expect(logSection).not.toContain('Preview completed') - expect(logSection).not.toContain('total time') - expect(html).not.toContain('status-card') - expect(html.indexOf('Preview logs</h2>')).toBeLessThan(html.indexOf('Preview log <script>')) - expect(html).toContain('datetime="1970-01-01T00:00:00.000Z"') - expect(html).toContain('datetime="1970-01-01T00:00:02.000Z"') - for (const caption of ['Preview', 'Image', 'Markdown', 'Palette', 'Custom']) - expect(html).toContain(`${caption}</caption>`) - expect(html.match(/Image<\/caption>/g)).toHaveLength(1) - expect(html.match(/Markdown<\/caption>/g)).toHaveLength(1) - for (const output of ['image.original', 'image.s.webp', 'markdown.description']) - expect(html).toContain(`${output}</bdi>`) - for (const path of ['generated/cover.jpg', 'generated/s.webp', 'generated/body.html']) - expect(html).toContain(`href="/admin/preview/job/asset/${path}"`) - expect(html).not.toMatch(/ download(?:[= >])/) - expect(html).toContain('aria-label="Show Markdown: Asset 2, Content"') - expect(html).toContain('aria-label="Show Markdown.description: Asset 2, Content"') - expect(html).toContain('aria-label="Show Palette: Asset 2, Content"') - expect(html).toContain('aria-expanded="false"') - expect(html).not.toContain('{"color":"#abc","count":42}') - expect(html).not.toContain('color</bdi>') - expect(html).not.toContain('count</bdi>') - expect(html).toContain('Unavailable</small>') - expect(html).toContain('Not recorded') - for (const absent of [ - 'privateBody()', - 'privateDescription()', - '<script>', - '<iframe', - 'archive/manual.pdf', - 'Attachment</caption>', - 'Last publish', - 'Publication</caption>', - 'Matches publish', - 'https://public.test', - 'https://cdn.test', - '/pieces/assets/', - '/admin/asset/', - 'background-color: #abc' - ]) - expect(html).not.toContain(absent) - expectAppNavigation() -}) - -test.each(['waiting', 'running', 'failed', 'expired'] as const)( - '%s Preview puts current facts above logs without inventing assets or publication facts', - (status) => { - const props = data(status) - const html = tree(props) - expect(html.match(/<main\b/g)).toHaveLength(1) - expect(html).toContain('Piece overview') - expect(html).toContain('Preview</caption>') - expect(html).toContain('Preview logs</h2>') - const label = { - waiting: 'Queued', - running: 'Building', - failed: 'Failed', - expired: 'Expired' - }[status] - expect(html).toContain(`>${label}</span>`) - expect(html).toContain('datetime="1970-01-01T00:00:00.000Z"') - if (status === 'waiting' || status === 'running') - expect(html).not.toContain('datetime="1970-01-01T00:00:02.000Z"') - expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>')) - expect(html).not.toContain('Preview assets') - expect(html).not.toContain('Preview outputs') - expect(html).not.toContain('Publication</caption>') - expect(html).not.toContain('Preview <Draft>') - expect(html).not.toContain('/asset/generated/') - expectAppNavigation() - } -) - -test('completed workflow without an assembled result keeps facts/logs and omits assets rather than crashing', () => { - const running = data('running') - if (running.status !== 'running') throw new Error('Expected running fixture') - const props = { ...running, status: 'completed' as const } - const html = tree(props) - expect(html.match(/<main\b/g)).toHaveLength(1) - expect(html.match(/<h1\b/g)).toHaveLength(1) - expect(html).toContain('Preview</h1>') - expect(html).toContain('Preview</caption>') - expect(html).toContain('>Completed</span>') - expect(html).toContain('Preview logs</h2>') - expect(html).toContain('Preview log <script>escaped()</script>') - expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>')) - expect(html).not.toContain('Preview assets') - expect(html).not.toContain('None recorded') - expect(html).not.toContain('/asset/generated/') - expectAppNavigation() -}) - -test('recorded generated Share PNG remains a View link in Overview', () => { - const props = data() - if (props.status !== 'completed' || !props.outputs) throw new Error('Expected assembled fixture') - props.outputs = [ - ...props.outputs, - asset('opengraph', 'generated/share.png', 'piece', 'image/png') - ] - const html = tree(props) - const png = html - .match(/<a\b[^>]*>/g) - ?.find((tag) => tag.includes('href="/admin/preview/job/asset/generated/share.png"')) - expect(png).toBeDefined() - expect(png).toContain('aria-label="View opengraph: generated/share.png"') - expect(png).toContain('target="_blank"') - expect(png).toContain('rel="external noopener noreferrer"') - expect(png).not.toMatch(/\bdownload(?:=|\s|>)/) - expect(html.match(/<main\b/g)).toHaveLength(1) - expectAppNavigation() -}) - -test('empty generated outputs do not turn SDK passthroughs into downloadable preview artifacts', () => { - const props = data() - if (props.status !== 'completed') throw new Error('Expected completed fixture') - props.outputs = [] - const html = tree(props) - expect(html).toContain('Preview assets</h2>') - expect(html).toContain('None recorded') - expect(html).not.toContain('Image</caption>') + expect(html).toContain('Preview logs') + expect(html).toContain('Preview seed line') + expect(html).toContain('href="/admin/preview/current-job/asset/generated/cover.jpg"') expect(html).not.toContain('archive/manual.pdf') - expect(html).not.toContain('/asset/generated/') - expectAppNavigation() + expect(html).not.toContain('/pieces/assets/') + expect(html.indexOf('Preview logs')).toBeLessThan(html.indexOf('Preview assets')) + expect(html.match(/<main\b/g)).toHaveLength(1) }) -test('replacement completed snapshots use only the new job namespace and metadata with mocked app navigation', () => { - tree(data()) - const props = data() - if (props.status !== 'completed' || !props.piece) throw new Error('Expected assembled fixture') - props.job = 'new-job' - props.piece = { ...props.piece, title: 'New result' } - props.outputs = [asset('custom', 'new.txt', 'new', 'text/plain')] - page.url = new URL('https://app.test/admin/piece/books/book.md/preview/new-job') - page.route.id = '/(auth)/admin/piece/[...path]/preview/[jobId]' - const html = tree(props) - expect(html).toContain('New result') - expect(html).toContain('href="/admin/preview/new-job/asset/new.txt"') - expect(html).not.toContain('/preview/job/asset/') +test('empty generated outputs do not fall back to SDK passthrough assets', () => { + const html = renderPreview({ ...completedPreview, outputs: [] }) + + expect(html).toContain('Preview assets') + expect(html).toContain('None recorded') expect(html).not.toContain('generated/cover.jpg') - expectAppNavigation(['job', 'new-job']) + expect(html).not.toContain('archive/manual.pdf') +}) + +test('a completed job without an assembled result keeps its facts and logs rather than crashing', () => { + const html = renderPreview({ ...job, status: 'completed' }) + + expect(html).toContain('Preview seed line') + expect(html).toContain('Preview logs') + expect(html).toContain('Completed') + expect(html).not.toContain('Preview assets') + expect(html.match(/<main\b/g)).toHaveLength(1) }) diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts index 134c16e1..51cf2716 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/published/page.test.ts @@ -1,45 +1,25 @@ -import { readFileSync } from 'node:fs' import { beforeEach, expect, test, vi } from 'vitest' import { render } from 'svelte/server' import type { ComponentProps } from 'svelte' import { MarkdownPublicationStatus } from '#lib/pieces/types.js' -import { editorHref, publishedHref } from '#lib/pieces/navigation.js' import { makePublicConfig } from '#lib/server/config.fixture.js' -import { - publishedPiece as rawPublishedPiece, - asset -} from '#lib/pieces/viewer/fixtures/published.js' +import { publishedPiece as originalPiece, asset } from '#lib/pieces/viewer/fixtures/published.js' import Overview from './+page.svelte' import PublishedTree from './fixtures/PublishedTree.svelte' -const publishedPiece = { - ...rawPublishedPiece, - templates: { - icon: { name: 'default', fields: { image: 'cover' } }, - page: { name: 'default', fields: { image: 'cover' } }, - opengraph: { name: 'default', fields: { image: 'cover' } } - } -} - -const { page, resolve } = vi.hoisted(() => ({ +const { page } = vi.hoisted(() => ({ page: { - url: new URL('https://app.test/'), route: { id: '/(auth)/admin/piece/[...path]/published' }, - params: {}, data: { config: { url: { app: '/garden', luzzle_assets: '/cdn' } } } - }, - resolve: vi.fn((route: string, params?: { path?: string; tag?: string }) => { - if (route === '/tags/[tag]' && params?.tag !== undefined) return `/tags/${params.tag}` - if (route.startsWith('/(auth)/admin/piece/[...path]/') && params?.path !== undefined) - return route.replace('/(auth)', '').replace('[...path]', params.path) - if (params || route.includes('[')) throw new Error(`Unexpected route: ${route}`) - return route.startsWith('/') ? route : `/${route}` - }) + } })) vi.mock('$app/state', () => ({ page })) -vi.mock('$app/paths', () => ({ resolve })) -// These synchronous renderer stubs establish route props, URLs and landmarks; -// they do not exercise native Kit resolution, the wrappers' static catalogs or client prop updates. +vi.mock('$app/paths', () => ({ + resolve: (route: string, params?: { path?: string; tag?: string }) => { + if (route === '/tags/[tag]') return `/tags/${params?.tag}` + return route.replace('/(auth)', '').replace('[...path]', () => params?.path ?? '') + } +})) vi.mock('#lib/pieces/components/page.svelte', async () => ({ default: (await import('#lib/pieces/viewer/fixtures/PageRenderer.svelte')).default })) @@ -49,550 +29,119 @@ vi.mock('#lib/pieces/components/icon.svelte', async () => ({ vi.mock('#lib/pieces/components/opengraph.svelte', async () => ({ default: (await import('#lib/pieces/viewer/fixtures/Composition.svelte')).default })) -function data( - status: MarkdownPublicationStatus = MarkdownPublicationStatus.MATCHING, - time: number | null = 1234 -): ComponentProps<typeof Overview>['data'] { - const config = makePublicConfig({ - url: { app: '/garden', app_assets: '/static', luzzle_assets: '/cdn' } - }) - config.content.text = { title: 'Garden', description: 'Description' } + +function publishedData(): ComponentProps<typeof Overview>['data'] { return { pieceTypes: ['books'], - file: 'books/<draft #?%>.books.md', - piece: publishedPiece, + file: 'books/draft #?.books.md', + piece: { + ...originalPiece, + templates: { + icon: { name: 'default', fields: { image: 'cover' } }, + page: { name: 'default', fields: { image: 'cover' } }, + opengraph: { name: 'default', fields: { image: 'cover' } } + }, + assets: [ + ...originalPiece.assets, + asset('opengraph', 'generated/share.png', 'piece', 'image/png') + ] + }, tags: [{ slug: 'fiction', tag: 'Fiction' }], - publication: { lastPublishedAt: time, markdownStatus: status }, - publishedUrl: 'https://public.test/garden/pieces/books/published%20%23%3F%25', - config, + publication: { + lastPublishedAt: Date.UTC(2026, 10, 1, 12), + markdownStatus: MarkdownPublicationStatus.MATCHING + }, + publishedUrl: 'https://public.test/pieces/books/published-book', + config: makePublicConfig({ + url: { app: '/garden', app_assets: '/static', luzzle_assets: '/cdn' } + }), meta: { title: 'Garden', description: 'Description' } } } + beforeEach(() => { - vi.clearAllMocks() - page.url = new URL(publishedHref(data().file), 'https://app.test') page.route.id = '/(auth)/admin/piece/[...path]/published' }) -test('Overview shows escaped published identity, actual completion time and external public link', () => { - const html = render(Overview, { props: { data: data(), params: { path: data().file } } }).body +test('overview shows the published snapshot, recorded completion time and public destinations', () => { + const data = publishedData() + + const html = render(Overview, { props: { data, params: { path: data.file } } }).body + expect(html).toContain('Published <Book>') - expect(html).not.toContain('books/<draft #?%>.books.md') - expect(html).toContain('Type') - expect(html).toContain('books') - expect(html).toContain('<caption') - expect(html).toContain('Publication</caption>') - expect(html).toMatch(/<th[^>]*scope="row"[^>]*>Last publish<\/th>/) - expect(html).not.toMatch(/<th[^>]*scope="row"[^>]*>Source<\/th>/) - expect(html).not.toContain('Excludes') - expect(html).not.toContain('Unsaved edits ยท attachment bytes') - expect(html).not.toContain('Status covers indexed Markdown') - expect(html).toContain('datetime="1970-01-01T00:00:01.234Z"') - expect(html).toContain('UTC') - expect(html).toContain( - 'href="https://public.test/garden/pieces/books/published%20%23%3F%25" target="_blank" rel="noopener noreferrer"' - ) - expect(html).toContain(`<bdi dir="ltr">${data().publishedUrl}</bdi>`) - expect(html).not.toContain(`href="${editorHref(data().file)}"`) - expect(html).not.toContain('>Open<') - expect(html).not.toContain('<details') - expect(html).toContain('href="/cdn/pieces/assets/published/cover.jpg"') - expect(html.match(/<main /g)).toHaveLength(1) - expect(html).not.toContain('<draft') - expect(html).not.toContain('Serving metadata only') - expect(html).not.toContain('/admin/asset') -}) - -test('only the public URL remains a linked field value; long source paths are absent', () => { - const props = data() - props.piece = { ...publishedPiece, assets: [] } - props.file = `${'nested folder/'.repeat(24)}้›ช <long & "draft">.books.md` - props.publishedUrl = `https://public.test/${'long-prefix/'.repeat(24)}published` - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - const links = html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? [] - expect(links).toHaveLength(1) - for (const link of links) { - expect(link).toMatch(/class="truncated[^"]*"/) - expect(link).toMatch(/<bdi[^>]*dir="ltr"[^>]*>/) - expect(link).not.toContain('aria-label=') - } - expect(html).not.toContain(editorHref(props.file)) - expect(html).not.toContain('nested folder/') - expect(links[0]).toContain(`href="${props.publishedUrl}"`) - expect(links[0]).toContain(`>${props.publishedUrl}</bdi>`) - expect(links[0]).toContain('target="_blank" rel="noopener noreferrer"') - for (const excluded of ['<details', '<summary', '>Open<', '<long']) - expect(html).not.toContain(excluded) -}) - -test('publication rows retain explicit table semantics when styled as narrow-screen fields', () => { - const props = data() - props.piece = { ...publishedPiece, assets: [] } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - const publication = html.slice(0, html.indexOf('</table>') + '</table>'.length) - expect(publication).toMatch(/<table[^>]*role="table"/) - expect(publication).toMatch(/<tbody[^>]*role="rowgroup"/) - expect(publication.match(/<tr[^>]*role="row"/g)).toHaveLength(4) - expect(publication.match(/<th[^>]*scope="row"[^>]*role="rowheader"/g)).toHaveLength(4) - expect(publication.match(/<td[^>]*role="cell"/g)).toHaveLength(4) -}) - -test('publication and asset tables use the same caption, cell padding and metadata size', () => { - const sources = [ - readFileSync( - new URL('../../../../../../lib/pieces/viewer/PieceOverview.svelte', import.meta.url), - 'utf8' - ), - readFileSync( - new URL('../../../../../../lib/pieces/viewer/AssetOutputs.svelte', import.meta.url), - 'utf8' - ) - ] - for (const source of sources) { - const styles = source.slice(source.indexOf('<style>')) - expect(styles.match(/\bcaption\)?\s*\{([^}]+)\}/)?.[1]).toContain( - 'padding: var(--space-2) var(--space-3)' - ) - expect( - styles.match(/\bth\)?,\s*(?:\.overview :global\(\.overview-facts )?td\)?\s*\{([^}]+)\}/)?.[1] - ).toContain('padding: var(--space-2) var(--space-3)') - const tableRules = [...styles.matchAll(/\btable\)?\s*\{([^}]+)\}/g)] - expect(tableRules.some((rule) => rule[1].includes('font-size: var(--font-size-xs)'))).toBe(true) - expect(styles.slice(styles.indexOf('@media'))).toContain( - 'padding: var(--space-2) var(--space-3)' - ) - } -}) - -test('epoch-zero completion is a recorded time, not an Unknown state', () => { - const props = data(MarkdownPublicationStatus.MATCHING, 0) - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - expect(html).toContain('datetime="1970-01-01T00:00:00.000Z"') + expect(html).toContain('datetime="2026-11-01T12:00:00.000Z"') expect(html).toContain('data-status="matching"') + expect(html).toContain(`href="${data.publishedUrl}"`) + expect(html).toContain('href="/cdn/pieces/assets/generated/share.png"') + expect(html).not.toContain('/admin/preview/') + expect(html.match(/<main\b/g)).toHaveLength(1) +}) + +test('uses the app base when no asset CDN is configured', () => { + const data = publishedData() + data.config.url.luzzle_assets = '' + + const html = render(Overview, { + props: { data, params: { path: data.file } } + }).body + + expect(html).toContain('href="/garden/pieces/assets/generated/share.png"') +}) + +test('unpublished changes do not hide the previous publication or claim it matches the source', () => { + const data = publishedData() + data.publication.markdownStatus = MarkdownPublicationStatus.CHANGED + + const html = render(Overview, { props: { data, params: { path: data.file } } }).body + + expect(html).toContain('data-status="changed"') + expect(html).toContain('Unpublished changes') + expect(html).not.toContain('Matches publish') + expect(html).toContain('datetime="2026-11-01T12:00:00.000Z"') + expect(html).toContain(`href="${data.publishedUrl}"`) +}) + +test('missing publication evidence remains unknown rather than using source dates', () => { + const data = publishedData() + data.publication = { lastPublishedAt: null, markdownStatus: MarkdownPublicationStatus.UNKNOWN } + + const html = render(Overview, { props: { data, params: { path: data.file } } }).body + + expect(html).toContain('data-status="unknown"') + expect(html).toContain('Unknown') + expect(html).not.toMatch(/<time\b/) +}) + +test('the reset layout retains configured branding and noindex without adding public SEO', () => { + const data = publishedData() + data.config.content.images.favicon = '/assets/favicon/custom.svg' + + const result = render(PublishedTree, { props: { data } }) + + expect(result.head).toContain('rel="icon" href="/assets/favicon/custom.svg"') + expect(result.head).toContain('Published: Published <Book>') + expect(result.head).toContain('name="robots" content="noindex"') + expect(result.head).not.toContain('property="og:') + expect(result.head).not.toContain('name="description"') + expect(result.body).toContain('href="/admin/piece/books/draft%20%23%3F.books.md/source"') }) test.each([ - [MarkdownPublicationStatus.MATCHING, 'Matches publish'], - [MarkdownPublicationStatus.CHANGED, 'Unpublished changes'], - [MarkdownPublicationStatus.UNKNOWN, 'Unknown'] -] as const)( - 'status %s and Unknown timestamp are truthful and independent of source dates', - (status, copy) => { - const html = render(Overview, { - props: { data: data(status, null), params: { path: data().file } } - }).body - expect(html).toMatch(new RegExp(`data-status="${status}"[^>]*>${copy}</span>`)) - expect(html).toMatch(/<th[^>]*scope="row"[^>]*>State<\/th>/) - expect(html.replace(/<!--[^]*?-->/g, '').replace(/\s+/g, ' ')).toMatch( - /Last publish<\/th>\s*<td[^>]*>\s*<span[^>]*>Unknown<\/span>/ - ) - expect(html).not.toContain('Unsaved edits ยท attachment bytes') - expect(html).not.toContain('Indexed Markdown') - expect(html).not.toContain('<time') - } -) + { view: 'page', marker: 'data-renderer="page"' }, + { view: 'icon', marker: 'data-renderer="icon"' }, + { view: 'share', marker: 'data-renderer="composition"' } +] as const)('the $view route delegates the published piece to its renderer', ({ view, marker }) => { + const data = publishedData() + page.route.id = `/(auth)/admin/piece/[...path]/published/${view}` -test.each(['https://cdn.test/prefix/', '/cdn/prefix/', ''])( - 'Overview selects public CDN/app fallback for base %s', - (base) => { - const props = data() - props.config.url.luzzle_assets = base - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - expect(html).toContain( - `href="${(base || '/garden').replace(/\/+$/, '')}/pieces/assets/published/cover.jpg"` - ) - } -) + const html = render(PublishedTree, { props: { data, view } }).body -test('Overview, not Share, links only the actual recorded generated output', () => { - const props = data() - props.piece = { - ...publishedPiece, - assets: [asset('opengraph', 'piece/generated.png', 'piece', 'image/png')] + expect(html).toContain(marker) + expect(html).toContain('Published <Book>') + expect(html.match(/<main\b/g)).toHaveLength(1) + if (view === 'page') { + expect(html).toContain('href="/tags/fiction"') + expect(html).toContain('Fiction') } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - const generatedLink = html.match( - /<a[^>]*href="\/cdn\/pieces\/assets\/piece\/generated.png"[^>]*>/ - )?.[0] - expect(generatedLink).toContain('target="_blank"') - expect(generatedLink).toContain('rel="external noopener noreferrer"') - expect(html).toMatch(/<a[^>]*href="\/cdn\/pieces\/assets\/piece\/generated.png"[^>]*>View<\/a>/) - expect(html).not.toContain('>Open<') - expect(html).not.toContain('<details') - expect(html).not.toContain('<img') - expect(html).not.toContain('Rendered share composition') + if (view === 'share') expect(html).not.toContain('generated/share.png') }) - -test('Overview keeps JSON and HTML collapsed behind Show without exposing or executing either', () => { - const props = data() - props.piece = { - ...publishedPiece, - assets: [ - { - ...asset('palette', null, 'piece', 'application/json'), - content: '{"background":"#123456","accent":"#abcdef"}' - }, - { - ...asset('markdown', null, 'piece', 'text/html'), - content: '<script>alert(1)</script><h1>Serving body</h1>' - }, - asset('custom.output', 'published/output.pdf', 'piece', 'application/pdf') - ] - } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - expect(html).toContain('Palette</caption>') - expect(html).toContain('aria-label="Show Palette: Asset 1, Content"') - expect(html).not.toContain('{"background":"#123456","accent":"#abcdef"}') - expect(html).not.toContain('background</bdi>') - expect(html).not.toContain('accent</bdi>') - expect(html).not.toContain('style=') - expect(html).toContain('Markdown</caption>') - expect(html).toMatch(/<button\b[^>]*>[\s\S]*?Show[\s\S]*?<\/button>/) - expect(html).toContain('aria-expanded="false"') - expect(html).not.toContain('alert(1)') - expect(html).not.toContain('Serving body') - expect(html).not.toContain('<script>') - expect(html).not.toContain('<h1>Serving body') - expect(html).toContain('href="/cdn/pieces/assets/published/output.pdf"') - expect(html).not.toContain('Other published files') - expect(html.match(/<main /g)).toHaveLength(1) -}) - -test('Overview retains every recorded Opengraph output for inspection, including invalid and non-PNG rows', () => { - const props = data() - props.piece = { - ...publishedPiece, - assets: [ - asset('opengraph', 'piece/image.png', 'piece', 'image/png'), - asset('opengraph', 'piece/image.jpg', 'piece', 'image/jpeg'), - asset('opengraph', '../unsafe.png', 'piece', 'image/png') - ] - } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - expect(html).toContain('href="/cdn/pieces/assets/piece/image.png"') - expect(html).toContain('href="/cdn/pieces/assets/piece/image.jpg"') - expect(html).toContain('../unsafe.png</bdi>') - expect(html).not.toContain('href="/cdn/pieces/assets/..') - expect(html).toContain('Unavailable</small>') -}) - -test('Overview hides internal asset identifiers and preserves related outputs with readable groups', () => { - const props = data() - const first = `internal-asset-${'a'.repeat(64)}` - const second = `internal-asset-${'b'.repeat(64)}` - const piece = `internal-piece-${'c'.repeat(64)}` - props.piece = { - ...publishedPiece, - assets: [ - asset('image.original', 'one/cover.jpg', first), - asset('image.s.webp', 'one/s.webp', first, 'image/webp'), - asset('attachment', 'one/doc.pdf', first, 'application/pdf'), - { ...asset('highlight', null, first, 'text/html'), content: '<code>one</code>' }, - asset('image.original', 'two/cover.jpg', second), - asset('image.s.webp', 'two/s.webp', second, 'image/webp'), - asset('attachment', 'two/doc.pdf', second, 'application/pdf'), - { ...asset('highlight', null, second, 'text/html'), content: '<code>two</code>' }, - { ...asset('palette', null, piece, 'application/json'), content: '{"background":"#123456"}' } - ] - } - const html = render(Overview, { - props: { data: props, params: { path: props.file } } - }).body.replace(/<!--[^]*?-->/g, '') - for (const key of [first, second, piece]) expect(html).not.toContain(key) - for (const excluded of ['>Key<', 'Technical details', '<details']) - expect(html).not.toContain(excluded) - expect(html).toContain('Asset 1') - expect(html).toContain('Asset 2') - for (const path of [ - 'one/cover.jpg', - 'one/s.webp', - 'one/doc.pdf', - 'two/cover.jpg', - 'two/s.webp', - 'two/doc.pdf' - ]) { - expect(html).toContain(`href="/cdn/pieces/assets/${path}"`) - } - expect(html).toContain('aria-label="Show Highlight: Asset 1, Content"') - expect(html).toContain('aria-label="Show Highlight: Asset 2, Content"') - expect(html).not.toContain('<code>one</code>') - expect(html).not.toContain('<code>two</code>') - expect(html).toContain('aria-label="Show Palette: Asset 3, Content"') - expect(html).not.toContain('{"background":"#123456"}') - expect(html).not.toContain('style=') - expect(html.match(/Image<\/caption>/g)).toHaveLength(1) - expect(html).toContain('image.original</bdi>') - expect(html).toContain('image.s.webp</bdi>') - expect(html).not.toContain('>Variant<') - expect(html.match(/<main /g)).toHaveLength(1) -}) - -test('every transform uses View or inline Show with no color or image roles', () => { - const props = data() - props.piece = { - ...publishedPiece, - assets: [ - asset('pictures.original', 'published/cover.jpg', 'cover'), - asset('pictures.s.webp', 'published/s.webp', 'cover', 'image/webp'), - { - ...asset('rendered-note', null, 'piece', 'text/html'), - content: '<script>bad()</script><h1>Serving HTML</h1>' - }, - { - ...asset('colors.v2', null, 'piece', 'application/json'), - content: '{"background":"#123456","swatches":["#fff","#abc"]}' - }, - { - ...asset('custom.output', 'published/body.html', 'piece', 'text/html'), - content: '<b>File and inline</b>' - } - ] - } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - for (const caption of ['Pictures', 'Rendered-note', 'Colors', 'Custom']) - expect(html).toContain(`${caption}</caption>`) - for (const path of ['published/cover.jpg', 'published/s.webp', 'published/body.html']) - expect(html).toContain(`href="/cdn/pieces/assets/${path}"`) - expect(html).toContain('aria-label="Show Rendered-note: Asset 2, Content"') - expect(html).toContain('aria-label="Show Colors.v2: Asset 2, Content"') - expect(html).not.toContain('{"background":"#123456","swatches":["#fff","#abc"]}') - expect(html).toContain('aria-label="Show Custom.output: Asset 2, Content"') - expect(html).not.toContain('bad()') - expect(html).not.toContain('File and inline') - expect(html).not.toContain('background</bdi>') - expect(html).not.toContain('swatches</bdi>') - expect(html).not.toContain('style=') - expect(html.match(/<button /g)).toHaveLength(3) - expect(html).not.toMatch(/ download(?:[= >])/) - expect(html).not.toContain('<script>') - expect(html).not.toContain('<h1>Serving HTML') - expect(html).not.toContain('Colors (palette)</caption>') - expect(html.match(/<main /g)).toHaveLength(1) -}) - -test('inline Show controls keep complete long JSON and text out of the collapsed page', () => { - const props = data() - const big = ' ้›ช๐Ÿ˜€\n'.repeat(300) - props.piece = { - ...publishedPiece, - assets: [ - { ...asset('future-text-job', null, 'piece', 'text/plain'), content: big }, - { - ...asset('markdown.description', null, 'piece', 'application/json'), - content: JSON.stringify({ count: 42, label: 'short', nested: { long: big } }) - }, - asset('future-file-job', 'published/download.zip', 'piece', 'application/zip') - ] - } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - expect(html).toContain('aria-label="Show Future-text-job: Asset 1, Content"') - expect(html).toContain('aria-label="Show Markdown.description: Asset 1, Content"') - expect(html).not.toContain('42</code>') - expect(html).not.toContain('short</code>') - expect(html).not.toContain('Show Markdown.description: Asset 1, count') - expect(html).not.toContain('Show Markdown.description: Asset 1, label') - expect(html).not.toContain(big) - expect(html).toContain('href="/cdn/pieces/assets/published/download.zip"') - expect(html).toContain('aria-label="View future-file-job: published/download.zip"') - expect(html).not.toMatch(/ download(?:[= >])/) - expect(html).not.toContain('<textarea') - expect(html).not.toContain('<script>') -}) - -test('original image and palette names receive the same ordinary presentation as renamed jobs', () => { - const props = data() - props.piece = { - ...publishedPiece, - assets: [ - asset('image.original', 'published/a.jpg', 'piece'), - asset('image.s.webp', 'published/a.webp', 'piece', 'image/webp'), - { - ...asset('palette', null, 'piece', 'application/json'), - content: '{"color":"#abc","swatches":["#abc","#fff"]}' - }, - { - ...asset('colors.v2', null, 'piece', 'application/json'), - content: '{"color":"#abc","swatches":["#abc","#fff"]}' - } - ] - } - const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body - for (const caption of ['Image', 'Palette', 'Colors']) - expect(html).toContain(`${caption}</caption>`) - for (const excluded of [ - '>Images</caption>', - '>Variant<', - 'Colors (palette)', - 'background-color', - 'class="swatch', - 'None recorded' - ]) - expect(html).not.toContain(excluded) - expect(html).toContain('aria-label="Show Palette: Asset 1, Content"') - expect(html).toContain('aria-label="Show Colors.v2: Asset 1, Content"') - expect(html.match(/<button /g)).toHaveLength(2) - expect(html).not.toContain('{"color":"#abc","swatches":["#abc","#fff"]}') - expect(html).not.toContain('color</bdi>') - expect(html).not.toContain('swatches</bdi>') - for (const path of ['published/a.jpg', 'published/a.webp']) - expect(html).toContain(`href="/cdn/pieces/assets/${path}"`) - expect(html).not.toMatch(/ download(?:[= >])/) -}) - -test('Overview renders one table per family with every image and Markdown output retained in an Output column', () => { - const props = data() - props.piece = { - ...publishedPiece, - assets: [ - asset('image.original', 'published/original.jpg', 'image'), - asset('image.s.webp', 'published/s.webp', 'image', 'image/webp'), - asset('image.m.avif', 'published/m.avif', 'image', 'image/avif'), - { - ...asset('markdown', 'published/body.html', 'piece', 'text/html'), - content: '<h1>Body</h1>' - }, - { - ...asset('markdown.description', null, 'piece', 'text/html'), - content: '<p>Description</p>' - }, - { - ...asset('markdown.metadata', null, 'piece', 'application/json'), - content: '{"title":"Recorded title","count":42}' - } - ] - } - const html = render(Overview, { - props: { data: props, params: { path: props.file } } - }).body.replace(/<!--[^]*?-->/g, '') - expect([...html.matchAll(/<caption[^>]*>([^<]*)<\/caption>/g)].map((match) => match[1])).toEqual([ - 'Publication', - 'Image', - 'Markdown' - ]) - for (const output of [ - 'image.original', - 'image.s.webp', - 'image.m.avif', - 'markdown', - 'markdown.description', - 'markdown.metadata' - ]) - expect(html).toContain(`${output}</bdi>`) - expect(html.match(/scope="col"[^>]*>Output<\/th>/g)).toHaveLength(2) - for (const path of [ - 'published/original.jpg', - 'published/s.webp', - 'published/m.avif', - 'published/body.html' - ]) - expect(html).toContain(`href="/cdn/pieces/assets/${path}"`) - expect(html).not.toMatch(/ download(?:[= >])/) - expect(html.match(/<button /g)).toHaveLength(3) - expect(html).toContain('aria-label="Show Markdown.description: Asset 2, Content"') - expect(html).toContain('aria-label="Show Markdown.metadata: Asset 2, Content"') - expect(html).not.toContain('{"title":"Recorded title","count":42}') - expect(html).not.toContain('title</bdi>') - expect(html).not.toContain('count</bdi>') - for (const excluded of [ - 'Markdown values</caption>', - 'Body</h1>', - 'Description</p>', - '>Variant<', - 'style=', - 'None recorded' - ]) - expect(html).not.toContain(excluded) -}) - -test.each(['/static', 'https://cdn.test/site-assets', ''])( - 'Published reset layout renders the resolved default favicon for original asset base %j', - (base) => { - const props = data() - props.config = makePublicConfig({ - url: { ...props.config.url, app: 'https://public.test/garden', app_assets: base } - }) - expect(props.config.content.images).toEqual({ - favicon: `${base}/images/favicon.png`, - opengraph: new URL(`${base}/images/opengraph.png`, 'https://public.test').href - }) - // Defaults are already in the payload, not recalculated by the consumer. - props.config.url.app_assets = 'https://unrelated-cdn.test/assets' - const result = render(PublishedTree, { props: { data: props } }) - expect(result.head).toContain(`rel="icon" href="${base}/images/favicon.png"`) - expect(result.head.match(/rel="icon"/g)).toHaveLength(1) - expect(result.head).toContain('Published: Published <Book>') - expect(result.head).toContain('name="robots" content="noindex"') - expect(result.head).not.toContain('property="og:') - expect(result.head).not.toContain('name="description"') - expect(result.head).not.toContain('/cdn/images/favicon.png') - expect(result.body.match(/<main\b/g)).toHaveLength(1) - } -) - -test.each([false, true])( - 'Published reset layout consumes resolved favicon selection %s', - (selected) => { - const props = data() - props.config.url.app_assets = 'https://unrelated-cdn.test/assets' - props.config.url.app = 'https://public.test' - const favicon = selected ? '/assets/favicon/selected-icon.svg' : '/static/images/favicon.png' - const opengraph = selected - ? 'https://public.test/assets/opengraph/selected-card.png' - : 'https://public.test/static/images/opengraph.png' - props.config.content.images = { favicon, opengraph } - const result = render(PublishedTree, { props: { data: props, view: 'share' } }) - expect(result.head).toContain(`rel="icon" href="${props.config.content.images.favicon}"`) - expect(result.head.match(/rel="icon"/g)).toHaveLength(1) - expect(result.head).not.toContain('property="og:') - expect(result.head).not.toContain('assets/opengraph') - expect(result.body).toContain('data-renderer="composition"') - } -) - -test('reset layout keeps its inspector title/noindex and adds only the favicon, not public SEO', () => { - const result = render(PublishedTree, { props: { data: data() } }) - expect(result.head).toContain('Published: Published <Book>') - expect(result.head).toContain('name="robots" content="noindex"') - expect(result.head).not.toContain('og:') - expect(result.body).toContain('Back to editor') - expect(result.body).toContain(`href="${editorHref(data().file)}"`) - expect(result.body).toContain(`href="${publishedHref(data().file)}" aria-current="page"`) - expect(result.body.match(/aria-current="page"/g)).toHaveLength(1) - expect(result.body).not.toMatch(/<th[^>]*>Source<\/th>/) - expect(result.body.match(/<main /g)).toHaveLength(1) -}) - -test.each(['page', 'icon', 'share'] as const)( - 'Published %s delegates serving data and uses published assets without nested main', - (view) => { - page.url = new URL(`${publishedHref(data().file)}/${view}`, 'https://app.test') - page.route.id = `/(auth)/admin/piece/[...path]/published/${view}` - const result = render(PublishedTree, { props: { data: data(), view } }) - expect(result.body).toContain( - `href="${publishedHref(data().file)}/${view}" aria-current="page"` - ) - expect(result.body.match(/aria-current="page"/g)).toHaveLength(1) - expect(result.body.match(/<main /g)).toHaveLength(1) - expect(result.body).toContain('src="/cdn/pieces/assets/published/cover.jpg"') - expect(result.body).not.toContain('/admin/asset') - expect(result.body).not.toContain('/admin/preview') - if (view === 'page') { - expect(result.body).toContain('data-renderer="page"') - expect(result.body).toContain('href="/tags/fiction"') - expect(result.body).toContain('Fiction') - expect(resolve).toHaveBeenCalledWith('/tags/[tag]', { tag: 'fiction' }) - } - if (view === 'icon') { - expect(result.body).toContain('aria-label="Piece icon"') - expect(result.body).toContain('data-width="500"') - expect(result.body).toContain('loading="eager"') - } - if (view === 'share') { - expect(result.body).toContain('aria-label="Share"') - expect(result.body).toContain('data-renderer="composition"') - expect(result.body).not.toContain('Rendered share composition') - expect(result.body).not.toContain('not the generated PNG') - expect(result.body).not.toContain('Open generated share image') - } - } -) diff --git a/docs/development.md b/docs/development.md index 7493f642..6e48f1ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,8 +25,24 @@ pnpm lint pnpm build ``` -Keep coverage from dropping. If you change Docker or publishing, exercise that -path too. A passing unit test is not a running container. +If you change Docker or publishing, exercise that path too. +A passing unit test is not a running container. + +## Testing + +Tests should protect plausible application-owned regressions. Name what could +break and why it matters before adding or repairing a test. Remove low-value +tests without replacement; test counts and coverage percentages are not goals. + +Use the cheapest valid proof. Each layer should protect a distinct boundary, +not repeat an existing matrix. Keep real database, HTTP or browser tests where +mocks cannot prove the behavior. Test our decisions and integration; trust +established dependencies to do their jobs. + +Write scenarios in setup, action and outcome order, using named facts and simple +fixtures. String assertions and small functions can protect important contracts; +cosmetic inventories, private implementation details and hypothetical failures +usually do not. ## Keep it boring diff --git a/mise.toml b/mise.toml index 420bc5c6..12e298d3 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,3 @@ [tools] -node = "26.4.0" +node = "26.5.0" pnpm = "11.2.2" -- 2.54.0