[web] unify admin step logs #204
No files matched your search
@@ -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.
|
||||
@@ -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('<script>Failure & details</script>')
|
||||
expect(terminal).toContain('<img src=x> & 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"')
|
||||
})
|
||||
+35
-139
@@ -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('<script>Failure & details</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(/<script>not executable & not hidden<\/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')
|
||||
})
|
||||
+2
-317
@@ -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>
|
||||
+42
-153
@@ -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('<script>')
|
||||
})
|
||||
expect(failed).toContain('<script>Render failed & stopped</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(/<script>not executable & still visible<\/script>/g)).toHaveLength(
|
||||
3
|
||||
const headings = [...output.matchAll(/<h3\b[^>]*>([\s\S]*?)<\/h3>/g)].map((match) =>
|
||||
text(match[1])
|
||||
)
|
||||
expect(output).toContain('<img src=x> & 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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user