[web] unify admin step logs #204

Merged
eleith merged 1 commits from web/admin-log-layout into main 2026-11-10 17:43:28 +00:00
10 changed files with 498 additions and 1176 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Share a compact step-log layout between Publish and Preview, with step/status in the header and line count, elapsed seconds and a compact scroll lock below the output. Keep Publish logs visible without a disclosure.
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
import { tick } from 'svelte'
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
import LockSimpleFill from 'virtual:icons/ph/lock-simple-fill'
import LockSimpleOpenFill from 'virtual:icons/ph/lock-simple-open-fill'
type Props = {
phase: ProgressPhase
logs?: readonly (ProgressLog & { job_id?: string })[]
}
let { phase, logs = [] }: Props = $props()
let viewport = $state<HTMLDivElement>()
let scrollLocked = $state(true)
let now = $state(Date.now())
let lastScrollTop = 0
const running = $derived(phase.status === 'running' && phase.finished_at == null)
const waiting = $derived(
phase.status === 'queued' ||
phase.status === 'waiting' ||
phase.status === 'enqueued' ||
phase.status === 'pending'
)
const elapsedSeconds = $derived.by(() => {
if (!Number.isFinite(phase.started_at)) return null
const end = phase.finished_at ?? (running ? now : null)
if (end === null || !Number.isFinite(end)) return null
return Math.max(0, Math.floor((end - phase.started_at) / 1000))
})
$effect(() => {
if (!running) return
now = Date.now()
const interval = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(interval)
})
function handleScroll() {
if (!viewport) return
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 10
if (atBottom) {
scrollLocked = true
} else if (viewport.scrollTop < lastScrollTop) {
scrollLocked = false
}
lastScrollTop = viewport.scrollTop
}
$effect(() => {
const rowCount = logs.length
const node = viewport
if (!scrollLocked || !node) return
let canceled = false
tick().then(() => {
if (canceled || !scrollLocked || !node.isConnected || logs.length !== rowCount) return
node.scrollTop = node.scrollHeight
lastScrollTop = node.scrollTop
})
return () => {
canceled = true
}
})
</script>
<section class="step-log">
<header class="step-header">
<h3>{phase.phase}</h3>
<span class="status" class:is-error={phase.status === 'failed'}>
{#if waiting}
Queued
{:else if phase.status === 'running'}
Running
{:else if phase.status === 'completed'}
Completed
{:else if phase.status === 'failed'}
Failed
{:else if phase.status === 'skipped'}
Skipped
{:else if phase.status === 'cancelled' || phase.status === 'canceled'}
Cancelled
{:else if phase.status === 'expired'}
Expired
{:else if phase.status}
{phase.status}
{:else}
Unknown
{/if}
</span>
</header>
<div
class="log-viewport"
bind:this={viewport}
onscroll={handleScroll}
role="region"
aria-label={`${phase.phase} output`}
tabindex="0"
>
{#if phase.message}
<p class="phase-message" class:is-error={phase.status === 'failed'}>{phase.message}</p>
{/if}
{#each logs as log (JSON.stringify([log.job_id ?? '', log.line_number]))}
{@const timestamp = new Date(log.ts).toISOString()}
<div class="log-row" class:is-error={log.level === 'error' || log.level === 'stderr'}>
<time class="log-timestamp" datetime={timestamp}>
{timestamp.slice(11, 19)}
</time>
<span class="log-message">{log.message}</span>
</div>
{:else}
<p class="empty-message">
{#if phase.status === 'running' || waiting}
Waiting for output…
{:else}
No output recorded.
{/if}
</p>
{/each}
</div>
<footer class="step-footer">
<span
>{logs.length}
{#if logs.length === 1}line{:else}lines{/if}</span
>
<div class="footer-controls">
<span aria-label="Elapsed time">
{#if elapsedSeconds !== null}{elapsedSeconds}s{:else}—{/if}
</span>
<button
type="button"
class="scroll-lock"
aria-label="Auto-scroll"
aria-pressed={scrollLocked}
title={scrollLocked ? 'Auto-scroll locked' : 'Auto-scroll unlocked'}
onclick={() => (scrollLocked = !scrollLocked)}
>
{#if scrollLocked}
<LockSimpleFill aria-hidden="true" />
{:else}
<LockSimpleOpenFill aria-hidden="true" />
{/if}
</button>
</div>
</footer>
</section>
<style>
.step-log {
min-width: 0;
overflow: hidden;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-medium);
background: var(--color-surface-container-lowest);
color: var(--color-on-surface);
}
.step-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-outline-variant);
background: var(--color-surface-container);
}
h3 {
flex: 1;
min-width: 0;
margin: 0;
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
.status {
flex-shrink: 0;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-small);
background: var(--color-surface-container-high);
color: var(--color-on-surface-variant);
font-size: var(--font-size-xxs);
}
.scroll-lock {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
min-width: 0;
min-height: 0;
border: none;
border-radius: var(--radius-small);
background: transparent;
color: var(--color-on-surface-variant);
font: inherit;
line-height: 1;
box-shadow: none;
cursor: pointer;
}
.scroll-lock :global(svg) {
width: 1em;
height: 1em;
}
.scroll-lock:hover {
background: var(--color-surface-container-highest);
color: var(--color-on-surface);
}
.scroll-lock:focus-visible,
.log-viewport:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.log-viewport {
min-width: 0;
max-height: 18rem;
overflow-y: auto;
padding: var(--space-2) var(--space-3);
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xxs);
line-height: 1.6;
}
.log-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
.log-timestamp {
flex-shrink: 0;
color: var(--color-on-surface-variant);
font-variant-numeric: tabular-nums;
}
.log-message {
min-width: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.phase-message,
.empty-message {
margin: 0;
color: var(--color-on-surface-variant);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.phase-message {
margin-bottom: var(--space-2);
}
.is-error {
color: var(--color-error);
}
.footer-controls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.step-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: var(--space-1) var(--space-3);
border-top: 1px solid var(--color-outline-variant);
background: var(--color-surface-container-low);
color: var(--color-on-surface-variant);
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xxs);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,53 @@
import { render } from 'svelte/server'
import { expect, test } from 'vitest'
import StepLog from './StepLog.svelte'
test('puts status above escaped terminal output and line count, seconds and lock below it', () => {
const started = Date.parse('2026-01-02T03:04:00Z')
const { body } = render(StepLog, {
props: {
phase: {
phase: 'render',
status: 'failed',
started_at: started,
finished_at: started + 65_000,
message: '<script>Failure & details</script>'
},
logs: [
{
phase: 'render',
line_number: 1,
ts: started,
level: 'stderr',
message: '<img src=x> & output'
}
]
}
})
const header = body.match(/<header\b[^>]*>([\s\S]*?)<\/header>/)?.[1] ?? ''
const footer = body.match(/<footer\b[^>]*>([\s\S]*?)<\/footer>/)?.[1] ?? ''
const headerStart = body.indexOf('<header')
const terminalStart = body.indexOf('role="region"')
const footerStart = body.indexOf('<footer')
const terminal = body.slice(terminalStart, footerStart)
expect(headerStart).toBeGreaterThanOrEqual(0)
expect(terminalStart).toBeGreaterThan(headerStart)
expect(footerStart).toBeGreaterThan(terminalStart)
expect(header).toMatch(/<h3\b[^>]*>render<\/h3>/)
expect(header).toContain('Failed')
expect(header).not.toContain('aria-label="Auto-scroll"')
expect(terminal).toContain('&lt;script>Failure &amp; details&lt;/script>')
expect(terminal).toContain('&lt;img src=x> &amp; output')
expect(body).not.toMatch(/<(?:script|img)\b/)
expect(
footer
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
).toBe('1 line 65s')
expect(footer).toMatch(/<button\b[^>]*aria-label="Auto-scroll"/)
expect(footer).toContain('aria-pressed="true"')
expect(footer).toContain('title="Auto-scroll locked"')
})
@@ -1,146 +1,42 @@
import { expect, test } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
import PreviewLogs from './PreviewLogs.svelte'
const started = Date.parse('2026-01-02T03:04:00Z')
const phases: ProgressPhase[] = [
{
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: 'Rendered successfully'
},
{
phase: 'assets',
status: 'failed',
started_at: started + 65_000,
finished_at: started + 90_000,
message: 'Asset failed'
}
]
const logs: Record<string, ProgressLog[]> = {
render: [
{ phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' },
{
phase: 'render',
line_number: 2,
ts: started + 1000,
level: 'info',
message: 'Recorded render detail'
// This wrapper owns the labelled section and forwarding, not StepLog's controls.
test('labels the preview log section and forwards phase, output and failure details', () => {
const started = Date.parse('2026-01-02T03:04:00Z')
const { body } = render(PreviewLogs, {
props: {
status: 'failed',
errorMessage: 'Preview job failed',
phases: [
{
phase: 'render',
status: 'failed',
started_at: started,
finished_at: started + 65_000,
message: 'Renderer stopped'
}
],
logs: {
render: [
{
phase: 'render',
line_number: 1,
ts: started,
level: 'stderr',
message: 'Missing template'
}
]
}
}
],
assets: [
{
phase: 'assets',
line_number: 1,
ts: started + 65_000,
level: 'stderr',
message: 'Recorded asset error'
}
]
}
})
function props(
overrides: Partial<ComponentProps<typeof PreviewLogs>> = {}
): ComponentProps<typeof PreviewLogs> {
return { status: 'completed', phases, logs, ...overrides }
}
function text(html: string) {
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
// SSR checks delegation and initial controls, not browser scrolling, SSE, or onMount behavior.
test('logs are a labelled section with an instance-scoped heading, not a main or H1', () => {
const first = render(PreviewLogs, { props: props(), idPrefix: 'first' }).body
const second = render(PreviewLogs, { props: props(), idPrefix: 'second' }).body
const headingId = (html: string) => html.match(/<h2[^>]*id="([^"]+)"/)?.[1]
for (const html of [first, second]) {
expect(headingId(html)).toBeTruthy()
expect(html).toContain(`aria-labelledby="${headingId(html)}"`)
expect(html).toMatch(/<h2[^>]*>Preview logs<\/h2>/)
expect(html).not.toContain('<h1')
expect(html).not.toContain('<main')
}
expect(headingId(first)).not.toBe(headingId(second))
})
test.each(['waiting', 'future-state'])(
'passes %s through without inventing console status copy',
(status) => {
const html = render(PreviewLogs, { props: props({ status, phases: [], logs: {} }) }).body
expect(html).not.toContain('status-card')
expect(html).not.toContain('status-title')
expect(html).not.toContain('status-sub')
expect(text(html)).toBe('Preview logs')
}
)
test('preserves native phase groups, timing, log content and initial lock controls', () => {
const html = render(PreviewLogs, { props: props() }).body
const content = text(html)
for (const expected of [
'render',
'assets',
'Rendered successfully',
'Asset failed',
'1m 5s',
'25s',
'2 lines',
'1 lines',
'03:04:00',
'03:04:01',
'03:05:05',
'Output ready',
'Recorded render detail',
'Recorded asset error'
])
expect(content).toContain(expected)
expect(html.match(/class="log-viewport/g)).toHaveLength(2)
expect(html.match(/<button /g)).toHaveLength(2)
expect(html.match(/title="Auto-scroll locked"/g)).toHaveLength(2)
expect(html).toMatch(/class="log-row[^"]*\bis-error\b[^"]*"/)
expect(html).toContain('-log-container-render')
expect(html).toContain('-log-container-assets')
})
test.each(['failed', 'running', 'completed', 'expired'])(
'passes optional errors through with native failed-only visibility: %s',
(status) => {
const html = render(PreviewLogs, {
props: props({ status, errorMessage: '<script>Failure & details</script>' })
}).body
expect(html.includes('error-strip')).toBe(status === 'failed')
expect(html.includes('&lt;script>Failure &amp; details&lt;/script>')).toBe(status === 'failed')
expect(html).not.toContain('<script>')
}
)
test('phase and log content remain escaped through delegation', () => {
const unsafe = '<script>not executable & not hidden</script>'
const html = render(PreviewLogs, {
props: props({
phases: [{ ...phases[0], message: unsafe }],
logs: { render: [{ ...logs.render[0], message: unsafe }] }
})
}).body
expect(html.match(/&lt;script>not executable &amp; not hidden&lt;\/script>/g)).toHaveLength(2)
expect(html).not.toContain('<script>')
})
test('empty queued data does not invent phases, logs or controls', () => {
const html = render(PreviewLogs, {
props: props({ status: 'enqueued', phases: [], logs: {} })
}).body
expect(text(html)).toBe('Preview logs')
expect(html).not.toContain('class="timeline')
expect(html).not.toContain('class="log-console')
expect(html).not.toContain('<button ')
expect(html).not.toContain('error-strip')
const heading = body.match(/<h2\b[^>]*id="([^"]+)"[^>]*>Preview logs<\/h2>/)
expect(heading).not.toBeNull()
expect(body).toContain(`aria-labelledby="${heading?.[1]}"`)
expect(body).toContain('Preview job failed')
expect(body).toMatch(/<h3\b[^>]*>render<\/h3>/)
expect(body).toContain('Renderer stopped')
expect(body).toContain('Missing template')
})
@@ -1,13 +1,7 @@
<script lang="ts">
import { tick } from 'svelte'
import StepLog from '#lib/components/StepLog.svelte'
import { latestPhases, type ProgressPhase, type ProgressLog } from '#lib/components/progress.js'
import { formatDuration } from './duration'
import CheckCircleFill from 'virtual:icons/ph/check-circle-fill'
import XCircleFill from 'virtual:icons/ph/x-circle-fill'
import CircleNotchBold from 'virtual:icons/ph/circle-notch-bold'
import MinusCircle from 'virtual:icons/ph/minus-circle'
import LockSimpleFill from 'virtual:icons/ph/lock-simple-fill'
import LockSimpleOpenFill from 'virtual:icons/ph/lock-simple-open-fill'
interface Props {
status: string
@@ -17,39 +11,7 @@
}
let { status, errorMessage = '', phases, logs }: Props = $props()
const consoleId = $props.id()
let scrollLocks = $state<Record<string, boolean>>({})
const timelinePhases = $derived(latestPhases(phases))
function handleScroll(phaseName: string, event: Event) {
const el = event.currentTarget as HTMLDivElement
if (!el) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10
const currentLock = scrollLocks[phaseName] ?? true
if (isAtBottom && !currentLock) {
scrollLocks[phaseName] = true
} else if (!isAtBottom && currentLock) {
scrollLocks[phaseName] = false
}
}
let logLengths = $derived(
Object.fromEntries(Object.entries(logs).map(([phase, list]) => [phase, list.length]))
)
$effect(() => {
for (const [phase, _length] of Object.entries(logLengths)) {
const isLocked = scrollLocks[phase] ?? true
if (isLocked) {
tick().then(() => {
const el = document.getElementById(`${consoleId}-log-container-${phase}`)
if (el) el.scrollTop = el.scrollHeight
})
}
}
})
</script>
{#if errorMessage && status === 'failed'}
@@ -62,95 +24,7 @@
{#if timelinePhases.length > 0}
<div class="timeline">
{#each timelinePhases as phase (phase.phase)}
{@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0}
<div class="phase">
<div class="phase-row">
<span class="phase-icon-wrap status-{phase.status}">
{#if phase.status === 'completed'}
<CheckCircleFill class="phase-icon" />
{:else if phase.status === 'running'}
<CircleNotchBold class="phase-icon spin" />
{:else if phase.status === 'failed'}
<XCircleFill class="phase-icon" />
{:else if phase.status === 'skipped'}
<MinusCircle class="phase-icon" />
{:else}
<span class="phase-dot"></span>
{/if}
</span>
<span class="phase-name">{phase.phase}</span>
{#if phase.message}
<span class="phase-message" class:is-error={phase.status === 'failed'}>
{phase.message}
</span>
{/if}
<span class="phase-aside">
{#if phase.started_at}
<span class="phase-time">
{formatDuration((phase.finished_at || Date.now()) - phase.started_at)}
</span>
{/if}
</span>
</div>
{#if hasLogs}
<div class="log-console">
<div class="log-header">
<span>{logs[phase.phase]?.length || 0} lines</span>
<div class="log-header-right">
{#if phase.started_at}
<span class="log-duration">
{formatDuration((phase.finished_at || Date.now()) - phase.started_at)}
</span>
{/if}
<button
type="button"
class="scroll-lock-btn"
onclick={() => {
const current = scrollLocks[phase.phase] ?? true
const next = !current
scrollLocks[phase.phase] = next
if (next) {
tick().then(() => {
const el = document.getElementById(
`${consoleId}-log-container-${phase.phase}`
)
if (el) el.scrollTop = el.scrollHeight
})
}
}}
title={(scrollLocks[phase.phase] ?? true)
? 'Auto-scroll locked'
: 'Auto-scroll unlocked'}
>
{#if scrollLocks[phase.phase] ?? true}
<LockSimpleFill class="lock-icon" />
{:else}
<LockSimpleOpenFill class="lock-icon" />
{/if}
</button>
</div>
</div>
<div
class="log-viewport"
id="{consoleId}-log-container-{phase.phase}"
onscroll={(e) => handleScroll(phase.phase, e)}
>
{#each logs[phase.phase] ?? [] as log (log.line_number)}
<div
class="log-row"
class:is-error={log.level === 'error' || log.level === 'stderr'}
>
<span class="log-timestamp">
{new Date(log.ts).toISOString().split('T')[1].slice(0, -5)}
</span>
<span class="log-body">{log.message}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<StepLog {phase} logs={logs[phase.phase] ?? []} />
{/each}
</div>
{/if}
@@ -178,195 +52,6 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.phase {
display: flex;
flex-direction: column;
gap: 2px;
}
.phase-row {
padding: var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-3);
}
.phase-icon-wrap {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phase-icon-wrap :global(.phase-icon) {
font-size: 18px;
}
.phase-icon-wrap.status-completed {
color: var(--color-primary);
}
.phase-icon-wrap.status-running {
color: var(--color-secondary);
}
.phase-icon-wrap.status-failed {
color: var(--color-error);
}
.phase-icon-wrap.status-skipped {
color: var(--color-on-surface-variant);
}
.phase-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid var(--color-outline-variant);
}
.phase-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface);
flex-shrink: 0;
}
.phase-message {
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.phase-message.is-error {
color: var(--color-error);
}
.phase-aside {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.phase-time {
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
min-width: 36px;
text-align: right;
}
.log-console {
margin: 4px 0 0 0;
background-color: var(--color-surface);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-small);
overflow: hidden;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: 4px var(--space-3);
background-color: var(--color-surface-container-low);
border-bottom: 1px solid var(--color-outline-variant);
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface-variant);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.log-duration {
color: var(--color-on-surface);
}
.log-header-right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.scroll-lock-btn {
background: none;
border: none;
padding: 2px;
margin: 0;
color: var(--color-on-surface-variant);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-small);
transition:
background-color 0.15s,
color 0.15s;
}
.scroll-lock-btn:hover {
background-color: var(--color-surface-container-highest);
color: var(--color-on-surface);
}
.scroll-lock-btn :global(.lock-icon) {
font-size: 14px;
}
.log-viewport {
padding: var(--space-2) var(--space-3);
max-height: 260px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
line-height: 1.6;
color: var(--color-on-surface-variant);
}
.log-row {
display: flex;
gap: var(--space-3);
}
.log-row.is-error {
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
margin: 0 calc(-1 * var(--space-3));
padding: 0 var(--space-3);
}
.log-timestamp {
color: var(--color-outline);
flex-shrink: 0;
opacity: 0.7;
}
.log-body {
white-space: pre-wrap;
word-break: break-all;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -1,22 +1,15 @@
import { describe, expect, test } from 'vitest'
import { expect, test } from 'vitest'
import { render } from 'svelte/server'
import WorkflowConsole from './WorkflowConsole.svelte'
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
import type { ProgressPhase } from '#lib/components/progress.js'
const started = Date.parse('2026-01-02T03:04:00Z')
const phases: ProgressPhase[] = [
{
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: 'Rendered successfully'
}
]
const logs: Record<string, ProgressLog[]> = {
render: [
{ phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' }
]
const phase: ProgressPhase = {
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: 'Retry succeeded'
}
function text(html: string) {
@@ -26,147 +19,43 @@ function text(html: string) {
.trim()
}
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')
expect(result.body).not.toContain('status-sub')
test('renders the latest attempt with its phase logs and keeps logless steps visible', () => {
const { body } = render(WorkflowConsole, {
props: {
status: 'completed',
phases: [
{ ...phase, status: 'failed', message: 'First attempt failed' },
{ ...phase, phase: 'assets', message: 'Assets ready' },
phase
],
logs: {
render: [
{ phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' }
]
}
}
})
test.each(['failed', 'running', 'completed', 'expired'])(
'shows the existing error strip only for failed status: %s',
(status) => {
const result = render(WorkflowConsole, {
props: { status, phases: [], logs: {}, errorMessage: 'Render failed' }
})
expect(result.body.includes('error-strip')).toBe(status === 'failed')
expect(text(result.body).includes('Render failed')).toBe(status === 'failed')
}
)
const headings = [...body.matchAll(/<h3\b[^>]*>([\s\S]*?)<\/h3>/g)].map((match) => text(match[1]))
expect(headings).toEqual(['render', 'assets'])
expect(text(body)).not.toContain('First attempt failed')
expect(text(body)).toContain('Retry succeeded')
expect(text(body)).toContain('Output ready')
expect(text(body)).toContain('Assets ready')
expect(body).toContain('aria-label="assets output"')
expect(text(body)).toContain('No output recorded.')
})
describe('preview phases and logs', () => {
test('renders preview logs with auto-scroll locked by default', () => {
const result = render(WorkflowConsole, {
props: {
status: 'completed',
phases,
logs
}
})
expect(result.body).not.toContain('Completed')
expect(text(result.body)).toContain('1 lines')
expect(result.body).toContain('Auto-scroll locked')
})
test('shows an escaped job error only when the preview has failed', () => {
const props = {
phases: [],
logs: {},
errorMessage: '<script>Render failed & stopped</script>'
}
const failed = render(WorkflowConsole, { props: { ...props, status: 'failed' } }).body
const running = render(WorkflowConsole, { props: { ...props, status: 'running' } }).body
test('omits the timeline without phases and log panels without logs', () => {
const empty = render(WorkflowConsole, {
props: { status: 'enqueued', phases: [], logs: {} }
})
expect(empty.body).not.toContain('class="timeline')
const withoutLogs = render(WorkflowConsole, {
props: { status: 'completed', phases, logs: {} }
})
expect(withoutLogs.body).toContain('class="timeline')
expect(withoutLogs.body).not.toContain('class="log-console')
})
test('renders one timeline row for a retried phase using its latest state', () => {
const result = render(WorkflowConsole, {
props: {
status: 'completed',
phases: [
{ ...phases[0], status: 'failed', message: 'First attempt failed' },
{ ...phases[0], message: 'Retry succeeded' }
],
logs
}
})
expect(result.body.match(/class="phase-name\b/g)).toHaveLength(1)
expect(text(result.body)).toContain('Retry succeeded')
expect(text(result.body)).not.toContain('First attempt failed')
})
test('scopes log viewport IDs to the rendered console', () => {
const props = { status: 'completed', phases, logs }
const first = render(WorkflowConsole, { props, idPrefix: 'first' })
const second = render(WorkflowConsole, { props, idPrefix: 'second' })
const viewportId = (body: string) => body.match(/id="([^"]+-log-container-render)"/)?.[1]
expect(viewportId(first.body)).toBeTruthy()
expect(viewportId(second.body)).toBeTruthy()
expect(viewportId(first.body)).not.toBe(viewportId(second.body))
})
test('shows each phase duration rather than a total preview time', () => {
const result = render(WorkflowConsole, {
props: {
status: 'completed',
phases: [
phases[0],
{
...phases[0],
phase: 'publish',
started_at: started + 65_000,
finished_at: started + 90_000
}
],
logs: {}
}
})
expect(result.body).not.toContain('total time')
expect(text(result.body)).toContain('25s')
})
test('retains phase statuses and stderr/error highlighting', () => {
const items = ['running', 'failed', 'skipped', 'waiting'].map((status) => ({
...phases[0],
phase: status,
status
}))
const result = render(WorkflowConsole, {
props: {
status: 'failed',
phases: items,
logs: {
failed: [
{ ...logs.render[0], phase: 'failed', level: 'stderr', message: 'Failure details' }
]
}
}
})
for (const { status } of items) expect(result.body).toContain(`status-${status}`)
expect(result.body).toContain('spin')
expect(result.body).toContain('is-error')
expect(text(result.body)).toContain('Failure details')
})
test.each(['stdout', 'info', 'stderr', 'error'])(
'highlights only error log levels: %s',
(level) => {
const result = render(WorkflowConsole, {
props: {
status: 'completed',
phases,
logs: { render: [{ ...logs.render[0], level }] }
}
})
const rowClasses = result.body.match(/class="(log-row[^"]*)"/)?.[1]
expect(rowClasses).toBeTruthy()
expect(rowClasses?.includes('is-error')).toBe(level === 'stderr' || level === 'error')
}
)
test('escapes preview errors, phase messages, and logs', () => {
const unsafe = '<script>not executable</script>'
const result = render(WorkflowConsole, {
props: {
status: 'failed',
errorMessage: unsafe,
phases: [{ ...phases[0], message: unsafe }],
logs: { render: [{ ...logs.render[0], message: unsafe }] }
}
})
expect(result.body).not.toContain('<script>')
expect(result.body).toContain('&lt;script>')
})
expect(failed).toContain('&lt;script>Render failed &amp; stopped&lt;/script>')
expect(failed).not.toContain('<script>')
expect(text(running)).not.toContain('Render failed')
})
@@ -1,14 +1,7 @@
<script lang="ts">
import { tick } from 'svelte'
import type { PiecesDiff } from '@luzzle/core'
import { latestPhases, type PhaseProgress, type PhaseLog } from './report.js'
import CaretRight from 'virtual:icons/ph/caret-right'
import CheckCircleFill from 'virtual:icons/ph/check-circle-fill'
import XCircleFill from 'virtual:icons/ph/x-circle-fill'
import CircleNotchBold from 'virtual:icons/ph/circle-notch-bold'
import MinusCircle from 'virtual:icons/ph/minus-circle'
import LockSimpleFill from 'virtual:icons/ph/lock-simple-fill'
import LockSimpleOpenFill from 'virtual:icons/ph/lock-simple-open-fill'
import StepLog from '#lib/components/StepLog.svelte'
type Props = {
schemas?: PiecesDiff['schemas'] | null
@@ -20,71 +13,14 @@
let { schemas = null, schemasAlwaysIncluded = false, phases = [], logs = {} }: Props = $props()
const schemaKinds = ['added', 'updated', 'pruned'] as const
let hasSchemaChanges = $derived(
const hasSchemaChanges = $derived(
schemas !== null && schemaKinds.some((kind) => schemas[kind].length > 0)
)
let timelinePhases = $derived(latestPhases([...phases]))
let overallDuration = $derived.by(() => {
if (phases.length === 0 || !phases[0].started_at) return 0
return (phases[phases.length - 1].finished_at || Date.now()) - phases[0].started_at
})
let open = $state(false)
let disclosure = $state<HTMLDetailsElement>()
let scrollLocks = $state<Record<string, boolean>>({})
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Imperative DOM registry, not rendered state.
const viewports = new Map<string, HTMLDivElement>()
function viewport(node: HTMLDivElement, phase: string) {
viewports.set(phase, node)
return {
destroy() {
if (viewports.get(phase) === node) viewports.delete(phase)
}
}
}
function handleScroll(phase: string, event: Event) {
const node = event.currentTarget as HTMLDivElement
if (!disclosure?.open || !node.clientHeight) return
scrollLocks[phase] = node.scrollHeight - node.scrollTop - node.clientHeight < 10
}
$effect(() => {
if (!open || !disclosure) return
const lockedPhases = timelinePhases
.filter((phase) => logs[phase.phase]?.length && (scrollLocks[phase.phase] ?? true))
.map((phase) => phase.phase)
let canceled = false
// Wait for new rows and local viewport actions before measuring. Opening an
// already completed run follows the same path as receiving more log rows.
tick().then(() => {
if (canceled || !disclosure?.open) return
for (const phase of lockedPhases) {
const node = viewports.get(phase)
if (node?.isConnected && (scrollLocks[phase] ?? true)) {
node.scrollTop = node.scrollHeight
}
}
})
return () => {
canceled = true
}
})
function formatDuration(ms: number) {
if (ms < 0) return '0s'
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
}
const timelinePhases = $derived(latestPhases([...phases]))
</script>
{#if phases.length > 0 || hasSchemaChanges}
<details class="disclosure run-details" bind:this={disclosure} bind:open>
<summary>
<span class="disclosure-icon" aria-hidden="true"><CaretRight /></span> Details
</summary>
<div class="run-details">
{#if hasSchemaChanges && schemas}
<section class="schema-changes">
<h2>
@@ -108,123 +44,30 @@
</ul>
</section>
{/if}
{#if phases.length > 0}
<p class="details-meta">{timelinePhases.length} stages · {formatDuration(overallDuration)}</p>
<div class="timeline">
{#each timelinePhases as phase (phase.phase)}
<div class="phase">
<div class="phase-row">
<span class="phase-icon status-{phase.status}" aria-hidden="true">
{#if phase.status === 'completed'}
<CheckCircleFill />
{:else if phase.status === 'running'}
<span class="spin"><CircleNotchBold /></span>
{:else if phase.status === 'failed'}
<XCircleFill />
{:else if phase.status === 'skipped'}
<MinusCircle />
{:else}
<span class="phase-dot"></span>
{/if}
</span>
<span class="phase-name">{phase.phase}</span>
{#if phase.message}<span class="phase-message">{phase.message}</span>{/if}
</div>
{#if logs[phase.phase]?.length}
<div class="log-console">
<div class="log-header">
<span>{logs[phase.phase].length} lines</span>
<button
type="button"
class="scroll-lock"
onclick={() => {
scrollLocks[phase.phase] = !(scrollLocks[phase.phase] ?? true)
}}
aria-pressed={scrollLocks[phase.phase] ?? true}
title={(scrollLocks[phase.phase] ?? true)
? 'Auto-scroll locked'
: 'Auto-scroll unlocked'}
>
{#if scrollLocks[phase.phase] ?? true}<LockSimpleFill
/>{:else}<LockSimpleOpenFill />{/if}
</button>
</div>
<div
class="log-viewport"
use:viewport={phase.phase}
onscroll={(event) => handleScroll(phase.phase, event)}
>
{#each logs[phase.phase] as log (JSON.stringify([log.job_id, log.line_number]))}
<div
class="log-row"
class:is-error={log.level === 'error' || log.level === 'stderr'}
>
<span class="log-timestamp"
>{new Date(log.ts).toISOString().split('T')[1].slice(0, -5)}</span
>
<span>{log.message}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
{#if timelinePhases.length > 0}
<section>
<h2>Run logs</h2>
<div class="timeline">
{#each timelinePhases as phase (phase.phase)}
<StepLog {phase} logs={logs[phase.phase] ?? []} />
{/each}
</div>
</section>
{/if}
</details>
</div>
{/if}
<style>
h2 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-xs);
}
.scroll-lock {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2);
border: none;
background: none;
font: inherit;
color: var(--color-on-surface-variant);
cursor: pointer;
}
button:focus-visible,
summary:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.disclosure-icon {
display: inline-flex;
flex-shrink: 0;
}
.is-error {
color: var(--color-error);
}
.disclosure > summary {
display: flex;
align-items: center;
gap: var(--space-1);
width: fit-content;
min-height: 44px;
list-style: none;
color: var(--color-on-surface-variant);
cursor: pointer;
}
.disclosure > summary::-webkit-details-marker {
display: none;
}
.disclosure[open] > summary > .disclosure-icon {
transform: rotate(90deg);
}
.run-details {
margin-top: var(--space-4);
border-top: 1px solid var(--color-outline-variant);
margin-top: var(--space-6);
}
h2 {
margin: 0 0 var(--space-3);
font-size: var(--font-size-normal);
font-weight: var(--font-weight-bold);
}
.schema-changes {
margin: var(--space-3) 0;
margin-bottom: var(--space-5);
}
.schema-changes ul {
list-style: none;
@@ -232,97 +75,21 @@
margin: 0;
}
.schema-changes li {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-2) 0;
}
.details-meta {
color: var(--color-on-surface-variant);
font-size: var(--font-size-xxs);
}
.badge {
font-size: var(--font-size-xxs);
padding: 2px var(--space-1);
padding: var(--space-1) var(--space-2);
background: var(--color-surface-container-high);
border-radius: var(--radius-small);
color: var(--color-on-surface-variant);
}
.timeline {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.phase-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.phase-icon {
display: inline-flex;
width: 16px;
color: var(--color-on-surface-variant);
}
.status-completed {
color: var(--color-primary);
}
.status-running {
color: var(--color-secondary);
}
.status-failed {
color: var(--color-error);
}
.phase-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid var(--color-outline-variant);
}
.phase-name {
font-family: var(--font-mono-name), monospace;
}
.phase-message {
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
overflow-wrap: anywhere;
}
.log-console {
margin-top: var(--space-2);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-small);
background: var(--color-surface-container-lowest);
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--color-outline-variant);
color: var(--color-on-surface-variant);
font-size: var(--font-size-xxs);
}
.log-viewport {
max-height: 260px;
overflow-y: auto;
padding: var(--space-2);
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xxs);
}
.log-row {
display: flex;
gap: var(--space-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.log-timestamp {
flex-shrink: 0;
color: var(--color-on-surface-variant);
}
.spin {
display: inline-flex;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
display: grid;
gap: var(--space-4);
min-width: 0;
}
</style>
@@ -1,23 +1,19 @@
import type { PiecesDiff } from '@luzzle/core'
import type { ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { expect, test, vi } from 'vitest'
import { expect, test } from 'vitest'
import PublishDetails from './PublishDetails.svelte'
import type { PhaseProgress, PhaseLog } from './report.js'
type Props = ComponentProps<typeof PublishDetails>
const started = Date.parse('2026-01-02T03:04:00Z')
function phase(
overrides: Partial<PhaseProgress & { job_id: string }> = {}
): PhaseProgress & { job_id: string } {
function phase(overrides: Partial<PhaseProgress> = {}): PhaseProgress {
return {
job_id: 'publish-job',
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: null,
message: 'Rendered successfully',
...overrides
}
}
@@ -34,8 +30,8 @@ function log(overrides: Partial<PhaseLog> = {}): PhaseLog {
}
}
function details(props: Props = {}) {
return render(PublishDetails, { props }).body.replace(/<!--[\s\S]*?-->/g, '')
function details(props: Props) {
return render(PublishDetails, { props }).body
}
function text(html: string) {
@@ -45,254 +41,53 @@ function text(html: string) {
.trim()
}
function rows(html: string) {
return [...html.matchAll(/<div\b[^>]*class="log-row\b[^"]*"[^>]*>([\s\S]*?)<\/div>/g)].map(
(match) => match[0]
)
}
const emptySchemas: PiecesDiff['schemas'] = { added: [], updated: [], pruned: [] }
test('default props have no disclosure or empty-state copy', () => {
expect(details().trim()).toBe('')
})
test('empty schema lists and no phases omit Details even with the always-included policy', () => {
for (const schemas of [null, emptySchemas]) {
expect(details({ schemas, schemasAlwaysIncluded: true, phases: [], logs: {} }).trim()).toBe('')
}
})
test('orphan logs alone do not invent a phase or a disclosure', () => {
expect(details({ logs: { render: [log()] } }).trim()).toBe('')
})
test('phase-only Details is a native disclosure, initially closed, with an icon and summary', () => {
const output = details({ phases: [phase()] })
const disclosure = output.match(/<details\b[^>]*>/)?.[0]
expect(disclosure).toBeDefined()
expect(disclosure).not.toMatch(/\sopen(?:\s|=|>)/)
expect(output.match(/<details\b/g)).toHaveLength(1)
const summary = output.match(/<summary\b[^>]*>([\s\S]*?)<\/summary>/)?.[1] ?? ''
expect(text(summary)).toBe('Details')
expect(summary).toMatch(/<svg\b/)
expect(text(output)).toContain('1 stages · 1m 5s')
expect(text(output)).toContain('render')
expect(output).not.toMatch(/<(?:section|h2|button)\b/)
})
test('schema-only changes render New, Updated and Removed with separated names', () => {
test('schema changes and step output are visible without opening a disclosure', () => {
const output = details({
schemas: { added: ['books'], updated: ['films'], pruned: ['notes'] }
schemas: { added: ['books'], updated: ['films'], pruned: ['notes'] },
schemasAlwaysIncluded: true,
phases: [phase()],
logs: { render: [log()] }
})
expect(output).toMatch(/<details\b/)
expect(output).toMatch(/<h2\b[^>]*>Piece types<\/h2>/)
expect(output).not.toMatch(/<(?:details|summary)\b/)
expect(text(output)).toContain('Piece types · always included')
const entries = [...output.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/g)].map((match) =>
text(match[1])
)
expect(entries).toEqual(['New books', 'Updated films', 'Removed notes'])
expect(text(output)).not.toContain('always included')
expect(text(output)).not.toContain('stages')
expect(output).not.toMatch(/<button\b/)
expect(text(output)).toContain('Run logs')
expect(text(output)).toContain('render')
expect(text(output)).toContain('Rendered successfully')
expect(text(output)).toContain('Output ready')
})
test.each(['added', 'updated', 'pruned'] as const)(
'a lone %s schema makes Details visible without phases',
(kind) => {
const output = details({ schemas: { ...emptySchemas, [kind]: ['only-type'] } })
expect(output).toMatch(/<details\b/)
expect(output.match(/<li\b/g)).toHaveLength(1)
expect(text(output)).toContain('only-type')
}
)
test('always-included policy stays in the schema heading with proper whitespace', () => {
const output = details({
schemas: { ...emptySchemas, updated: ['books'] },
schemasAlwaysIncluded: true,
phases: [phase()]
})
const heading = output.match(/<h2\b[^>]*>([\s\S]*?)<\/h2>/)?.[1] ?? ''
expect(text(heading)).toBe('Piece types · always included')
expect(output.indexOf('</h2>')).toBeLessThan(output.indexOf('stages'))
expect(output.match(/<details\b/g)).toHaveLength(1)
})
test('unchanged schemas do not add a heading or policy to phase details', () => {
const output = details({ schemas: emptySchemas, schemasAlwaysIncluded: true, phases: [phase()] })
expect(output).not.toMatch(/<h2\b/)
expect(text(output)).not.toContain('Piece types')
expect(text(output)).not.toContain('always included')
expect(text(output)).toContain('1 stages · 1m 5s')
})
test('latest attempts replace earlier phase states without changing first-seen phase order', () => {
test('latest phase attempts replace old messages while retaining logs from both jobs', () => {
const output = details({
phases: [
phase({ status: 'failed', finished_at: started + 10_000, message: 'Old render failure' }),
phase({
phase: 'assets',
started_at: started + 10_000,
finished_at: started + 20_000,
message: 'Assets ready'
}),
phase({
job_id: 'retry-job',
started_at: started + 20_000,
finished_at: started + 90_000,
message: 'Retry succeeded'
})
]
})
const content = text(output)
expect(content).toContain('2 stages · 1m 30s')
expect(content).not.toContain('Old render failure')
expect(content.match(/render/g)).toHaveLength(1)
expect(content.match(/assets/g)).toHaveLength(1)
expect(content.indexOf('render')).toBeLessThan(content.indexOf('assets'))
expect(content).toContain('Retry succeeded')
expect(content).toContain('Assets ready')
})
test.each([
{ finished: started + 59_999, duration: '59s' },
{ finished: started + 60_000, duration: '1m 0s' },
{ finished: started - 1000, duration: '0s' }
])('metadata formats total duration as $duration', ({ finished, duration }) => {
expect(text(details({ phases: [phase({ finished_at: finished })] }))).toContain(
`1 stages · ${duration}`
)
})
test('missing start time yields zero elapsed duration', () => {
expect(text(details({ phases: [phase({ started_at: 0 })] }))).toContain('1 stages · 0s')
})
test('unfinished phase duration uses the current time when rendered', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(started + 125_000)
try {
expect(text(details({ phases: [phase({ status: 'running', finished_at: null })] }))).toContain(
'1 stages · 2m 5s'
)
} finally {
now.mockRestore()
}
})
test.each(['completed', 'running', 'failed', 'skipped', 'waiting', 'future-state'])(
'phase status %s preserves its message and known-status icon',
(status) => {
const output = details({ phases: [phase({ status, message: `Message for ${status}` })] })
expect(text(output)).toContain(`Message for ${status}`)
// The summary always has a caret; known phase statuses add their own icon.
const known = ['completed', 'running', 'failed', 'skipped'].includes(status)
expect(output.match(/<svg\b/g)).toHaveLength(known ? 2 : 1)
}
)
test('logs render UTC timestamps, line counts, error emphasis and a native locked button', () => {
const output = details({
phases: [phase()],
phase({ status: 'failed', message: 'First attempt failed' }),
phase({ phase: 'assets', message: 'Assets ready' }),
phase({ message: 'Retry succeeded' })
],
logs: {
render: [
log(),
log({ line_number: 2, ts: started + 1000, level: 'error', message: 'Error detail' }),
log({ line_number: 3, ts: started + 2000, level: 'stderr', message: 'Stderr detail' }),
log({ line_number: 4, ts: started + 3000, level: 'info', message: 'Info detail' })
]
}
})
const content = text(output)
for (const expected of [
'4 lines',
'03:04:00',
'03:04:01',
'03:04:02',
'03:04:03',
'Output ready',
'Error detail',
'Stderr detail',
'Info detail'
])
expect(content).toContain(expected)
const button = output.match(/<button\b([^>]*)>([\s\S]*?)<\/button>/)
expect(button).not.toBeNull()
expect(button?.[1]).toContain('type="button"')
expect(button?.[1]).toContain('title="Auto-scroll locked"')
expect(button?.[1]).toContain('aria-pressed="true"')
expect(button?.[2]).toMatch(/<svg\b/)
const renderedRows = rows(output)
expect(renderedRows).toHaveLength(4)
expect(renderedRows[0]).not.toContain('is-error')
expect(renderedRows[1]).toContain('is-error')
expect(renderedRows[2]).toContain('is-error')
expect(renderedRows[3]).not.toContain('is-error')
})
test('logless phases do not invent consoles, buttons, or line-count copy', () => {
const output = details({ phases: [phase(), phase({ phase: 'assets' })], logs: { render: [] } })
expect(text(output)).toContain('2 stages')
expect(text(output)).not.toContain('lines')
expect(output).not.toMatch(/<button\b/)
})
test('equal line numbers from different jobs and phases remain distinct in their supplied order', () => {
const output = details({
phases: [phase(), phase({ phase: 'assets' })],
logs: {
render: [
log({ job_id: 'job:1', message: 'First job line one' }),
log({ job_id: 'job:2', message: 'Retry job line one' }),
log({ job_id: 'job:2', line_number: 2, message: 'Retry job line two' })
log({ message: 'First job output' }),
log({ job_id: 'retry-job', message: 'Retry job output' })
],
assets: [log({ job_id: 'job:1', phase: 'assets', message: 'Assets line one' })],
orphan: [log({ phase: 'orphan', message: 'Not a visible phase' })]
assets: [log({ phase: 'assets', message: 'Asset output' })]
}
})
const content = text(output)
expect(rows(output)).toHaveLength(4)
expect(content).toContain('3 lines')
expect(content).toContain('1 lines')
const messages = [
'First job line one',
'Retry job line one',
'Retry job line two',
'Assets line one'
]
for (const message of messages) expect(content.split(message)).toHaveLength(2)
for (let index = 1; index < messages.length; index++) {
expect(content.indexOf(messages[index - 1])).toBeLessThan(content.indexOf(messages[index]))
}
expect(content).not.toContain('Not a visible phase')
expect(output.match(/<button\b/g)).toHaveLength(2)
})
test('schema names, phase names, status messages and log messages remain escaped data', () => {
const unsafe = '<script>not executable & still visible</script>'
const phaseName = '<img src=x> & phase'
const output = details({
schemas: { added: [unsafe], updated: [], pruned: [] },
phases: [phase({ phase: phaseName, status: 'failed', message: unsafe })],
logs: { [phaseName]: [log({ phase: phaseName, level: 'error', message: unsafe })] }
})
expect(output).not.toMatch(/<(?:script|img)\b/)
expect(output.match(/&lt;script>not executable &amp; still visible&lt;\/script>/g)).toHaveLength(
3
const headings = [...output.matchAll(/<h3\b[^>]*>([\s\S]*?)<\/h3>/g)].map((match) =>
text(match[1])
)
expect(output).toContain('&lt;img src=x> &amp; phase')
expect(headings).toEqual(['render', 'assets'])
expect(text(output)).not.toContain('First attempt failed')
expect(text(output)).toContain('Retry succeeded')
expect(text(output)).toContain('Assets ready')
const content = text(output)
expect(content).toContain('First job output')
expect(content).toContain('Retry job output')
expect(content).toContain('Asset output')
expect(content.indexOf('First job output')).toBeLessThan(content.indexOf('Retry job output'))
expect(content.indexOf('Retry job output')).toBeLessThan(content.indexOf('Asset output'))
})
test('readonly parent arrays and DTO extra properties are accepted without input mutation', () => {
const phases = Object.freeze([Object.freeze(phase({ job_id: 'parent-job' }))])
const renderLogs = Object.freeze([Object.freeze(log({ job_id: 'parent-job' }))])
const logs = Object.freeze({ render: renderLogs })
const output = details({ phases, logs })
expect(text(output)).toContain('Output ready')
expect(phases[0].job_id).toBe('parent-job')
expect(logs.render).toBe(renderLogs)
})
// SSR covers rendering and initial controls, not DOM identity across client updates
// or browser scrolling. Manually review disclosure/keyboard toggles, opening after
// completion, locked appends, manual unlock/relock, button-off position preservation,
// independent phases/instances, and viewport cleanup when logs or Details disappear.
@@ -120,17 +120,6 @@ function expectEditLink(output: string, file: string, href: string) {
expect(editors[0][2]).toMatch(/<svg\b/)
}
function disclosure(output: string, summary: string) {
const details = [...withoutComments(output).matchAll(/<details\b([^>]*)>([\s\S]*?)<\/details>/g)]
const match = details.find((item) => {
const label = item[2].match(/<summary\b[^>]*>([\s\S]*?)<\/summary>/)?.[1]
return label !== undefined && text(label).trim() === summary
})
expect(match, `native ${summary} disclosure`).toBeDefined()
expect(match![1]).not.toMatch(/\bopen(?:\s|=|$)/)
return match![2]
}
function expectHeaderChecksOnly(output: string) {
expect(buttons(output).map((button) => text(button[2]).trim())).toEqual(['Check', 'Sync & Check'])
expect(withoutComments(output)).not.toMatch(/<footer\b/)
@@ -233,35 +222,7 @@ test('pruned pieces remain plain text instead of linking to Published or source'
expect(output).not.toContain(`/admin/piece/${file}/`)
})
test('partial reports retain one timeline row for the latest attempt and count stages inside collapsed Details', () => {
const phase = {
job_id: 'publish-id',
phase: 'cdn.sync',
started_at: 1000,
finished_at: 2000,
message: null
}
const output = html({
phases: [
{ ...phase, status: 'failed', message: 'Earlier CDN attempt failed' },
{
...phase,
status: 'completed',
started_at: 2000,
finished_at: 3000,
message: 'Latest CDN attempt completed'
}
]
})
const details = disclosure(output, 'Details')
expect(output).toContain('Published with errors')
expect(text(details).match(/cdn\.sync/g)).toHaveLength(1)
expect(text(details)).toContain('1 stages')
expect(text(details)).toContain('Latest CDN attempt completed')
expect(output).not.toContain('Earlier CDN attempt failed')
})
test('schemas and phase logs stay inside collapsed native Details', () => {
test('schema changes and phase logs stay visible on the publish page', () => {
const output = html({
phases: [
{
@@ -285,27 +246,11 @@ test('schemas and phase logs stay inside collapsed native Details', () => {
}
]
})
const details = disclosure(output, 'Details')
expect(details).toContain('book.schema.json')
expect(output).not.toMatch(/<(?:details|summary)\b/)
expect(text(output).match(/book\.schema\.json/g)).toHaveLength(1)
expect(details).toContain('CDN assets synchronized')
expect(text(output).match(/CDN assets synchronized/g)).toHaveLength(1)
})
test.each([
{ kind: 'added', badge: 'New' },
{ kind: 'updated', badge: 'Updated' },
{ kind: 'pruned', badge: 'Removed' }
] as const)('schema $kind changes keep their $badge badge inside Details', ({ kind, badge }) => {
const name = `${kind}.schema.json`
const output = html({ schemas: { added: [], updated: [], pruned: [], [kind]: [name] } })
const details = disclosure(output, 'Details')
const rows = [...withoutComments(details).matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/g)]
const schemaRows = rows.filter((row) => text(row[1]).includes(name))
expect(schemaRows).toHaveLength(1)
expect(text(schemaRows[0][1]).trim().replace(/\s+/g, ' ')).toBe(`${badge} ${name}`)
})
test('local and remote checks are separate header actions without an Options menu or result checkboxes', () => {
const output = html()
expectHeaderChecksOnly(output)
@@ -382,7 +327,7 @@ test.each(['running', 'waiting'])(
)
test.each(['partial', 'successful'])(
'a resumed audit preserves the %s publication’s collapsed Details instead of audit progress',
'a resumed audit preserves the %s publication’s logs instead of audit progress',
(result) => {
const resumedAudit = audit('running')
resumedAudit.phases = [
@@ -431,12 +376,10 @@ test.each(['partial', 'successful'])(
],
audit: resumedAudit
})
const details = disclosure(output, 'Details')
expect(text(details)).toContain('1 stages')
expect(text(details)).toContain('cdn.sync')
expect(text(details)).toContain('Publication CDN sync completed')
expect(text(details)).toContain('Published assets synchronized')
expect(text(details)).toContain('book.schema.json')
expect(text(output)).toContain('cdn.sync')
expect(text(output)).toContain('Publication CDN sync completed')
expect(text(output)).toContain('Published assets synchronized')
expect(text(output)).toContain('book.schema.json')
expect(text(output).match(/Published assets synchronized/g)).toHaveLength(1)
expect(text(output)).not.toContain('archive.diff')
expect(text(output)).not.toContain('Audit is comparing changes')
+4
View File
@@ -52,6 +52,10 @@ Schema changes also apply globally. A renamed file looks like a removal and an
addition: the old public version can disappear before you select its replacement.
Check alone does not change the public site.
Publish and Preview show each step's logs directly. The header contains the step
name and status; the footer shows line count, elapsed seconds and a compact auto-scroll lock.
Scroll up to stop following new output, or lock auto-scroll to return to the latest lines.
Source and text-asset editors default to Standard keybindings. Toggle **Vim mode**
in the piece editor's **More** menu; the browser remembers your choice.
The piece editor's bottom bar shows the active Vim mode on the left and publication