[web] focus viewer tests on meaningful regressions #192
No files matched your search
@@ -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: '<Default>&',
|
||||
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"')
|
||||
})
|
||||
})
|
||||
@@ -228,14 +228,13 @@ describe('static default icon consumer SSR', () => {
|
||||
expect(body).not.toContain('<img')
|
||||
})
|
||||
|
||||
test('uses each new piece and mapped field value rather than a fallback snapshot', async () => {
|
||||
const current = piece('new-kind')
|
||||
current.title = 'Next <Piece>'
|
||||
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('<img')
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { expect, test } from 'vitest'
|
||||
import { parse } from 'svelte/compiler'
|
||||
|
||||
test.each([
|
||||
{ role: 'icon', component: 'Icon', getter: 'getIcon', catalog: 'icons' },
|
||||
{ role: 'page', component: 'Page', getter: 'getPage', catalog: 'pages' },
|
||||
{ role: 'opengraph', component: 'Opengraph', getter: 'getOpengraph', catalog: 'opengraphs' }
|
||||
])(
|
||||
'$role uses reactive props and the shared static catalog, not prepared HTML or async dispatch',
|
||||
({ role, component, getter, catalog }) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -26,34 +26,18 @@ vi.mock('$app/paths', () => ({
|
||||
resolve: (route: string, params: Record<string, string>) =>
|
||||
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(/<main\b/g)).toHaveLength(1)
|
||||
expect(html).toContain('id="main-content"')
|
||||
expect(html).toContain('aria-label="Piece icon"')
|
||||
expect(html.match(/<img\b/g)).toHaveLength(1)
|
||||
expect(html).toContain('data-renderer="icon"')
|
||||
expect(html).toContain('data-width="500"')
|
||||
expect(html).toContain('loading="eager"')
|
||||
expect(html).toContain(`src="${state.src}"`)
|
||||
|
||||
// SSR starts at full size; only the client can measure the available width.
|
||||
expect(html).toContain('max-width: 500px')
|
||||
const frame = html.match(/<div\b[^>]*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:')
|
||||
})
|
||||
@@ -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: () => '<div class="overview-facts"><p>Caller facts fixture</p></div>'
|
||||
}))
|
||||
const html = render(PieceOverview, {
|
||||
props: {
|
||||
title: 'Piece <title & "text">',
|
||||
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')
|
||||
})
|
||||
@@ -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/')
|
||||
})
|
||||
@@ -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}')
|
||||
})
|
||||
@@ -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}"`)
|
||||
})
|
||||
@@ -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] }
|
||||
])
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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: '' }
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
)
|
||||
+18
-2
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user