[web] connect preview and published overviews #203
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Connect Published and Preview overviews and offer fresh saved-source previews when a preview fails or expires.
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms'
|
||||
import { resolve } from '$app/paths'
|
||||
import { encodeArchivePath } from '#lib/pieces/navigation.js'
|
||||
|
||||
type Props = {
|
||||
file: string
|
||||
preview?: 'saved' | 'retry'
|
||||
hasPublishedVersion?: boolean
|
||||
}
|
||||
|
||||
let { file, preview, hasPublishedVersion = false }: Props = $props()
|
||||
const path = $derived(encodeArchivePath(file))
|
||||
let starting = $state(false)
|
||||
|
||||
const startPreview: SubmitFunction = ({ cancel }) => {
|
||||
if (starting) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
starting = true
|
||||
return async ({ update }) => {
|
||||
try {
|
||||
await update()
|
||||
} finally {
|
||||
starting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if preview}
|
||||
<form
|
||||
method="post"
|
||||
action={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
|
||||
use:enhance={startPreview}
|
||||
aria-busy={starting}
|
||||
>
|
||||
<button type="submit" disabled={starting}>
|
||||
{#if starting}Starting preview…
|
||||
{:else if preview === 'retry'}Generate fresh preview
|
||||
{:else}Generate{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if hasPublishedVersion}
|
||||
<a href={resolve('/(auth)/admin/piece/[...path]/published', { path })}>View published</a>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
form {
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface-container);
|
||||
border: 1px solid var(--color-outline-variant);
|
||||
border-radius: var(--radius-small);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -67,7 +67,7 @@
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--color-outline-variant);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
vertical-align: middle;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.overview :global(.overview-facts th) {
|
||||
@@ -112,7 +112,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
border-top: 1px solid var(--color-outline-variant);
|
||||
padding: 0;
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
}
|
||||
.overview :global(.overview-facts th),
|
||||
.overview :global(.overview-facts td) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { LayoutServerLoad } from './$types'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import { getWebPiece } from '#lib/server/pieces.js'
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
locals.inlineConfiguredStyles()
|
||||
return {}
|
||||
const published = await getWebPiece(db, params.path)
|
||||
return { hasPublishedVersion: !!published }
|
||||
}
|
||||
@@ -146,6 +146,7 @@
|
||||
<PieceOverview title="Preview" fileUrl={() => undefined}>
|
||||
<PreviewFacts
|
||||
file={data.file}
|
||||
hasPublishedVersion={data.hasPublishedVersion}
|
||||
{phases}
|
||||
{status}
|
||||
{streamDisconnected}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
>
|
||||
<PreviewFacts
|
||||
file={data.file}
|
||||
hasPublishedVersion={data.hasPublishedVersion}
|
||||
phases={data.phases ?? []}
|
||||
type={data.piece?.type}
|
||||
status="completed"
|
||||
|
||||
+16
-3
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { ProgressPhase } from '#lib/components/progress.js'
|
||||
import { formatDuration, previewDuration } from './duration.js'
|
||||
import OverviewActions from '#lib/pieces/viewer/OverviewActions.svelte'
|
||||
|
||||
interface Props {
|
||||
file: string
|
||||
type?: string
|
||||
status: string
|
||||
hasPublishedVersion?: boolean
|
||||
streamDisconnected?: boolean
|
||||
createdAt: string | null
|
||||
finishedAt: string | null
|
||||
@@ -16,6 +18,7 @@
|
||||
file,
|
||||
type,
|
||||
status,
|
||||
hasPublishedVersion = false,
|
||||
streamDisconnected = false,
|
||||
createdAt,
|
||||
finishedAt,
|
||||
@@ -84,9 +87,7 @@
|
||||
{#if !streamDisconnected && (status === 'enqueued' || status === 'waiting')}
|
||||
<div class="status-sub">Waiting for worker…</div>
|
||||
{:else if !streamDisconnected && status === 'expired'}
|
||||
<div class="status-sub">
|
||||
Assets are purged after 2 days. Re-run the preview from the editor.
|
||||
</div>
|
||||
<div class="status-sub">Assets are purged after 2 days.</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -131,6 +132,18 @@
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{#if hasPublishedVersion}
|
||||
<tr role="row">
|
||||
<th scope="row" role="rowheader">Publication</th>
|
||||
<td role="cell"><OverviewActions {file} {hasPublishedVersion} /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if status === 'failed' || status === 'expired'}
|
||||
<tr role="row">
|
||||
<th scope="row" role="rowheader">Preview</th>
|
||||
<td role="cell"><OverviewActions {file} preview="retry" /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+11
-136
@@ -1,10 +1,15 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { expect, test } from 'vitest'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { render } from 'svelte/server'
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import type { ProgressPhase } from '#lib/components/progress.js'
|
||||
import PreviewFacts from './PreviewFacts.svelte'
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
|
||||
vi.mock('$app/paths', () => ({
|
||||
resolve: (route: string, params: { path: string }) =>
|
||||
route.replace('/(auth)', '').replace('[...path]', () => params.path)
|
||||
}))
|
||||
|
||||
function facts(overrides: Partial<ComponentProps<typeof PreviewFacts>> = {}) {
|
||||
return render(PreviewFacts, {
|
||||
props: {
|
||||
@@ -45,52 +50,6 @@ function field(html: string, name: string) {
|
||||
return rows(html).find(([label]) => label === name)?.[1]
|
||||
}
|
||||
|
||||
test('fixed UI copy is literal markup rather than JavaScript label maps', () => {
|
||||
const source = readFileSync(new URL('./PreviewFacts.svelte', import.meta.url), 'utf8')
|
||||
const markup = source
|
||||
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/g, '')
|
||||
.replace(/\{[^}]*\}/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
|
||||
expect(source).not.toMatch(/\b(?:statusLabels|durationLabels)\b|\bnew\s+Map\s*\(/)
|
||||
for (const label of [
|
||||
'Preview',
|
||||
'File',
|
||||
'Type',
|
||||
'State',
|
||||
'Duration',
|
||||
'Created',
|
||||
'Finished',
|
||||
'Unknown',
|
||||
'Queued',
|
||||
'Building',
|
||||
'Completed',
|
||||
'Failed',
|
||||
'Expired',
|
||||
'Refresh to reconnect',
|
||||
'Waiting for worker…',
|
||||
'Assets are purged after 2 days. Re-run the preview from the editor.',
|
||||
'elapsed',
|
||||
'total time',
|
||||
'stopped at',
|
||||
'UTC'
|
||||
])
|
||||
expect(markup).toContain(label)
|
||||
})
|
||||
|
||||
test('facts have no state icon imports, markup, classification, motion, or local badge styles', () => {
|
||||
const source = readFileSync(new URL('./PreviewFacts.svelte', import.meta.url), 'utf8')
|
||||
const style = source.match(/<style>([\s\S]*?)<\/style>/)?.[1]
|
||||
expect(style).toBeDefined()
|
||||
expect(source).not.toMatch(
|
||||
/virtual:icons|<svg\b|status-icon|status-\{|spin|@keyframes|prefers-reduced-motion/
|
||||
)
|
||||
expect(style).not.toMatch(/\.status\s*\{|data-status|background|animation|transform/)
|
||||
expect(source).toMatch(
|
||||
/<span\s+class="status"\s+data-status=\{!streamDisconnected && knownStatus \? status : 'unknown'\}\s*>/
|
||||
)
|
||||
})
|
||||
|
||||
test.each(['enqueued', 'running'])(
|
||||
'disconnection replaces the %s state label without inventing a job failure',
|
||||
(status) => {
|
||||
@@ -104,88 +63,19 @@ test.each(['enqueued', 'running'])(
|
||||
}
|
||||
)
|
||||
|
||||
test('shared overview styles give Published and Preview identical badge colors and neutral pending states', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../../../../../../../lib/pieces/viewer/PieceOverview.svelte', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const base = source.match(/\.overview\s+:global\(\.overview-facts \.status\)\s*\{([^}]+)\}/)?.[1]
|
||||
expect(base?.replace(/\s+/g, ' ').trim()).toBe(
|
||||
'display: inline-block; padding: var(--space-1) var(--space-2); border-radius: var(--radius-small); font-size: inherit; font-weight: var(--font-weight-medium); background: var(--color-surface-container-highest); color: var(--color-on-surface-variant);'
|
||||
)
|
||||
const blocks = [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
||||
for (const [status, tone] of [
|
||||
['matching', 'primary'],
|
||||
['completed', 'primary'],
|
||||
['changed', 'secondary'],
|
||||
['running', 'secondary'],
|
||||
['failed', 'error']
|
||||
]) {
|
||||
const block = blocks.find(([, selector]) =>
|
||||
selector.includes(`.overview :global(.overview-facts .status[data-status='${status}'])`)
|
||||
)
|
||||
expect(block, status).toBeDefined()
|
||||
expect(block?.[2]).toContain(`background: var(--color-${tone}-container);`)
|
||||
expect(block?.[2]).toContain(`color: var(--color-on-${tone}-container);`)
|
||||
}
|
||||
for (const status of ['waiting', 'enqueued', 'expired', 'unknown'])
|
||||
expect(source).not.toMatch(new RegExp(`data-status=['"]${status}['"]`))
|
||||
})
|
||||
|
||||
test('Preview facts have the six actual fields and explicit mobile table semantics', () => {
|
||||
const html = facts()
|
||||
expect(html).toContain('class="overview-facts"')
|
||||
expect(html).toMatch(/<table[^>]*role="table"/)
|
||||
expect(html.match(/<table\b/g)).toHaveLength(1)
|
||||
expect(html).toContain('<caption>Preview</caption>')
|
||||
expect(html).toMatch(/<tbody[^>]*role="rowgroup"/)
|
||||
expect(html.match(/<tr[^>]*role="row"/g)).toHaveLength(6)
|
||||
expect(html.match(/<th[^>]*scope="row"[^>]*role="rowheader"/g)).toHaveLength(6)
|
||||
expect(html.match(/<td[^>]*role="cell"/g)).toHaveLength(6)
|
||||
expect(rows(html)).toEqual([
|
||||
['File', 'books/a-book.books.md'],
|
||||
['Type', 'books'],
|
||||
['State', 'Completed'],
|
||||
['Duration', 'Unknown'],
|
||||
['Created', '1/2/2026, 3:04:05 AM UTC'],
|
||||
['Finished', '1/2/2026, 3:05:06 AM UTC']
|
||||
])
|
||||
for (const excluded of [
|
||||
'<h1',
|
||||
'<main',
|
||||
'<a ',
|
||||
'status-card',
|
||||
'Title',
|
||||
'Source',
|
||||
'Publication',
|
||||
'Last publish',
|
||||
'Matches publish',
|
||||
'Unpublished changes',
|
||||
'URL',
|
||||
'Started'
|
||||
])
|
||||
expect(html).not.toContain(excluded)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['waiting', 'Queued Waiting for worker…'],
|
||||
['enqueued', 'Queued Waiting for worker…'],
|
||||
['running', 'Building'],
|
||||
['completed', 'Completed'],
|
||||
['failed', 'Failed'],
|
||||
['expired', 'Expired Assets are purged after 2 days. Re-run the preview from the editor.']
|
||||
['expired', 'Expired Assets are purged after 2 days.']
|
||||
])('state %s retains facts with the agreed label %s', (status, label) => {
|
||||
const html = facts({ status, finishedAt: null })
|
||||
expect(html).toMatch(new RegExp(`<span class="status" data-status="${status}">`))
|
||||
const fields = rows(html)
|
||||
expect(fields).toEqual([
|
||||
['File', 'books/a-book.books.md'],
|
||||
['Type', 'books'],
|
||||
['State', label],
|
||||
['Duration', 'Unknown'],
|
||||
['Created', '1/2/2026, 3:04:05 AM UTC'],
|
||||
['Finished', 'Unknown']
|
||||
])
|
||||
expect(field(html, 'State')).toBe(label)
|
||||
expect(field(html, 'File')).toBe('books/a-book.books.md')
|
||||
expect(field(html, 'Finished')).toBe('Unknown')
|
||||
})
|
||||
|
||||
test('missing type is Unknown without inferring any identity', () => {
|
||||
@@ -272,21 +162,6 @@ test('the entire raw filename is escaped, direction-isolated, and available as i
|
||||
expect(html).not.toContain('<a ')
|
||||
})
|
||||
|
||||
test.each(['completed', 'running', 'enqueued', 'waiting', 'failed', 'expired'])(
|
||||
'%s uses the same icon-free State badge element and closed status attribute',
|
||||
(status) => {
|
||||
const html = facts({ status })
|
||||
const cell = html.match(/>State<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/)?.[1]
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell!.match(/<span\b/g)).toHaveLength(1)
|
||||
expect(cell).toContain(`<span class="status" data-status="${status}">`)
|
||||
expect(cell).not.toContain('status-icon')
|
||||
expect(html).not.toContain('<svg')
|
||||
expect(html).not.toContain('spin')
|
||||
expect(field(html, 'State')).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
['completed', '2s total time'],
|
||||
['running', '2s elapsed'],
|
||||
|
||||
@@ -3,7 +3,6 @@ import { render } from 'svelte/server'
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
|
||||
import PreviewLogs from './PreviewLogs.svelte'
|
||||
import PreviewFacts from './PreviewFacts.svelte'
|
||||
|
||||
const started = Date.parse('2026-01-02T03:04:00Z')
|
||||
const phases: ProgressPhase[] = [
|
||||
@@ -72,46 +71,6 @@ test('logs are a labelled section with an instance-scoped heading, not a main or
|
||||
expect(headingId(first)).not.toBe(headingId(second))
|
||||
})
|
||||
|
||||
test('file metadata is preserved by facts rather than duplicated in the logs section', () => {
|
||||
const file = `${'nested folder/'.repeat(24)}雪 & <script>draft</script> #?%.books.md`
|
||||
const html = render(PreviewLogs, { props: props() }).body
|
||||
const facts = render(PreviewFacts, {
|
||||
props: { file, status: 'completed', phases, createdAt: null, finishedAt: null }
|
||||
}).body
|
||||
expect(facts).toContain(file.replace(/&/g, '&').replace(/</g, '<'))
|
||||
expect(html).not.toContain('nested folder/')
|
||||
expect(facts).not.toContain('<script>')
|
||||
expect(facts).not.toContain('<a ')
|
||||
expect(html).not.toContain('class="meta')
|
||||
expect(html).not.toContain('Publication')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['expired', 'Expired', 'Assets are purged after 2 days. Re-run the preview from the editor.'],
|
||||
['enqueued', 'Queued', 'Waiting for worker…'],
|
||||
['running', 'Building', '1m 30s elapsed'],
|
||||
['failed', 'Failed', '1m 30s stopped at'],
|
||||
['completed', 'Completed', '1m 30s total time']
|
||||
])(
|
||||
'keeps %s summary in the facts table while preserving phase/log delegation',
|
||||
(status, title, detail) => {
|
||||
const html = render(PreviewLogs, { props: props({ status }) }).body
|
||||
const facts = render(PreviewFacts, {
|
||||
props: { file: 'book.md', status, phases, createdAt: null, finishedAt: null }
|
||||
}).body
|
||||
expect(text(facts)).toContain(title)
|
||||
expect(text(facts)).toContain(detail)
|
||||
expect(facts).not.toContain('<svg')
|
||||
expect(facts).not.toContain('spin')
|
||||
expect(html).not.toContain(title)
|
||||
expect(html).not.toContain('status-card')
|
||||
expect(html).not.toContain('status-title')
|
||||
expect(html).toContain('timeline')
|
||||
expect(text(html)).toContain('Output ready')
|
||||
expect(text(html)).toContain('Recorded asset error')
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['waiting', 'future-state'])(
|
||||
'passes %s through without inventing console status copy',
|
||||
(status) => {
|
||||
|
||||
+3
-79
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { render } from 'svelte/server'
|
||||
import WorkflowConsole from './WorkflowConsole.svelte'
|
||||
import PreviewFacts from './PreviewFacts.svelte'
|
||||
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
|
||||
|
||||
const started = Date.parse('2026-01-02T03:04:00Z')
|
||||
@@ -20,19 +19,6 @@ const logs: Record<string, ProgressLog[]> = {
|
||||
]
|
||||
}
|
||||
|
||||
function summary(status: string, items: ProgressPhase[] = phases) {
|
||||
return render(PreviewFacts, {
|
||||
props: {
|
||||
file: 'book.md',
|
||||
type: 'book',
|
||||
status,
|
||||
phases: items,
|
||||
createdAt: '2026-01-02T03:04:00Z',
|
||||
finishedAt: null
|
||||
}
|
||||
}).body.replace(/<!--[^]*?-->/g, '')
|
||||
}
|
||||
|
||||
function text(html: string) {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
@@ -40,34 +26,7 @@ function text(html: string) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(started + 75_000)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('preview status summary', () => {
|
||||
test.each([
|
||||
['expired', 'Expired', 'Assets are purged after 2 days. Re-run the preview from the editor.'],
|
||||
['enqueued', 'Queued', 'Waiting for worker…'],
|
||||
['running', 'Building', '1m 5s elapsed'],
|
||||
['failed', 'Failed', '1m 5s stopped at'],
|
||||
['completed', 'Completed', '1m 5s total time']
|
||||
])('retains wording and duration for %s', (status, title, subtitle) => {
|
||||
const result = render(WorkflowConsole, { props: { status, phases, logs } })
|
||||
const facts = summary(status)
|
||||
expect(text(facts)).toContain(title)
|
||||
expect(text(facts)).toContain(subtitle)
|
||||
expect(facts).not.toContain('<svg')
|
||||
expect(facts).not.toContain('spin')
|
||||
expect(result.body).not.toContain('status-card')
|
||||
expect(result.body).not.toContain(title)
|
||||
expect(result.body).toContain('phase-icon')
|
||||
expect(result.body).toContain('log-viewport')
|
||||
expect(text(result.body)).toContain('Rendered successfully')
|
||||
expect(text(result.body)).toContain('03:04:00')
|
||||
expect(text(result.body)).toContain('Output ready')
|
||||
})
|
||||
|
||||
describe('preview error reporting', () => {
|
||||
test('preserves an empty summary for an unrecognized status', () => {
|
||||
const result = render(WorkflowConsole, { props: { status: 'unknown', phases: [], logs: {} } })
|
||||
expect(result.body).not.toContain('status-title')
|
||||
@@ -95,7 +54,6 @@ describe('preview phases and logs', () => {
|
||||
logs
|
||||
}
|
||||
})
|
||||
expect(text(summary('completed'))).toContain('1m 5s total time')
|
||||
expect(result.body).not.toContain('Completed')
|
||||
expect(text(result.body)).toContain('1 lines')
|
||||
expect(result.body).toContain('Auto-scroll locked')
|
||||
@@ -139,7 +97,7 @@ describe('preview phases and logs', () => {
|
||||
expect(viewportId(first.body)).not.toBe(viewportId(second.body))
|
||||
})
|
||||
|
||||
test('measures total preview duration from the first phase through the last', () => {
|
||||
test('shows each phase duration rather than a total preview time', () => {
|
||||
const result = render(WorkflowConsole, {
|
||||
props: {
|
||||
status: 'completed',
|
||||
@@ -155,44 +113,10 @@ describe('preview phases and logs', () => {
|
||||
logs: {}
|
||||
}
|
||||
})
|
||||
expect(
|
||||
text(
|
||||
summary('completed', [
|
||||
phases[0],
|
||||
{
|
||||
...phases[0],
|
||||
phase: 'publish',
|
||||
started_at: started + 65_000,
|
||||
finished_at: started + 90_000
|
||||
}
|
||||
])
|
||||
)
|
||||
).toContain('1m 30s total time')
|
||||
expect(result.body).not.toContain('total time')
|
||||
expect(text(result.body)).toContain('25s')
|
||||
})
|
||||
|
||||
test.each([
|
||||
[[], 'Unknown'],
|
||||
[[{ ...phases[0], started_at: 0, finished_at: 2000 }], '2s'],
|
||||
[[{ ...phases[0], finished_at: null }], '1m 15s'],
|
||||
[[{ ...phases[0], finished_at: started + 5000 }], '5s'],
|
||||
[[{ ...phases[0], finished_at: started - 1000 }], '0s']
|
||||
])('retains elapsed-time formatting for %j', (items, duration) => {
|
||||
const result = render(WorkflowConsole, {
|
||||
props: {
|
||||
status: 'running',
|
||||
phases: items,
|
||||
logs: {}
|
||||
}
|
||||
})
|
||||
const facts = summary('running', items)
|
||||
const durationCell = facts.match(/>Duration<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/)?.[1]
|
||||
expect(durationCell).toBeDefined()
|
||||
expect(text(durationCell!)).toContain(duration === 'Unknown' ? duration : `${duration} elapsed`)
|
||||
expect(result.body).not.toContain('Building')
|
||||
})
|
||||
|
||||
test('retains phase statuses and stderr/error highlighting', () => {
|
||||
const items = ['running', 'failed', 'skipped', 'waiting'].map((status) => ({
|
||||
...phases[0],
|
||||
|
||||
@@ -37,6 +37,7 @@ const { page, EventSource, resolver, resolve } = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
vi.mock('$app/state', () => ({ page }))
|
||||
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
|
||||
vi.mock('$app/paths', () => ({ resolve }))
|
||||
|
||||
const file = 'books/book.books.md'
|
||||
@@ -51,6 +52,7 @@ function data(status: Status): ComponentProps<typeof PreviewLayout>['data'] {
|
||||
config: makePublicConfig(),
|
||||
meta: { title: 'Garden', description: 'Description' },
|
||||
preview: true as const,
|
||||
hasPublishedVersion: false,
|
||||
file,
|
||||
job: 'preview-id',
|
||||
createdAt: '2026-06-20T00:00:00Z',
|
||||
@@ -67,9 +69,13 @@ function data(status: Status): ComponentProps<typeof PreviewLayout>['data'] {
|
||||
return { ...common, status }
|
||||
}
|
||||
|
||||
function body(status: Status = 'completed') {
|
||||
function body(status: Status = 'completed', hasPublishedVersion = false) {
|
||||
return render(PreviewLayout, {
|
||||
props: { data: data(status), params: { path: file, jobId: 'preview-id' }, children }
|
||||
props: {
|
||||
data: { ...data(status), hasPublishedVersion },
|
||||
params: { path: file, jobId: 'preview-id' },
|
||||
children
|
||||
}
|
||||
}).body.replace(/<!--[^]*?-->/g, '')
|
||||
}
|
||||
|
||||
@@ -80,6 +86,8 @@ function anchors(html: string) {
|
||||
function expectAppNavigation(path = file, jobId = 'preview-id') {
|
||||
const calls = [
|
||||
['/(auth)/admin/piece/[...path]/source', { path }],
|
||||
['/(auth)/admin/piece/[...path]/preview', { path }],
|
||||
['/(auth)/admin/piece/[...path]/published', { path }],
|
||||
[previewRoute, { path, jobId }],
|
||||
[`${previewRoute}/page`, { path, jobId }],
|
||||
[`${previewRoute}/icon`, { path, jobId }],
|
||||
@@ -196,7 +204,17 @@ test('Preview registers Share without retaining an OG route or redirect alias',
|
||||
test.each(['waiting', 'running', 'completed', 'failed', 'expired'] as const)(
|
||||
'Preview %s retains destinations and availability inside the shared responsive header',
|
||||
(status) => {
|
||||
const html = body(status)
|
||||
const html = body(status, status === 'failed')
|
||||
const forms = html.match(/<form\b[^>]*>[\s\S]*?<\/form>/g) ?? []
|
||||
if (status === 'failed' || status === 'expired') {
|
||||
expect(forms).toHaveLength(1)
|
||||
expect(forms[0]).toContain('method="post"')
|
||||
expect(forms[0]).toContain(`action="/admin/piece/${file}/preview"`)
|
||||
expect(forms[0]).toContain('Generate fresh preview')
|
||||
} else {
|
||||
expect(forms).toHaveLength(0)
|
||||
}
|
||||
expect(html.includes(`href="/admin/piece/${file}/published"`)).toBe(status === 'failed')
|
||||
const header = html.slice(0, html.indexOf('</header>') + '</header>'.length)
|
||||
const links = anchors(header)
|
||||
expect(links).toHaveLength(5)
|
||||
@@ -289,7 +307,7 @@ test.each(['', '/page', '/icon', '/share'])(
|
||||
}
|
||||
)
|
||||
|
||||
test('Preview header retains encoded file and job identity reached through View', () => {
|
||||
test('Preview retains encoded file and job navigation and publication identity', () => {
|
||||
const props = data('completed')
|
||||
props.file = 'books/literal%2F/源 #?%.books.md'
|
||||
props.job = 'job /#?%2F'
|
||||
@@ -310,6 +328,17 @@ test('Preview header retains encoded file and job identity reached through View'
|
||||
expect(links.filter((link) => link.includes('aria-current="page"'))).toHaveLength(1)
|
||||
expect(links.at(-1)).toContain('aria-current="page"')
|
||||
expectAppNavigation(path, jobId)
|
||||
|
||||
const running = data('running')
|
||||
running.file = props.file
|
||||
running.job = props.job
|
||||
running.hasPublishedVersion = true
|
||||
const pendingHtml = render(PreviewLayout, {
|
||||
props: { data: running, params: { path: running.file, jobId: running.job }, children }
|
||||
}).body
|
||||
expect(pendingHtml.split(`href="${encodedRoot}/published"`)).toHaveLength(2)
|
||||
expect(pendingHtml).not.toMatch(/<form\b/)
|
||||
expectAppNavigation(path, jobId)
|
||||
expect(EventSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('$app/state', () => ({
|
||||
route: { id: '/(auth)/admin/piece/[...path]/preview/[jobId]' }
|
||||
}
|
||||
}))
|
||||
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
|
||||
vi.mock('$app/paths', () => ({
|
||||
resolve: (route: string, params: { path: string; jobId?: string }) =>
|
||||
route
|
||||
@@ -34,6 +35,7 @@ const job = {
|
||||
}),
|
||||
meta: { title: 'Garden', description: 'Description' },
|
||||
preview: true as const,
|
||||
hasPublishedVersion: false,
|
||||
file: 'books/book.md',
|
||||
job: 'current-job',
|
||||
createdAt: '2026-11-01T12:00:00.000Z',
|
||||
@@ -112,8 +114,16 @@ test.each(['running', 'completed'] as const)(
|
||||
}
|
||||
)
|
||||
|
||||
test('completed Preview inspects only generated outputs in the current job namespace', () => {
|
||||
const html = renderPreview(completedPreview)
|
||||
test('completed Preview links its publication and inspects only current-job generated outputs', () => {
|
||||
const html = renderPreview({
|
||||
...completedPreview,
|
||||
file: 'books/源 #?%.books.md',
|
||||
hasPublishedVersion: true
|
||||
})
|
||||
|
||||
expect(
|
||||
html.split('href="/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published"')
|
||||
).toHaveLength(2)
|
||||
|
||||
expect(html).toContain('Preview <Draft>')
|
||||
expect(html).toContain('Preview logs')
|
||||
@@ -130,6 +140,7 @@ test('empty generated outputs do not fall back to SDK passthrough assets', () =>
|
||||
|
||||
expect(html).toContain('Preview assets')
|
||||
expect(html).toContain('None recorded')
|
||||
expect(html).not.toContain('/published"')
|
||||
expect(html).not.toContain('generated/cover.jpg')
|
||||
expect(html).not.toContain('archive/manual.pdf')
|
||||
})
|
||||
|
||||
@@ -40,9 +40,10 @@ const { page, resolve } = vi.hoisted(() => ({
|
||||
})
|
||||
}))
|
||||
vi.mock('$app/state', () => ({ page }))
|
||||
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
|
||||
vi.mock('$app/paths', () => ({ resolve }))
|
||||
|
||||
// Only the layout's five destinations and explicit page tag links may use this mock.
|
||||
// Only layout destinations, the retry action and explicit page tag links may use this mock.
|
||||
// Resource/helper URLs must remain independent; this is not native Kit resolution evidence.
|
||||
function expectAppNavigation(jobs = ['job'], tags: string[] = []) {
|
||||
const calls = [
|
||||
@@ -52,6 +53,7 @@ function expectAppNavigation(jobs = ['job'], tags: string[] = []) {
|
||||
const jobId = encodeURIComponent(job)
|
||||
return [
|
||||
['/(auth)/admin/piece/[...path]/source', { path }],
|
||||
['/(auth)/admin/piece/[...path]/preview', { path }],
|
||||
[route, { path, jobId }],
|
||||
[`${route}/page`, { path, jobId }],
|
||||
[`${route}/icon`, { path, jobId }],
|
||||
@@ -89,6 +91,7 @@ function data(status: Status = 'completed', png = true): Data {
|
||||
}),
|
||||
meta: { title: 'Garden', description: 'Description' },
|
||||
preview: true as const,
|
||||
hasPublishedVersion: false,
|
||||
file: 'books/book.md',
|
||||
job: 'job',
|
||||
createdAt: '1970-01-01T00:00:00.000Z',
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { load } from './+layout.server'
|
||||
import { db } from '#lib/server/database/index.js'
|
||||
import { getWebPiece } from '#lib/server/pieces.js'
|
||||
|
||||
test('the preview layout requests configured CSS without returning it as page data', async () => {
|
||||
vi.mock('#lib/server/database/index.js', () => ({ db: {} }))
|
||||
vi.mock('#lib/server/pieces.js', () => ({ getWebPiece: vi.fn() }))
|
||||
|
||||
test('the preview layout supplies publication availability and inlines configured CSS', async () => {
|
||||
const inlineConfiguredStyles = vi.fn()
|
||||
expect(
|
||||
await load({ locals: { inlineConfiguredStyles } } as unknown as Parameters<typeof load>[0])
|
||||
).toEqual({})
|
||||
vi.mocked(getWebPiece).mockResolvedValue(undefined)
|
||||
const file = 'books/example.books.md'
|
||||
const event = {
|
||||
params: { path: file },
|
||||
locals: { inlineConfiguredStyles }
|
||||
} as unknown as Parameters<typeof load>[0]
|
||||
|
||||
expect(await load(event)).toEqual({ hasPublishedVersion: false })
|
||||
expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file)
|
||||
expect(inlineConfiguredStyles).toHaveBeenCalledExactlyOnceWith()
|
||||
|
||||
const foundRow: NonNullable<Awaited<ReturnType<typeof getWebPiece>>> = {
|
||||
id: 'published-id',
|
||||
key: 'published-key',
|
||||
file_path: file,
|
||||
title: 'Published title',
|
||||
type: 'books',
|
||||
slug: 'example',
|
||||
date_added: 10,
|
||||
date_updated: 20,
|
||||
json_metadata: '{}',
|
||||
content_hash: 'published-hash',
|
||||
last_published_at: 1234
|
||||
}
|
||||
vi.mocked(getWebPiece).mockResolvedValue(foundRow)
|
||||
|
||||
expect(await load(event)).toEqual({ hasPublishedVersion: true })
|
||||
expect(getWebPiece).toHaveBeenLastCalledWith(db, file)
|
||||
expect(inlineConfiguredStyles).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -1,17 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types'
|
||||
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
|
||||
import OverviewActions from '#lib/pieces/viewer/OverviewActions.svelte'
|
||||
import { assetPathToUrl } from '@luzzle/web.pieces'
|
||||
import { MarkdownPublicationStatus } from '#lib/pieces/types.js'
|
||||
let { data }: PageProps = $props()
|
||||
function fileUrl(path: string) {
|
||||
return assetPathToUrl(path, data.config.url.luzzle_assets || data.config.url.app)
|
||||
}
|
||||
const statusLabels: Record<MarkdownPublicationStatus, string> = {
|
||||
[MarkdownPublicationStatus.MATCHING]: 'Matches publish',
|
||||
[MarkdownPublicationStatus.CHANGED]: 'Unpublished changes',
|
||||
[MarkdownPublicationStatus.UNKNOWN]: 'Unknown'
|
||||
}
|
||||
const publishedAt = $derived(
|
||||
data.publication.lastPublishedAt === null ? null : new Date(data.publication.lastPublishedAt)
|
||||
)
|
||||
@@ -49,9 +45,13 @@
|
||||
<tr role="row">
|
||||
<th scope="row" role="rowheader">State</th>
|
||||
<td role="cell">
|
||||
<span class="status" data-status={data.publication.markdownStatus}
|
||||
>{statusLabels[data.publication.markdownStatus]}</span
|
||||
>
|
||||
<span class="status" data-status={data.publication.markdownStatus}>
|
||||
{#if data.publication.markdownStatus === MarkdownPublicationStatus.MATCHING}Matches
|
||||
publish
|
||||
{:else if data.publication.markdownStatus === MarkdownPublicationStatus.CHANGED}Unpublished
|
||||
changes
|
||||
{:else}Unknown{/if}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr role="row">
|
||||
@@ -68,6 +68,12 @@
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
</td>
|
||||
</tr>
|
||||
{#if data.publication.markdownStatus === MarkdownPublicationStatus.CHANGED}
|
||||
<tr role="row">
|
||||
<th scope="row" role="rowheader">Preview</th>
|
||||
<td role="cell"><OverviewActions file={data.file} preview="saved" /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const { page } = vi.hoisted(() => ({
|
||||
}
|
||||
}))
|
||||
vi.mock('$app/state', () => ({ page }))
|
||||
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
|
||||
vi.mock('$app/paths', () => ({
|
||||
resolve: (route: string, params?: { path?: string; tag?: string }) => {
|
||||
if (route === '/tags/[tag]') return `/tags/${params?.tag}`
|
||||
@@ -71,6 +72,7 @@ test('overview shows the published snapshot, recorded completion time and public
|
||||
expect(html).toContain('Published <Book>')
|
||||
expect(html).toContain('datetime="2026-11-01T12:00:00.000Z"')
|
||||
expect(html).toContain('data-status="matching"')
|
||||
expect(html).not.toMatch(/<form\b/)
|
||||
expect(html).toContain(`href="${data.publishedUrl}"`)
|
||||
expect(html).toContain('href="/cdn/pieces/assets/generated/share.png"')
|
||||
expect(html).not.toContain('/admin/preview/')
|
||||
@@ -95,7 +97,14 @@ test('unpublished changes do not hide the previous publication or claim it match
|
||||
const html = render(Overview, { props: { data, params: { path: data.file } } }).body
|
||||
|
||||
expect(html).toContain('data-status="changed"')
|
||||
expect(html).toContain('Unpublished changes')
|
||||
expect(html).toMatch(/Unpublished\s+changes/)
|
||||
const forms = html.match(/<form\b[^>]*>[\s\S]*?<\/form>/g) ?? []
|
||||
expect(forms).toHaveLength(1)
|
||||
expect(forms[0]).toContain('method="post"')
|
||||
expect(forms[0]).toContain('action="/admin/piece/books/draft%20%23%3F.books.md/preview"')
|
||||
expect(forms[0]?.replace(/<!--[\s\S]*?-->/g, '')).toMatch(
|
||||
/<button\b[^>]*type="submit"[^>]*>\s*Generate\s*<\/button>/
|
||||
)
|
||||
expect(html).not.toContain('Matches publish')
|
||||
expect(html).toContain('datetime="2026-11-01T12:00:00.000Z"')
|
||||
expect(html).toContain(`href="${data.publishedUrl}"`)
|
||||
@@ -108,6 +117,7 @@ test('missing publication evidence remains unknown rather than using source date
|
||||
const html = render(Overview, { props: { data, params: { path: data.file } } }).body
|
||||
|
||||
expect(html).toContain('data-status="unknown"')
|
||||
expect(html).not.toMatch(/<form\b/)
|
||||
expect(html).toContain('Unknown')
|
||||
expect(html).not.toMatch(/<time\b/)
|
||||
})
|
||||
|
||||
@@ -31,6 +31,13 @@ not save; use **Save** when ready. Use **Attach** to download or upload assets e
|
||||
**Preview** always generates a fresh preview of the saved file; save your edits first.
|
||||
**View published** opens the existing public version's Overview.
|
||||
|
||||
When saved Markdown has unpublished changes, Published Overview adds a **Preview**
|
||||
row to the Publication table. Its **Generate** action creates a fresh preview
|
||||
of the saved file without changing the publication. The Preview table adds a
|
||||
**Publication → View published** row when a public version exists. Failed or expired
|
||||
previews add a **Preview → Generate fresh preview** row;
|
||||
this starts a new job from saved content rather than reusing the old preview.
|
||||
|
||||
When saved Markdown matches the publication, the toolbar is quieter. **Publish**
|
||||
remains available in **More** to regenerate unchanged Markdown after attachment,
|
||||
template, or renderer changes.
|
||||
|
||||
Reference in new issue
Block a user