[web] simplify publication selection and results #164

Merged
eleith merged 1 commits from feat/publish-selection-screen into main 2026-10-28 20:52:27 +00:00
12 changed files with 3036 additions and 992 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@luzzle/web": minor
---
Choose pending pieces in a consistent publishing table with separate edit links,
explicit local and remote checks, retained results, and a fresh local check before
retrying. Preserve unselected choices and deduplicate replayed progress logs.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,327 @@
<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'
type Props = {
schemas?: PiecesDiff['schemas'] | null
schemasAlwaysIncluded?: boolean
phases?: readonly PhaseProgress[]
logs?: Readonly<Record<string, readonly PhaseLog[]>>
}
let { schemas = null, schemasAlwaysIncluded = false, phases = [], logs = {} }: Props = $props()
const schemaKinds = ['added', 'updated', 'pruned'] as const
let 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>>({})
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`
}
</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>
{#if hasSchemaChanges && schemas}
<section class="schema-changes">
<h2>
{#if schemasAlwaysIncluded}
Piece types · always included
{:else}
Piece types
{/if}
</h2>
<ul>
{#each schemaKinds as kind (kind)}
{#each schemas[kind] as name (name)}
<li>
<span class="badge">
{#if kind === 'added'}New{:else if kind === 'updated'}Updated{:else}Removed{/if}
</span>
{name}
</li>
{/each}
{/each}
</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}
</details>
{/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);
}
.schema-changes {
margin: var(--space-3) 0;
}
.schema-changes ul {
list-style: none;
padding: 0;
margin: 0;
}
.schema-changes li {
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);
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);
}
}
</style>
@@ -0,0 +1,298 @@
import type { PiecesDiff } from '@luzzle/core'
import type { ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { expect, test, vi } 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 } {
return {
job_id: 'publish-job',
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: null,
...overrides
}
}
function log(overrides: Partial<PhaseLog> = {}): PhaseLog {
return {
job_id: 'publish-job',
phase: 'render',
line_number: 1,
ts: started,
level: 'stdout',
message: 'Output ready',
...overrides
}
}
function details(props: Props = {}) {
return render(PublishDetails, { props }).body.replace(/<!--[\s\S]*?-->/g, '')
}
function text(html: string) {
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.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', () => {
const output = details({
schemas: { added: ['books'], updated: ['films'], pruned: ['notes'] }
})
expect(output).toMatch(/<details\b/)
expect(output).toMatch(/<h2\b[^>]*>Piece types<\/h2>/)
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/)
})
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', () => {
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()],
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' })
],
assets: [log({ job_id: 'job:1', phase: 'assets', message: 'Assets line one' })],
orphan: [log({ phase: 'orphan', message: 'Not a visible phase' })]
}
})
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
)
expect(output).toContain('&lt;img src=x> &amp; phase')
})
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.
@@ -0,0 +1,377 @@
<script lang="ts">
import type { PiecesDiff } from '@luzzle/core'
import Check from 'virtual:icons/ph/check'
import X from 'virtual:icons/ph/x'
import PencilSimple from 'virtual:icons/ph/pencil-simple'
import {
editorHref,
publishedHref,
selectablePaths as getSelectablePaths,
toggleSelection,
type PublicationOutcome
} from './report.js'
type Props = {
diff?: PiecesDiff | null
outcomes?: PublicationOutcome[]
order?: string[]
selectedPaths?: string[]
publishingPaths?: string[]
disabled?: boolean
}
let {
diff = null,
outcomes = [],
order = [],
selectedPaths = $bindable([]),
publishingPaths = [],
disabled = false
}: Props = $props()
let masterCheckbox = $state<HTMLInputElement | undefined>(undefined)
const pendingRemovals = $derived(new Set(diff?.pieces.pruned ?? []))
const selectablePaths = $derived.by(() => {
const paths: string[] = []
if (!diff) return paths
for (const file of getSelectablePaths(diff)) {
if (!pendingRemovals.has(file)) paths.push(file)
}
return paths
})
const outcomesByPath = $derived.by(() => {
const latest = new Map<string, PublicationOutcome>()
for (const outcome of outcomes) latest.set(outcome.filePath, outcome)
return latest
})
const currentMembership = $derived.by(() => {
const paths = new Set(selectablePaths)
for (const outcome of outcomesByPath.values()) {
if (outcome.state === 'removed' || pendingRemovals.has(outcome.filePath)) continue
paths.add(outcome.filePath)
}
return paths
})
const displayOrder = $derived.by(() => {
const paths = new Set<string>()
for (const file of order) {
if (currentMembership.has(file)) paths.add(file)
}
for (const file of currentMembership) paths.add(file)
return Array.from(paths)
})
const removals = $derived.by(() => {
const paths = new Set(pendingRemovals)
for (const outcome of outcomesByPath.values()) {
if (outcome.state !== 'removed' || selectablePaths.includes(outcome.filePath)) continue
paths.add(outcome.filePath)
}
return Array.from(paths)
})
const selectedCount = $derived(
selectablePaths.filter((file) => selectedPaths.includes(file)).length
)
const allSelected = $derived(
selectablePaths.length > 0 && selectedCount === selectablePaths.length
)
const partiallySelected = $derived(selectedCount > 0 && selectedCount < selectablePaths.length)
$effect(() => {
if (masterCheckbox) masterCheckbox.indeterminate = partiallySelected
})
</script>
<div class="publish-table">
{#if displayOrder.length > 0}
<section class="pieces" aria-label="Pieces">
{#if selectablePaths.length > 0}
<label class="selection-master">
<input
bind:this={masterCheckbox}
type="checkbox"
checked={allSelected}
disabled={disabled || selectablePaths.some((file) => publishingPaths.includes(file))}
onchange={(event) => {
selectedPaths = event.currentTarget.checked ? [...selectablePaths] : []
}}
/>
<span>Select all</span>
</label>
{:else}
<h3>Pieces</h3>
{/if}
<ul>
{#each displayOrder as file (file)}
{@const pending = selectablePaths.includes(file)}
{@const outcome = outcomesByPath.get(file)}
{@const publishing = publishingPaths.includes(file)}
{@const failed = outcome?.state === 'failed'}
{@const published = !pending && outcome?.state === 'published' && !publishing}
{#snippet details()}
<span class="piece-details">
<span class="filename">{file}</span>
{#if failed && !publishing && outcome?.message}
<span class="error">{outcome.message}</span>
{/if}
</span>
{#if publishing}
<span class="badge">Publishing</span>
{:else if published}
<a class="badge published-link" href={publishedHref(file)}>Published</a>
{:else}
<span class="badge">
{#if failed}
Failed
{:else if diff?.pieces.added.includes(file)}
New
{:else}
Updated
{/if}
</span>
{/if}
{/snippet}
<li class="piece-row">
{#if pending}
<label class="piece-content piece-selection">
<input
type="checkbox"
checked={selectedPaths.includes(file)}
disabled={disabled || publishing}
onchange={(event) => {
selectedPaths = toggleSelection(
selectedPaths,
file,
event.currentTarget.checked
)
}}
/>
{@render details()}
</label>
{:else}
<div class="piece-content">
<span class="result-icon" class:is-error={failed}>
{#if published}<Check aria-hidden="true" />{:else if failed}<X
aria-hidden="true"
/>{/if}
</span>
{@render details()}
</div>
{/if}
<a
class="edit-link"
href={editorHref(file)}
aria-label={`Edit ${file}`}
title={`Edit ${file}`}
>
<PencilSimple aria-hidden="true" />
</a>
</li>
{/each}
</ul>
</section>
{/if}
{#if removals.length > 0}
<section class="removals" aria-label="Removals">
<h3>
{#if pendingRemovals.size > 0}
Removals · always included
{:else}
Removals
{/if}
</h3>
<ul>
{#each removals as file (file)}
<li class="removed-row">
<span class="filename" class:change-gone={!pendingRemovals.has(file)}>{file}</span>
{#if !pendingRemovals.has(file)}<span class="badge">Removed</span>{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if displayOrder.length === 0 && removals.length === 0}
<p class="empty">No piece changes.</p>
{/if}
</div>
<style>
.publish-table {
color: var(--color-on-surface);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.pieces,
.removals {
background-color: var(--color-surface-container-lowest);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-small);
}
.removals {
margin-top: var(--space-3);
}
.removals:first-child {
margin-top: 0;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.selection-master,
.piece-content {
box-sizing: border-box;
display: grid;
align-items: center;
gap: var(--space-2);
min-height: 44px;
padding: var(--space-2) var(--space-3);
}
.selection-master,
.piece-selection {
cursor: pointer;
}
.selection-master {
grid-template-columns: 20px minmax(0, 1fr);
}
.selection-master,
h3 {
background-color: var(--color-surface-container-low);
}
.piece-row {
display: flex;
align-items: stretch;
border-top: 1px solid var(--color-outline-variant);
}
.piece-content {
flex: 1;
min-width: 0;
grid-template-columns: 20px minmax(0, 1fr) auto;
}
input,
.result-icon {
width: 20px;
height: 20px;
margin: 0;
}
.result-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
}
input {
accent-color: var(--color-primary);
cursor: pointer;
}
input:disabled {
cursor: not-allowed;
}
.piece-details {
min-width: 0;
}
.filename {
min-width: 0;
font-family: var(--font-mono-name), monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.is-error {
color: var(--color-error);
}
.error {
display: block;
color: var(--color-error);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.badge {
padding: 2px var(--space-1);
border-radius: var(--radius-small);
background-color: var(--color-surface-container-high);
color: var(--color-on-surface-variant);
font-size: var(--font-size-xxs);
}
.published-link {
color: var(--color-primary);
}
.edit-link {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 44px;
min-height: 44px;
padding: var(--space-2);
color: var(--color-primary);
text-decoration: none;
}
.piece-row:focus-within,
.selection-master:focus-within,
.piece-selection:hover,
.edit-link:hover {
background-color: var(--color-surface-container-low);
}
input:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
h3 {
box-sizing: border-box;
min-height: 44px;
display: flex;
align-items: center;
margin: 0;
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.removed-row {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: 44px;
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-outline-variant);
color: var(--color-on-surface-variant);
}
.change-gone {
text-decoration: line-through;
}
.empty {
margin: 0;
color: var(--color-on-surface-variant);
}
</style>
@@ -0,0 +1,537 @@
import type { PiecesDiff } from '@luzzle/core'
import { render } from 'svelte/server'
import { expect, test } from 'vitest'
import PublishTable from './PublishTable.svelte'
import type { PublicationOutcome } from './report.js'
const added = 'books/源 #?%.books.md'
const updated = 'films/a saved change.films.md'
const removed = 'books/gone.books.md'
function diff(pieces: Partial<PiecesDiff['pieces']> = {}): PiecesDiff {
return {
schemas: { added: [], updated: [], pruned: [] },
pieces: { added: [added], updated: [updated], pruned: [removed], ...pieces }
}
}
type TableProps = {
diff?: PiecesDiff | null
outcomes?: PublicationOutcome[]
order?: string[]
selectedPaths?: string[]
publishingPaths?: string[]
disabled?: boolean
}
function table(props: TableProps = {}) {
return render(PublishTable, { props }).body.replace(/<!--[\s\S]*?-->/g, '')
}
function html(changes = diff(), selectedPaths?: string[]) {
return render(PublishTable, {
props: { diff: changes, ...(selectedPaths === undefined ? {} : { selectedPaths }) }
}).body.replace(/<!--[\s\S]*?-->/g, '')
}
function labels(output: string): string[] {
return [...output.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)].map((match) => match[1])
}
function labelFor(output: string, text: string): string {
const label = labels(output).find((label) => label.includes(text))
if (!label) throw new Error(`Missing checkbox label: ${text}`)
return label
}
function checkbox(label: string): string {
const input = label.match(/<input\b[^>]*>/)?.[0]
if (!input) throw new Error('Missing native checkbox')
expect(input).toContain('type="checkbox"')
return input
}
function section(output: string, name: string): string {
const match = [...output.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/g)].find((match) =>
match[1].includes(`aria-label="${name}"`)
)
if (!match) throw new Error(`Missing section: ${name}`)
return match[2]
}
const checkedAttribute = /\schecked(?:\s|=|\/?>)/
test('one pending list contains New and Updated rows with filenames inside native checkbox labels', () => {
const output = html()
const pending = section(output, 'Pieces')
expect(pending.match(/<ul\b/g)).toHaveLength(1)
expect(pending.match(/<li\b/g)).toHaveLength(2)
for (const [file, badge] of [
[added, 'New'],
[updated, 'Updated']
]) {
const label = labelFor(pending, file)
checkbox(label)
expect(label).toContain(file)
expect(label).toMatch(new RegExp(`>\\s*${badge}\\s*</span>`))
expect(label).not.toMatch(/<a\b/)
}
})
test('each Edit link is separate from the selection label and has an encoded editor URL', () => {
const pending = section(html(), 'Pieces')
const row = [...pending.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/g)].find((match) =>
match[1].includes(added)
)?.[1]
if (!row) throw new Error('Missing added row')
const link = row.match(/<a\b([^>]*)>([\s\S]*?)<\/a>/)
if (!link) throw new Error('Missing Edit link')
expect(link[1]).toContain('href="/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/source"')
expect(link[1]).toContain(`aria-label="Edit ${added}"`)
expect(link[1]).toContain(`title="Edit ${added}"`)
expect(link[1]).not.toMatch(/\btarget\s*=/)
expect(link[2]).toMatch(/<svg\b/)
expect(link[2].replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/g, '').trim()).toBe('')
expect(row.indexOf('</label>')).toBeLessThan(row.indexOf('<a '))
expect(pending.match(/<a\b/g)).toHaveLength(2)
})
test('master selection is a single native checkbox rather than separate all and none actions', () => {
const output = html()
checkbox(labelFor(output, 'Select all'))
expect(labels(output).filter((label) => label.includes('Select all'))).toHaveLength(1)
expect(output).not.toContain('Select none')
expect(output).not.toMatch(/<button\b/)
})
test.each([
{ state: 'none', selection: [], checkedFiles: [], masterChecked: false },
{ state: 'some', selection: [added], checkedFiles: [added], masterChecked: false },
{
state: 'all',
selection: [added, updated],
checkedFiles: [added, updated],
masterChecked: true
}
])('renders checkbox state with $state selected', ({ selection, checkedFiles, masterChecked }) => {
const output = html(diff(), selection)
for (const file of [added, updated]) {
const input = checkbox(labelFor(output, file))
expect(checkedAttribute.test(input)).toBe(checkedFiles.includes(file))
}
expect(checkedAttribute.test(checkbox(labelFor(output, 'Select all')))).toBe(masterChecked)
})
test('omitting the bindable selection starts with unchecked pending pieces', () => {
const output = html()
for (const label of labels(output)) {
expect(checkbox(label)).not.toMatch(checkedAttribute)
}
})
test('master state counts only distinct pending paths, not removals or stale selections', () => {
const output = html(diff({ added: [added, added], updated: [updated, added] }), [
added,
added,
updated,
removed,
'stale.books.md'
])
expect(checkbox(labelFor(output, 'Select all'))).toMatch(checkedAttribute)
expect(section(output, 'Pieces').match(/<li\b/g)).toHaveLength(2)
})
test('removals stay visible beneath pending pieces as plain, unselectable filenames', () => {
const output = html(diff(), [removed])
const removals = section(output, 'Removals')
expect(removals).toContain('Removals · always included')
expect(removals).toContain(removed)
expect(removals.match(/<li\b/g)).toHaveLength(1)
expect(removals).not.toMatch(/<(?:input|label|a|button)\b/)
expect(output.indexOf('aria-label="Pieces"')).toBeLessThan(
output.indexOf('aria-label="Removals"')
)
expect(output).not.toContain(`/admin/piece/${removed}/`)
})
test('removal-only changes have no master checkbox or pending selection list', () => {
const output = html(diff({ added: [], updated: [] }))
expect(section(output, 'Removals')).toContain(removed)
expect(output).not.toContain('aria-label="Pieces"')
expect(output).not.toContain('Select all')
expect(output).not.toMatch(/<(?:input|label|a)\b/)
expect(output).not.toContain('No piece changes.')
})
test('upsert-only changes omit the removals section', () => {
const output = html(diff({ pruned: [] }))
expect(section(output, 'Pieces')).toContain(added)
expect(output).not.toContain('aria-label="Removals"')
expect(output).not.toContain('Removals · always included')
})
test('no piece changes shows a short empty state without schema rows or selection controls', () => {
const changes = diff({ added: [], updated: [], pruned: [] })
changes.schemas = {
added: ['new-schema'],
updated: ['updated-schema'],
pruned: ['removed-schema']
}
const output = html(changes)
expect(output).toContain('No piece changes.')
expect(output).not.toMatch(/<(?:section|ul|input|label|a)\b/)
for (const name of Object.values(changes.schemas).flat()) {
expect(output).not.toContain(name)
}
})
test('filenames in every change kind are escaped rather than rendered as markup', () => {
const unsafe = '<img src=x onerror="alert(1)"> & notes.books.md'
const output = html(
diff({ added: [unsafe], updated: [`updated/${unsafe}`], pruned: [`removed/${unsafe}`] })
)
expect(output).not.toMatch(/<img\b/)
expect(output).toContain('&lt;img')
expect(output).toContain('&amp; notes.books.md')
expect(section(output, 'Removals')).toContain('removed/&lt;img')
expect(output).toContain(
'href="/admin/piece/%3Cimg%20src%3Dx%20onerror%3D%22alert(1)%22%3E%20%26%20notes.books.md/source"'
)
})
function rowFor(output: string, file: string): string {
const row = [...output.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/g)].find((match) =>
match[1].includes(file)
)
if (!row) throw new Error(`Missing row: ${file}`)
return row[1]
}
const disabledAttribute = /\sdisabled(?:\s|=|\/?>)/
test('default props render an empty state', () => {
const output = table()
expect(output).toContain('No piece changes.')
expect(output).not.toMatch(/<(?:section|ul|input|label|a)\b/)
})
test('pending, failed, and confirmed rows share one list with only pending rows selectable', () => {
const failed = 'failed.books.md'
const published = 'published.books.md'
const outside = 'outside.books.md'
const output = table({
diff: diff({ added: [added, failed], updated: [updated], pruned: [] }),
outcomes: [
{ filePath: failed, state: 'failed', message: 'Invalid date' },
{ filePath: published, state: 'published' },
{ filePath: outside, state: 'failed', message: 'Not found' }
],
selectedPaths: [added, failed]
})
const pieces = section(output, 'Pieces')
expect(pieces.match(/<ul\b/g)).toHaveLength(1)
expect(pieces.match(/<li\b/g)).toHaveLength(5)
for (const file of [added, updated, failed]) checkbox(labelFor(output, file))
const retry = rowFor(output, failed)
expect(retry).toContain('Failed')
expect(retry).toContain('Invalid date')
expect(checkbox(labelFor(output, failed))).toMatch(checkedAttribute)
const readOnly = rowFor(output, outside)
expect(readOnly).toContain('Failed')
expect(readOnly).toContain('Not found')
expect(readOnly).not.toMatch(/<(?:input|label)\b/)
expect(readOnly).toContain('aria-label="Edit outside.books.md"')
expect(rowFor(output, published)).not.toMatch(/<(?:input|label)\b/)
})
test('confirmed publication replaces the checkbox with a check icon and a linked Published status', () => {
const output = table({ outcomes: [{ filePath: added, state: 'published' }] })
const pieces = section(output, 'Pieces')
expect(pieces).toMatch(/<h3\b[^>]*>Pieces<\/h3>/)
expect(pieces).not.toContain('Select all')
expect(pieces).not.toMatch(/<(?:input|label)\b/)
const row = rowFor(pieces, added)
const links = [...row.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/g)]
expect(links).toHaveLength(2)
expect(links[0][1]).toContain(
'href="/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published"'
)
expect(links[0][2].trim()).toBe('Published')
expect(links[1][1]).toContain(`aria-label="Edit ${added}"`)
expect(row.match(/<svg\b/g)).toHaveLength(2)
expect(row).toContain(`>${added}</span>`)
for (const link of links) {
expect(link[2]).not.toContain(added)
expect(link[1]).not.toMatch(/\b(?:target|disabled|aria-disabled)\s*=/)
}
})
test('fresh pending data takes precedence over prior published and removed outcomes', () => {
const output = table({
diff: diff({ pruned: [] }),
outcomes: [
{ filePath: added, state: 'published' },
{ filePath: updated, state: 'removed' }
]
})
const pieces = section(output, 'Pieces')
expect(pieces.match(/<li\b/g)).toHaveLength(2)
for (const [file, status] of [
[added, 'New'],
[updated, 'Updated']
]) {
const label = labelFor(output, file)
expect(checkbox(label)).not.toMatch(checkedAttribute)
expect(label).toContain(status)
expect(label).not.toContain('Published')
}
expect(output).not.toContain('aria-label="Removals"')
expect(output).not.toContain('/published"')
})
test('fresh pending removals replace prior outcomes and keep the always-included policy', () => {
const output = table({
diff: diff({ added: [], updated: [], pruned: [removed, removed] }),
outcomes: [
{ filePath: removed, state: 'failed', message: 'obsolete failure' },
{ filePath: 'other.books.md', state: 'removed' }
]
})
const removals = section(output, 'Removals')
expect(removals).toContain('Removals · always included')
expect(removals.match(/<li\b/g)).toHaveLength(2)
expect(output).not.toContain('obsolete failure')
expect(output).not.toContain('aria-label="Pieces"')
expect(removals).not.toMatch(/<(?:input|label|a|button|svg)\b/)
const rows = [...removals.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/g)].map((match) => match[1])
const pending = rows.find((row) => row.includes(removed))
const completed = rows.find((row) => row.includes('other.books.md'))
expect(pending).toBeDefined()
expect(completed).toBeDefined()
expect(pending).not.toMatch(/<span\b[^>]*>\s*Removed\s*<\/span>/)
expect(completed).toMatch(/<span\b[^>]*>\s*Removed\s*<\/span>/)
})
test('confirmed removals use the same plain removal list without the pending policy', () => {
const output = table({
outcomes: [
{ filePath: removed, state: 'removed' },
{ filePath: removed, state: 'removed' }
]
})
const removals = section(output, 'Removals')
expect(removals).toMatch(/<h3\b[^>]*>Removals<\/h3>/)
expect(removals).not.toContain('always included')
expect(removals.match(/<li\b/g)).toHaveLength(1)
expect(removals).not.toMatch(/<(?:input|label|a|button|svg)\b/)
})
test('disabled locks all selection controls but keeps native completed and Edit links available', () => {
const completed = 'complete.books.md'
const output = table({
diff: diff({ pruned: [] }),
disabled: true,
outcomes: [{ filePath: completed, state: 'published' }]
})
for (const label of labels(output)) expect(checkbox(label)).toMatch(disabledAttribute)
const links = [...output.matchAll(/<a\b([^>]*)>/g)]
expect(links).toHaveLength(4)
for (const link of links) {
expect(link[1]).toContain('href=')
expect(link[1]).not.toMatch(/\b(?:disabled|aria-disabled|tabindex)\s*=/)
}
expect(rowFor(output, completed)).toContain('Published')
})
test('publishing paths show a locked Publishing row instead of prior failure or success', () => {
const output = table({
diff: diff({ pruned: [] }),
selectedPaths: [added],
publishingPaths: [added],
outcomes: [{ filePath: added, state: 'failed', message: 'old error' }]
})
const row = rowFor(output, added)
expect(row).toContain('Publishing')
expect(row).not.toContain('Failed')
expect(row).not.toContain('old error')
expect(checkbox(labelFor(output, added))).toMatch(disabledAttribute)
expect(checkbox(labelFor(output, added))).toMatch(checkedAttribute)
expect(checkbox(labelFor(output, 'Select all'))).toMatch(disabledAttribute)
expect(checkbox(labelFor(output, updated))).not.toMatch(disabledAttribute)
expect(row).toContain(`aria-label="Edit ${added}"`)
})
test('completed records never affect master selection counts and duplicate outcomes make one row', () => {
const output = table({
diff: diff({ pruned: [] }),
outcomes: [
{ filePath: 'done.books.md', state: 'failed', message: 'old error' },
{ filePath: 'done.books.md', state: 'published' }
],
selectedPaths: [added, updated, removed, 'done.books.md', 'stale.books.md']
})
expect(checkbox(labelFor(output, 'Select all'))).toMatch(checkedAttribute)
expect(section(output, 'Pieces').match(/<li\b/g)).toHaveLength(3)
expect(rowFor(output, 'done.books.md')).toContain('Published')
expect(output).not.toContain('old error')
})
test('failed-only completed lists have a Pieces header and a separate editor action', () => {
const output = table({
outcomes: [{ filePath: added, state: 'failed', message: 'Cannot parse YAML' }]
})
const pieces = section(output, 'Pieces')
expect(pieces).toMatch(/<h3\b[^>]*>Pieces<\/h3>/)
expect(pieces).toContain('Failed')
expect(pieces).toContain('Cannot parse YAML')
expect(pieces).not.toMatch(/<(?:input|label)\b/)
expect(pieces).not.toContain('Select all')
expect(pieces).not.toContain('/published"')
expect(pieces.match(/<a\b/g)).toHaveLength(1)
expect(pieces).toContain(`aria-label="Edit ${added}"`)
})
test('untrusted outcome filenames and failure messages render as escaped text', () => {
const unsafe = '<img src=x onerror="alert(1)"> & notes.books.md'
const message = '<script>alert("bad")</script> & invalid'
const output = table({
outcomes: [
{ filePath: unsafe, state: 'published' },
{ filePath: `failed/${unsafe}`, state: 'failed', message },
{ filePath: `removed/${unsafe}`, state: 'removed' }
]
})
expect(output).not.toMatch(/<(?:img|script)\b/)
expect(output).toContain('&lt;img')
expect(output).toContain('&amp; notes.books.md')
expect(output).toContain('&lt;script')
expect(output).toContain('&amp; invalid')
expect(output).toContain(
'/%3Cimg%20src%3Dx%20onerror%3D%22alert(1)%22%3E%20%26%20notes.books.md/published'
)
expect(section(output, 'Removals')).not.toMatch(/<a\b/)
})
test('parent order keeps completed B between pending A and C after provisional filtering', () => {
const a = 'A.books.md'
const b = 'B.books.md'
const c = 'C.books.md'
const order = [a, b, c]
const before = table({
diff: diff({ added: [a, b, c], updated: [], pruned: [] }),
order,
publishingPaths: [b]
})
const after = table({
diff: diff({ added: [a, c], updated: [], pruned: [] }),
outcomes: [{ filePath: b, state: 'published' }],
order
})
for (const output of [before, after]) {
const pieces = section(output, 'Pieces')
expect(pieces.match(/<li\b/g)).toHaveLength(3)
expect(pieces.indexOf(a)).toBeLessThan(pieces.indexOf(b))
expect(pieces.indexOf(b)).toBeLessThan(pieces.indexOf(c))
}
expect(rowFor(before, b)).toContain('Publishing')
expect(rowFor(after, b)).toContain('Published')
expect(rowFor(after, b)).not.toMatch(/<input\b/)
})
test('order deduplicates current rows, drops stale or removed paths, and appends new membership', () => {
const output = table({
diff: diff({ added: [added, updated, added], updated: [], pruned: [removed] }),
outcomes: [
{ filePath: 'done.books.md', state: 'published' },
{ filePath: 'failed.books.md', state: 'failed', message: 'Invalid date' },
{ filePath: removed, state: 'published' }
],
order: [updated, 'stale.books.md', updated, removed, added]
})
const pieces = section(output, 'Pieces')
expect(pieces.match(/<li\b/g)).toHaveLength(4)
const expected = [updated, added, 'done.books.md', 'failed.books.md']
for (let index = 1; index < expected.length; index++) {
expect(pieces.indexOf(expected[index - 1])).toBeLessThan(pieces.indexOf(expected[index]))
}
expect(pieces).not.toContain('stale.books.md')
expect(pieces).not.toContain(removed)
expect(section(output, 'Removals')).toContain(removed)
})
test('pending removals win over overlapping upserts, outcomes, order, and selection', () => {
const output = table({
diff: diff({ added: [added, removed], updated: [removed], pruned: [removed, removed] }),
outcomes: [{ filePath: removed, state: 'failed', message: 'superseded failure' }],
order: [removed, added, removed],
selectedPaths: [added, removed]
})
const pieces = section(output, 'Pieces')
expect(pieces.match(/<li\b/g)).toHaveLength(1)
expect(pieces).not.toContain(removed)
expect(checkbox(labelFor(output, added))).toMatch(checkedAttribute)
expect(checkbox(labelFor(output, 'Select all'))).toMatch(checkedAttribute)
const removals = section(output, 'Removals')
expect(removals.match(/<li\b/g)).toHaveLength(1)
expect(removals).toContain(removed)
expect(removals).toContain('always included')
expect(removals).not.toMatch(/<(?:input|label|a)\b/)
expect(output).not.toContain('superseded failure')
})
test('latest outcomes decide piece versus removal membership without duplicate rows or old errors', () => {
const output = table({
outcomes: [
{ filePath: removed, state: 'published' },
{ filePath: added, state: 'removed' },
{ filePath: removed, state: 'failed', message: 'old failure' },
{ filePath: removed, state: 'removed' },
{ filePath: added, state: 'published' }
],
order: [removed, added, removed, added]
})
const pieces = section(output, 'Pieces')
expect(pieces.match(/<li\b/g)).toHaveLength(1)
expect(pieces).toContain(added)
expect(pieces).not.toContain(removed)
expect(rowFor(pieces, added)).toContain('/published"')
const removals = section(output, 'Removals')
expect(removals.match(/<li\b/g)).toHaveLength(1)
expect(removals).toContain(removed)
expect(removals).not.toContain(added)
expect(removals).not.toContain('always included')
expect(output).not.toContain('old failure')
})
test('partial results keep skipped pending targets unchecked rather than inferring publication', () => {
const done = 'done.books.md'
const retry = 'retry.books.md'
const skipped = 'skipped.books.md'
const output = table({
diff: diff({ added: [retry, skipped], updated: [], pruned: [] }),
outcomes: [
{ filePath: done, state: 'published' },
{ filePath: retry, state: 'failed', message: 'Retry this item' }
],
order: [done, retry, skipped, 'unknown.books.md'],
selectedPaths: [retry]
})
const pieces = section(output, 'Pieces')
expect(pieces.match(/<li\b/g)).toHaveLength(3)
expect(pieces).not.toContain('unknown.books.md')
expect(pieces.indexOf(done)).toBeLessThan(pieces.indexOf(retry))
expect(pieces.indexOf(retry)).toBeLessThan(pieces.indexOf(skipped))
expect(rowFor(output, done)).toContain('/published"')
expect(rowFor(output, done)).not.toMatch(/<(?:input|label)\b/)
expect(checkbox(labelFor(output, retry))).toMatch(checkedAttribute)
expect(labelFor(output, retry)).toContain('Failed')
expect(labelFor(output, retry)).toContain('Retry this item')
expect(checkbox(labelFor(output, skipped))).not.toMatch(checkedAttribute)
expect(labelFor(output, skipped)).toContain('New')
expect(rowFor(output, skipped)).not.toContain('/published"')
expect(checkbox(labelFor(output, 'Select all'))).not.toMatch(checkedAttribute)
})
// SSR exercises rendering only. Owner review still needs to cover label/keyboard toggles,
// parent binding updates, master indeterminate state, and independent native link navigation.
@@ -4,18 +4,52 @@ import type { ComponentProps } from 'svelte'
import Page from './+page.svelte'
import type { RunView } from './+page.server.js'
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }))
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/admin/publish') } }))
const location = vi.hoisted(() => ({ url: 'http://localhost/admin/publish' }))
function html(
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }))
vi.mock('$app/state', () => ({
page: {
get url() {
return new URL(location.url)
}
}
}))
// SSR fixtures do not exercise native clicks, onMount checks, or streamed transitions.
function html({
failedPieces = [{ filePath: 'failed.book.md', message: 'missing attachment' }],
phases: RunView['phases'] = [],
pieces: NonNullable<RunView['diff']>['pieces'] = {
phases = [],
pieces = {
added: ['failed.book.md', 'healthy.book.md'],
updated: [],
pruned: []
}
) {
},
publish = true,
state = 'completed',
schemas = { added: [], updated: [], pruned: [] },
logs = [],
audit = null
}: {
failedPieces?: RunView['failedPieces']
phases?: RunView['phases']
pieces?: NonNullable<RunView['diff']>['pieces']
publish?: boolean
state?: RunView['state']
schemas?: NonNullable<RunView['diff']>['schemas']
logs?: RunView['logs']
audit?: RunView | null
} = {}) {
const publication: RunView | null = publish
? {
jobId: 'publish-id',
state,
errors: null,
phases,
logs,
failedPieces,
diff: { schemas, pieces }
}
: null
const data = {
pieceTypes: ['books'],
meta: { title: 'Publish' },
@@ -23,62 +57,174 @@ function html(
url: { app: 'http://localhost', app_assets: '', luzzle_assets: '' },
content: { text: { title: 'Test', description: '' } }
},
audit: null,
publish: {
jobId: 'publish-id',
state: 'completed',
errors: null,
phases,
logs: [],
failedPieces,
diff: {
schemas: { added: [], updated: [], pruned: [] },
pieces
}
}
audit,
publish: publication
}
return render(Page, { props: { data } as ComponentProps<typeof Page> }).body
}
test('a completed partial publish stays visible after reload without an all-success claim', () => {
function withoutComments(output: string) {
return output.replace(/<!--[\s\S]*?-->/g, '')
}
function text(output: string) {
return withoutComments(output)
.replace(/<(?:"[^"]*"|'[^']*'|[^'">])*>/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(?:39|x27);/g, "'")
.replace(/&amp;/g, '&')
}
function buttons(output: string) {
return [...withoutComments(output).matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
}
function links(output: string) {
return [...withoutComments(output).matchAll(/<a\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/a>/g)]
}
function region(output: string, label: string) {
const matches = [
...withoutComments(output).matchAll(
/<section\b[^>]*\baria-label="([^"]+)"[^>]*>([\s\S]*?)<\/section>/g
)
].filter((section) => section[1] === label)
expect(matches, `one ${label} region`).toHaveLength(1)
return matches[0][2]
}
function pieceRow(output: string, file: string) {
const rows = [...region(output, 'Pieces').matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/g)].filter(
(row) => text(row[1]).includes(file)
)
expect(rows, `one row for ${file}`).toHaveLength(1)
return rows[0][1]
}
function expectEditLink(output: string, file: string, href: string) {
const editors = links(output).filter((link) => link[1].includes(`href="${href}"`))
expect(editors).toHaveLength(1)
expect(editors[0][1]).toContain(`aria-label="Edit ${file}"`)
expect(text(editors[0][2]).trim()).toBe('')
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/)
}
function audit(state: RunView['state'], diff: RunView['diff'] = null): RunView {
return {
jobId: 'audit-id',
state,
errors: null,
phases: [],
logs: [],
failedPieces: [],
diff
}
}
function expectChecking(output: string) {
const markup = withoutComments(output)
expect(markup.match(/<h1\b[^>]*>/g)).toHaveLength(1)
expect(markup).toMatch(/<h1\b[^>]*>\s*Publish\s*<\/h1>/)
expect(text(output).match(/Checking changes…/g)).toHaveLength(1)
expect(output.match(/role="(?:alert|status)"/g)).toHaveLength(1)
expectHeaderChecksOnly(output)
expect(text(output)).toContain('Sync & Check')
expect(output).not.toContain('Check item first')
expect(output).not.toContain('Publish all')
expect(output).not.toContain('Publish changes')
}
test('a completed partial publish stays visible after reload without duplicate results or success claims', () => {
const output = html()
expect(output).toContain('Publish finished with errors')
expect(output).toContain('missing attachment')
expect(output).toContain('asset generation failed')
expect(output).toContain('/admin/piece/failed.book.md/source')
const content = text(output)
expect(content.match(/Published with errors/g)).toHaveLength(1)
expect(output.match(/role="(?:alert|status)"/g)).toHaveLength(1)
expect(output).toContain('role="alert"')
expect(output).not.toContain('Checking changes…')
expect(content.match(/missing attachment/g)).toHaveLength(1)
expect(content.match(/failed\.book\.md/g)).toHaveLength(1)
expect(content.match(/healthy\.book\.md/g)).toHaveLength(1)
expect(output).not.toContain('asset generation failed')
expect(output).not.toContain('Asset generation failed')
const failed = pieceRow(output, 'failed.book.md')
expect(text(failed)).toContain('Failed')
expect(text(failed)).toContain('missing attachment')
expect(text(failed)).not.toContain('Published')
expectEditLink(failed, 'failed.book.md', '/admin/piece/failed.book.md/source')
expect(output).not.toContain('/admin/piece/failed.book.md/published')
expect(output).toContain('/admin/piece/healthy.book.md/published')
const healthy = pieceRow(output, 'healthy.book.md')
expectEditLink(healthy, 'healthy.book.md', '/admin/piece/healthy.book.md/source')
const published = links(healthy).filter((link) => text(link[2]).trim() === 'Published')
expect(published).toHaveLength(1)
expect(published[0][1]).toContain('href="/admin/piece/healthy.book.md/published"')
expect(links(output).some((link) => /(?:failed|healthy)\.book\.md/.test(text(link[2])))).toBe(
false
)
expectHeaderChecksOnly(output)
expect(output).not.toContain('/live')
expect(output).not.toContain('Published successfully')
expect(output).toContain('role="alert"')
})
test('failed-piece editor links encode filenames with URL delimiters', () => {
const output = html([{ filePath: 'books/chapter#1?%.book.md', message: 'missing image' }])
expect(output).toContain('/admin/piece/books/chapter%231%3F%25.book.md/source')
test('failures outside the diff remain visible once with encoded editor links', () => {
const file = 'books/chapter#1?%.book.md'
const output = html({ failedPieces: [{ filePath: file, message: 'missing image' }] })
expectEditLink(
pieceRow(output, file),
file,
'/admin/piece/books/chapter%231%3F%25.book.md/source'
)
expect(output).not.toContain('/admin/piece/books/chapter%231%3F%25.book.md/published')
expect(text(output).split(file)).toHaveLength(2)
expect(text(output).match(/missing image/g)).toHaveLength(1)
})
test.each(['added', 'updated'] as const)(
'healthy %s pieces open the encoded Published overview in the same tab',
(kind) => {
const file = 'books/源 #?%.book.md'
const output = html(undefined, [], { added: [], updated: [], pruned: [], [kind]: [file] })
const output = html({ pieces: { added: [], updated: [], pruned: [], [kind]: [file] } })
const href = '/admin/piece/books/%E6%BA%90%20%23%3F%25.book.md/published'
const link = output.match(/<a\b[^>]*>/g)?.find((tag) => tag.includes(`href="${href}"`))
expect(link).toBeDefined()
expect(link).not.toMatch(/\btarget\s*=/)
const row = pieceRow(output, file)
const published = links(row).filter((link) => link[1].includes(`href="${href}"`))
expect(published).toHaveLength(1)
expect(text(published[0][2]).trim()).toBe('Published')
expect(published[0][1]).not.toMatch(/\btarget\s*=/)
expectEditLink(row, file, '/admin/piece/books/%E6%BA%90%20%23%3F%25.book.md/source')
expect(links(row).some((link) => text(link[2]).includes(file))).toBe(false)
expect(text(output).split(file)).toHaveLength(2)
expect(output).not.toContain('/live')
}
)
test('pruned pieces remain plain text instead of linking to Published or source', () => {
const file = 'gone.book.md'
const output = html(undefined, [], { added: [], updated: [], pruned: [file] })
expect(output).toMatch(/<span class="change-gone(?:\s[^"]*)?">gone\.book\.md<\/span>/)
const output = html({ pieces: { added: [], updated: [], pruned: [file] } })
const removals = region(output, 'Removals')
expect(removals).toMatch(/<span\b[^>]*>gone\.book\.md<\/span>/)
expect(links(removals)).toHaveLength(0)
expect(text(output).split(file)).toHaveLength(2)
expect(output).not.toContain(`/admin/piece/${file}/`)
})
test('partial reports retain one timeline row per retried phase and count stages correctly', () => {
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',
@@ -86,24 +232,328 @@ test('partial reports retain one timeline row per retried phase and count stages
finished_at: 2000,
message: null
}
const output = html(undefined, [
{ ...phase, status: 'failed' },
{ ...phase, status: 'completed', started_at: 2000, finished_at: 3000 }
])
expect(output).toContain('Publish finished with errors')
expect(output.match(/class="phase-name(?:\s[^"]*)?"/g)).toHaveLength(1)
expect(output.replace(/<!--[\s\S]*?-->/g, '')).toContain('1 stages')
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('ordinary completed history retains the existing idle publish screen', () => {
const output = html([])
expect(output).toContain('Publish changes')
expect(output).not.toContain('Publish finished with errors')
expect(output).not.toContain('Asset generation failed')
test('schemas and phase logs stay inside collapsed native Details', () => {
const output = html({
phases: [
{
job_id: 'publish-id',
phase: 'cdn.sync',
status: 'completed',
started_at: 1000,
finished_at: 2000,
message: null
}
],
schemas: { added: ['book.schema.json'], updated: [], pruned: [] },
logs: [
{
job_id: 'publish-id',
phase: 'cdn.sync',
line_number: 1,
ts: 1500,
level: 'info',
message: 'CDN assets synchronized'
}
]
})
const details = disclosure(output, 'Details')
expect(details).toContain('book.schema.json')
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('failed-piece messages render as text rather than executable markup', () => {
const output = html([{ filePath: 'failed.book.md', message: '<script>alert(1)</script>' }])
expect(output).not.toContain('<script>alert(1)</script>')
expect(output).toContain('&lt;script')
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)
expect(text(output)).not.toContain('Options')
expect(text(output)).not.toContain('Check again')
expect(output).not.toMatch(/<input\b[^>]*\btype="checkbox"/)
})
test.each(['running', 'waiting'])(
'both header checks stay disabled without a Publish CTA during a %s publish',
(state) => {
const output = html({ failedPieces: [], state })
const actions = buttons(output)
expectHeaderChecksOnly(output)
for (const button of actions) {
expect(button[1]).toMatch(/\bdisabled(?:\s|=|$)/)
}
}
)
test.each(['running', 'waiting'])(
'a resumed %s audit retains a successful publication without a pending-ready state or Publish CTA',
(state) => {
const output = html({ failedPieces: [], audit: audit(state) })
const content = text(output)
expect(content.match(/Checking remaining changes…/g)).toHaveLength(1)
expect(output.match(/role="status"/g)).toHaveLength(1)
expect(output).not.toContain('role="alert"')
expect(content).not.toContain('Published with errors')
expect(content).not.toContain('Asset generation failed')
expect(content).not.toContain('Up to date')
expect(content).not.toContain('Select all')
expectHeaderChecksOnly(output)
for (const button of buttons(output)) {
expect(button[1]).toMatch(/\bdisabled(?:\s|=|$)/)
}
expect(output).not.toMatch(/<input\b[^>]*\btype="checkbox"/)
for (const file of ['healthy.book.md', 'failed.book.md']) {
const row = pieceRow(output, file)
expect(links(row).filter((link) => text(link[2]).trim() === 'Published')).toHaveLength(1)
expect(row).toContain(`/admin/piece/${file}/published`)
expectEditLink(row, file, `/admin/piece/${file}/source`)
expect(text(row)).not.toMatch(/\b(?:New|Updated|Failed)\b/)
}
}
)
test.each(['running', 'waiting'])(
'a resumed %s audit keeps partial publication links and inline errors while controls stay locked',
(state) => {
const output = html({ audit: audit(state) })
const content = text(output)
expect(content.match(/Checking remaining changes…/g)).toHaveLength(1)
expect(output.match(/role="status"/g)).toHaveLength(1)
expect(output).not.toContain('role="alert"')
const healthy = pieceRow(output, 'healthy.book.md')
const published = links(healthy).filter((link) => text(link[2]).trim() === 'Published')
expect(published).toHaveLength(1)
expect(published[0][1]).toContain('href="/admin/piece/healthy.book.md/published"')
expectEditLink(healthy, 'healthy.book.md', '/admin/piece/healthy.book.md/source')
const failed = pieceRow(output, 'failed.book.md')
expect(text(failed)).toContain('Failed')
expect(text(failed)).toContain('missing attachment')
expect(content.match(/missing attachment/g)).toHaveLength(1)
expect(failed).not.toContain('/admin/piece/failed.book.md/published')
expectEditLink(failed, 'failed.book.md', '/admin/piece/failed.book.md/source')
expectHeaderChecksOnly(output)
for (const button of buttons(output)) {
expect(button[1]).toMatch(/\bdisabled(?:\s|=|$)/)
}
// No pending diff is loaded during SSR, so retry selection is not observable here.
expect(output).not.toMatch(/<input\b[^>]*\btype="checkbox"/)
}
)
test.each(['partial', 'successful'])(
'a resumed audit preserves the %s publication’s collapsed Details instead of audit progress',
(result) => {
const resumedAudit = audit('running')
resumedAudit.phases = [
{
job_id: 'audit-id',
phase: 'archive.diff',
status: 'running',
started_at: 3000,
finished_at: null,
message: 'Audit is comparing changes'
}
]
resumedAudit.logs = [
{
job_id: 'audit-id',
phase: 'archive.diff',
line_number: 1,
ts: 3500,
level: 'info',
message: 'Audit progress log'
}
]
const output = html({
failedPieces:
result === 'partial' ? [{ filePath: 'failed.book.md', message: 'missing attachment' }] : [],
phases: [
{
job_id: 'publish-id',
phase: 'cdn.sync',
status: 'completed',
started_at: 1000,
finished_at: 2000,
message: 'Publication CDN sync completed'
}
],
schemas: { added: ['book.schema.json'], updated: [], pruned: [] },
logs: [
{
job_id: 'publish-id',
phase: 'cdn.sync',
line_number: 1,
ts: 1500,
level: 'info',
message: 'Published assets synchronized'
}
],
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).match(/Published assets synchronized/g)).toHaveLength(1)
expect(text(output)).not.toContain('archive.diff')
expect(text(output)).not.toContain('Audit is comparing changes')
expect(text(output)).not.toContain('Audit progress log')
}
)
test.each(['running', 'waiting', 'failed', 'canceled'])(
'a %s publication does not seed completed outcomes while an audit is available',
(state) => {
const output = html({ state, audit: audit('running') })
expectHeaderChecksOnly(output)
expect(output).not.toContain('aria-label="Pieces"')
expect(output).not.toContain('/admin/piece/healthy.book.md/published')
expect(output).not.toContain('missing attachment')
expect(output).not.toMatch(/<input\b[^>]*\btype="checkbox"/)
for (const button of buttons(output)) {
expect(button[1]).toMatch(/\bdisabled(?:\s|=|$)/)
}
}
)
test('a cold visit with only successful publication history starts checking without known outcomes', () => {
const output = html({ failedPieces: [] })
expectChecking(output)
expect(output).not.toContain('aria-label="Pieces"')
expect(output).not.toContain('/admin/piece/healthy.book.md/published')
expect(output).not.toContain('/admin/piece/failed.book.md/published')
})
test.each(['books/源 #?%.book.md', ''])(
'contextual cold SSR cannot fall back to unchecked publishing: %s',
(file) => {
const previous = location.url
location.url = `http://localhost/admin/publish?${new URLSearchParams({ check: '1', file })}`
try {
expectChecking(html({ failedPieces: [], publish: false }))
} finally {
location.url = previous
}
}
)
test('a cold visit without publish history renders checking and only the two header checks', () => {
expectChecking(html({ failedPieces: [], publish: false }))
})
test.each(['running', 'waiting'])(
'a resumed %s check disables both header checks without exposing a publish footer',
(state) => {
const output = html({ failedPieces: [], publish: false, audit: audit(state) })
expectHeaderChecksOnly(output)
for (const button of buttons(output)) {
expect(button[1]).toMatch(/\bdisabled(?:\s|=|$)/)
}
}
)
test.each(['upserts', 'removals', 'schemas'] as const)(
'a stored completed audit with %s is not a fresh check that authorizes publishing',
(kind) => {
const storedAudit = audit('completed', {
pieces: {
added: kind === 'upserts' ? ['new.book.md'] : [],
updated: [],
pruned: kind === 'removals' ? ['gone.book.md'] : []
},
schemas: { added: kind === 'schemas' ? ['book.schema.json'] : [], updated: [], pruned: [] }
})
expectChecking(html({ failedPieces: [], publish: false, audit: storedAudit }))
}
)
test('a completed publish does not reuse its stored audit to offer another publish', () => {
const output = html({
failedPieces: [],
audit: audit('completed', {
pieces: { added: ['healthy.book.md'], updated: [], pruned: ['gone.book.md'] },
schemas: { added: ['book.schema.json'], updated: [], pruned: [] }
})
})
expectChecking(output)
expect(output).not.toContain('aria-label="Pieces"')
expect(output).not.toMatch(/<input\b[^>]*\btype="checkbox"/)
})
test('an unchanged contextual item has no force-rebuild or unchecked publish action', () => {
const previous = location.url
location.url = 'http://localhost/admin/publish?check=1&file=unchanged.book.md'
try {
const output = html({
failedPieces: [],
publish: false,
audit: audit('completed', {
pieces: { added: [], updated: [], pruned: [] },
schemas: { added: [], updated: [], pruned: [] }
})
})
expectChecking(output)
expect(text(output)).not.toMatch(
/force[\s-]*(?:publish|rebuild)|rebuild|publish (?:anyway|unchanged)/i
)
} finally {
location.url = previous
}
})
test('duplicate failures and overlapping diff entries render one escaped filename and inline error', () => {
const file = 'books/<img src=x onerror=alert(1)> & "quote".book.md'
const message = '<script>alert(1)</script> & missing attachment'
const failure = { filePath: file, message }
const output = html({
failedPieces: [failure, failure],
pieces: { added: [file, file], updated: [file], pruned: [] }
})
const row = pieceRow(output, file)
expect(text(output).split(file)).toHaveLength(2)
expect(text(output).split(message)).toHaveLength(2)
expect(text(row)).toContain(message)
expect(text(row)).toContain('Failed')
expect(row).not.toContain('<script>')
expect(row).not.toContain('<img')
expect(row).toContain('&lt;script')
expect(row).toContain('&lt;img')
expect(links(row)).toHaveLength(1)
expect(links(row)[0][1]).toContain('/source')
expect(text(links(row)[0][2]).trim()).toBe('')
expect(row).not.toContain('/published')
})
@@ -1,5 +1,160 @@
import { expect, test } from 'vitest'
import { getRunKind, editorHref, publishedHref, latestPhases } from './report.js'
import type { PiecesDiff } from '@luzzle/core'
import {
getRunKind,
editorHref,
publishedHref,
latestPhases,
mergePhaseLogs,
type PhaseLog,
selectablePaths,
initialSelection,
toggleSelection,
canPublishSelection,
retainSelection,
recordPublication,
completionReport,
pendingAfterPublication,
type PublicationOutcome
} from './report.js'
function pendingChanges(): PiecesDiff {
return {
schemas: { added: [], updated: [], pruned: [] },
pieces: { added: ['books/new.book.md'], updated: ['books/edited.book.md'], pruned: [] }
}
}
test('completion failures survive a rejected refresh with stale running metadata', () => {
const failures = [{ filePath: 'books/failed.book.md', message: 'missing image' }]
const stalePublication = {
jobId: 'publish-id',
state: 'running',
diff: null,
failedPieces: []
}
const report = completionReport(
{ id: 'publish-id', refreshed: false, failures },
stalePublication
)
expect(report).toEqual({ diff: null, failedPieces: failures })
expect(recordPublication(report.diff, report.failedPieces)).toEqual([
{ filePath: 'books/failed.book.md', state: 'failed', message: 'missing image' }
])
expect(stalePublication.failedPieces).toEqual([])
})
test('fresh metadata supplies the diff while completion events supply failures', () => {
const failures = [{ filePath: 'books/edited.book.md', message: 'missing image' }]
const diff = pendingChanges()
const publication = { jobId: 'publish-id', state: 'completed', diff, failedPieces: [] }
const report = completionReport({ id: 'publish-id', refreshed: true, failures }, publication)
expect(report).toEqual({ diff, failedPieces: failures })
expect(recordPublication(report.diff, report.failedPieces)).toEqual([
{ filePath: 'books/new.book.md', state: 'published' },
{ filePath: 'books/edited.book.md', state: 'failed', message: 'missing image' }
])
})
test.each([
{ name: 'unrefreshed metadata', jobId: 'publish-id', state: 'completed', refreshed: false },
{ name: 'another run', jobId: 'another-id', state: 'completed', refreshed: true },
{ name: 'still running', jobId: 'publish-id', state: 'running', refreshed: true }
])('completion does not trust the diff from $name', ({ jobId, state, refreshed }) => {
const failures = [{ filePath: 'books/edited.book.md', message: 'missing image' }]
const publication = { jobId, state, diff: pendingChanges() }
expect(completionReport({ id: 'publish-id', refreshed, failures }, publication)).toEqual({
diff: null,
failedPieces: failures
})
})
test('completion keeps a successful empty failure list authoritative', () => {
const publication = {
jobId: 'publish-id',
state: 'completed',
diff: pendingChanges(),
failedPieces: [{ filePath: 'books/edited.book.md', message: 'obsolete failure' }]
}
expect(
completionReport({ id: 'publish-id', refreshed: true, failures: [] }, publication).failedPieces
).toEqual([])
})
test('ordinary Check selects additions and updates, never schemas or removals', () => {
const diff = pendingChanges()
diff.pieces.pruned = ['books/removed.book.md']
diff.schemas.added = ['books']
expect(selectablePaths(diff)).toEqual(['books/new.book.md', 'books/edited.book.md'])
expect(initialSelection(diff, null)).toEqual(selectablePaths(diff))
})
test('single-item Check preselects exactly its pending path', () => {
const diff = pendingChanges()
expect(initialSelection(diff, 'books/edited.book.md')).toEqual(['books/edited.book.md'])
for (const file of ['books/unchanged.book.md', 'books/removed.book.md', '']) {
expect(initialSelection(diff, file)).toEqual([])
}
})
test('preselection compares literal filenames without URL decoding or normalization', () => {
const diff = pendingChanges()
const file = 'books/源 #?%2F.book.md'
diff.pieces.added.push(file)
expect(initialSelection(diff, file)).toEqual([file])
expect(initialSelection(diff, encodeURIComponent(file))).toEqual([])
})
test('selection toggles are deduplicated and do not mutate previous selections', () => {
const original = ['books/new.book.md']
const both = toggleSelection(original, 'books/edited.book.md', true)
expect(both).toEqual(['books/new.book.md', 'books/edited.book.md'])
expect(toggleSelection(both, 'books/edited.book.md', true)).toEqual(both)
expect(toggleSelection(both, 'books/new.book.md', false)).toEqual(['books/edited.book.md'])
expect(toggleSelection(original, 'books/new.book.md', false)).toEqual([])
expect(original).toEqual(['books/new.book.md'])
expect(both).toHaveLength(2)
})
test('duplicate pending paths appear only once in selection', () => {
const diff = pendingChanges()
diff.pieces.updated.push(diff.pieces.added[0])
expect(selectablePaths(diff)).toEqual(['books/new.book.md', 'books/edited.book.md'])
})
test('empty or stale selection cannot silently publish pending additions and updates', () => {
const diff = pendingChanges()
expect(canPublishSelection(diff, [])).toBe(false)
expect(canPublishSelection(diff, ['books/unknown.book.md'])).toBe(false)
expect(canPublishSelection(diff, ['books/edited.book.md'])).toBe(true)
expect(initialSelection(diff, null)).toHaveLength(2)
})
test('removals-only publishing stays available with no selected additions or updates', () => {
const diff = pendingChanges()
diff.pieces.pruned = ['books/removed.book.md']
expect(canPublishSelection(diff, [])).toBe(true)
})
test.each(['added', 'updated', 'pruned'] as const)(
'schema-only %s changes stay publishable without selecting any pieces',
(kind) => {
const diff = pendingChanges()
diff.pieces = { added: [], updated: [], pruned: [] }
diff.schemas[kind] = ['books']
expect(initialSelection(diff, null)).toEqual([])
expect(canPublishSelection(diff, [])).toBe(true)
}
)
test('up-to-date checks have no publishable work', () => {
const diff = pendingChanges()
diff.pieces = { added: [], updated: [], pruned: [] }
expect(canPublishSelection(diff, [])).toBe(false)
})
test('the actual workflow overrides the action used to attach to an existing run', () => {
expect(getRunKind('Publish', 'audit')).toBe('publish')
@@ -39,3 +194,266 @@ test.each([
expect(decodeURIComponent(url.pathname)).toContain(file)
}
})
function appliedChanges(pieces: Partial<PiecesDiff['pieces']> = {}): PiecesDiff {
return {
schemas: { added: [], updated: [], pruned: [] },
pieces: { added: [], updated: [], pruned: [], ...pieces }
}
}
test('fresh checks retain only explicitly selected, still-pending upserts without expanding choice', () => {
const fresh = appliedChanges({
added: ['new', 'kept', 'kept'],
updated: ['edited', 'kept'],
pruned: ['gone']
})
const selected = ['edited', 'stale', 'kept', 'edited', 'gone']
expect(retainSelection(fresh, selected)).toEqual(['edited', 'kept'])
expect(retainSelection(fresh, [])).toEqual([])
expect(retainSelection(appliedChanges(), selected)).toEqual([])
expect(selected).toEqual(['edited', 'stale', 'kept', 'edited', 'gone'])
expect(fresh.pieces.added).toEqual(['new', 'kept', 'kept'])
})
test('multiple publications accumulate actual outcomes in original insertion order', () => {
const first = recordPublication(appliedChanges({ added: ['one', 'retry'], pruned: ['gone'] }), [
{ filePath: 'retry', message: 'bad frontmatter' }
])
expect(first).toEqual([
{ filePath: 'one', state: 'published' },
{ filePath: 'retry', state: 'failed', message: 'bad frontmatter' },
{ filePath: 'gone', state: 'removed' }
])
const second = recordPublication(appliedChanges({ updated: ['retry', 'two'] }), [], first)
expect(second).toEqual([
{ filePath: 'one', state: 'published' },
{ filePath: 'retry', state: 'published' },
{ filePath: 'gone', state: 'removed' },
{ filePath: 'two', state: 'published' }
])
expect(second[1]).not.toHaveProperty('message')
expect(first[1]).toEqual({ filePath: 'retry', state: 'failed', message: 'bad frontmatter' })
})
test('only actual diff upserts become published, never skipped or previously selected targets', () => {
const previous: PublicationOutcome[] = [
{ filePath: 'skipped', state: 'failed', message: 'still failed' }
]
const outcomes = recordPublication(appliedChanges({ updated: ['confirmed'] }), [], previous)
expect(outcomes).toEqual([...previous, { filePath: 'confirmed', state: 'published' }])
expect(recordPublication(null, [], previous)).toEqual(previous)
expect(recordPublication(null, [])).toEqual([])
})
test('failures outside the applied diff are recorded and latest failures replace messages', () => {
const previous: PublicationOutcome[] = [
{ filePath: 'outside', state: 'published' },
{ filePath: 'retry', state: 'failed', message: 'old error' }
]
expect(
recordPublication(
null,
[
{ filePath: 'outside', message: 'now failed' },
{ filePath: 'retry', message: 'new error' },
{ filePath: 'extra', message: '<script> & failure' }
],
previous
)
).toEqual([
{ filePath: 'outside', state: 'failed', message: 'now failed' },
{ filePath: 'retry', state: 'failed', message: 'new error' },
{ filePath: 'extra', state: 'failed', message: '<script> & failure' }
])
expect(previous[0].state).toBe('published')
expect(previous[1].message).toBe('old error')
})
test('deduplicated removals win conflicting upserts and failures and clear old error messages', () => {
const diff = appliedChanges({
added: ['same', 'ok', 'ok'],
updated: ['same', 'ok'],
pruned: ['same', 'same']
})
expect(
recordPublication(
diff,
[{ filePath: 'same', message: 'error' }],
[{ filePath: 'same', state: 'failed', message: 'previous error' }]
)
).toEqual([
{ filePath: 'same', state: 'removed' },
{ filePath: 'ok', state: 'published' }
])
})
test('global removals and schema-only publications never imply piece success', () => {
const diff = appliedChanges({ pruned: ['gone'] })
diff.schemas = { added: ['new-schema'], updated: ['edited-schema'], pruned: ['old-schema'] }
expect(recordPublication(diff, [])).toEqual([{ filePath: 'gone', state: 'removed' }])
diff.pieces.pruned = []
expect(recordPublication(diff, [])).toEqual([])
})
test('provisional pending removes only confirmed healthy upserts and actual removals', () => {
const pending = appliedChanges({
added: ['ok', 'failed', 'unselected', 'removed'],
updated: ['edited', 'failed', 'unconfirmed'],
pruned: ['removed', 'not-removed', 'ok']
})
const applied = appliedChanges({
added: ['ok', 'failed'],
updated: ['edited'],
pruned: ['removed']
})
const failures = [
{ filePath: 'failed', message: 'bad data' },
{ filePath: 'outside', message: 'missing' }
]
const beforePending = structuredClone(pending)
const beforeApplied = structuredClone(applied)
expect(pendingAfterPublication(pending, applied, failures)).toEqual(
appliedChanges({
added: ['failed', 'unselected'],
updated: ['failed', 'unconfirmed'],
pruned: ['not-removed', 'ok']
})
)
expect(pending).toEqual(beforePending)
expect(applied).toEqual(beforeApplied)
expect(failures).toHaveLength(2)
})
test('provisional pending applies schema changes only to their matching kind', () => {
const pending = appliedChanges()
pending.schemas = {
added: ['new', 'cross-kind', 'keep-new'],
updated: ['edit', 'new', 'keep-edit'],
pruned: ['gone', 'edit', 'keep-gone']
}
const applied = appliedChanges()
applied.schemas = { added: ['new'], updated: ['edit', 'cross-kind'], pruned: ['gone'] }
const result = pendingAfterPublication(pending, applied, [])
expect(result?.schemas).toEqual({
added: ['cross-kind', 'keep-new'],
updated: ['new', 'keep-edit'],
pruned: ['edit', 'keep-gone']
})
expect(pending.schemas.added).toContain('new')
expect(result?.schemas).not.toBe(pending.schemas)
})
test('confirmed removal overrides a conflicting failure in provisional pending', () => {
const pending = appliedChanges({
added: ['gone', 'keep'],
updated: ['gone'],
pruned: ['gone', 'keep']
})
const applied = appliedChanges({ added: ['gone'], pruned: ['gone'] })
expect(
pendingAfterPublication(pending, applied, [{ filePath: 'gone', message: 'conflict' }])
).toEqual(appliedChanges({ added: ['keep'], pruned: ['keep'] }))
})
test('unconfirmed completion leaves pending unchanged and null pending stays null', () => {
const pending = pendingChanges()
expect(pendingAfterPublication(pending, null, [{ filePath: 'outside', message: 'error' }])).toBe(
pending
)
expect(pendingAfterPublication(null, appliedChanges({ added: ['ok'] }), [])).toBeNull()
expect(pendingAfterPublication(null, null, [])).toBeNull()
})
function phaseLog(overrides: Partial<PhaseLog> = {}): PhaseLog {
return {
job_id: 'job-one',
phase: 'cdn.sync',
line_number: 1,
ts: 100,
level: 'info',
message: 'Syncing',
...overrides
}
}
test('SSR seed and SSE replay retain each log identity once and append new lines in order', () => {
const first = phaseLog()
const second = phaseLog({ line_number: 2, message: 'Synced' })
const seeded = mergePhaseLogs({}, [first, { ...first }])
expect(seeded).toEqual({ 'cdn.sync': [first] })
expect(mergePhaseLogs(seeded, [second, { ...first }])).toEqual({
'cdn.sync': [first, second]
})
expect(mergePhaseLogs({}, [])).toEqual({})
})
test('repeated SSE frames do not create duplicate log or DOM identities', () => {
const frame = [phaseLog({ line_number: 3 }), phaseLog(), phaseLog({ line_number: 2 })]
const first = mergePhaseLogs({}, frame)
const replayed = mergePhaseLogs(
first,
frame.map((row) => ({ ...row }))
)
expect(replayed).toEqual(first)
expect(replayed['cdn.sync'].map((row) => row.line_number)).toEqual([1, 2, 3])
const keys = replayed['cdn.sync'].map((row) => `${row.job_id}:${row.line_number}`)
expect(new Set(keys).size).toBe(keys.length)
})
test('log identity includes both job and phase as well as line number', () => {
const first = phaseLog()
const otherJob = phaseLog({ job_id: 'job-two' })
const otherPhase = phaseLog({ phase: 'cache.purge' })
const later = phaseLog({ job_id: 'job-two', line_number: 2 })
const seeded = mergePhaseLogs({}, [later, first, otherPhase])
const merged = mergePhaseLogs(seeded, [otherJob, { ...first }, { ...otherPhase }])
expect(merged).toEqual({
'cdn.sync': [first, otherJob, later],
'cache.purge': [otherPhase]
})
})
test('identical log payloads remain distinct when line identities differ', () => {
const first = phaseLog()
const second = phaseLog({ line_number: 2 })
expect(mergePhaseLogs({}, [second, first, { ...first }])).toEqual({
'cdn.sync': [first, second]
})
})
test('incoming rows replace existing identities with the freshest supplied row', () => {
const first = phaseLog()
const next = phaseLog({ line_number: 2 })
const update = phaseLog({ ts: 200, level: 'warn', message: 'Retrying' })
const freshest = phaseLog({ ts: 300, level: 'error', message: 'Failed' })
const merged = mergePhaseLogs({ 'cdn.sync': [first, next] }, [update, freshest])
expect(merged).toEqual({ 'cdn.sync': [freshest, next] })
expect(merged['cdn.sync'][0]).toBe(freshest)
})
test('merging logs does not mutate input records, arrays, or rows', () => {
const current = {
'cdn.sync': [phaseLog({ line_number: 3 }), phaseLog()],
'cache.purge': [phaseLog({ phase: 'cache.purge' })]
}
const incoming = [phaseLog({ line_number: 2 }), phaseLog({ message: 'Updated' })]
const beforeCurrent = structuredClone(current)
const beforeIncoming = structuredClone(incoming)
for (const rows of [...Object.values(current), incoming]) {
for (const row of rows) Object.freeze(row)
Object.freeze(rows)
}
Object.freeze(current)
const merged = mergePhaseLogs(current, incoming)
expect(current).toEqual(beforeCurrent)
expect(incoming).toEqual(beforeIncoming)
expect(merged).not.toBe(current)
expect(merged['cdn.sync']).not.toBe(current['cdn.sync'])
expect(merged['cache.purge']).not.toBe(current['cache.purge'])
expect(merged['cdn.sync'].map((row) => row.line_number)).toEqual([1, 2, 3])
expect(mergePhaseLogs(current, [])).toEqual({
'cdn.sync': [current['cdn.sync'][1], current['cdn.sync'][0]],
'cache.purge': current['cache.purge']
})
})
@@ -1,5 +1,95 @@
import type { PiecesDiff } from '@luzzle/core'
import type { PublishPieceFailure } from '@luzzle/web.jobs'
export { editorHref, publishedHref } from '$lib/pieces/navigation'
export function selectablePaths(diff: PiecesDiff): string[] {
return [...new Set([...diff.pieces.added, ...diff.pieces.updated])]
}
export type PublicationOutcome = {
filePath: string
state: 'published' | 'failed' | 'removed'
message?: string
}
export function completionReport(
completion: { id: string; refreshed: boolean; failures: PublishPieceFailure[] },
publication: { jobId: string; state: string; diff: PiecesDiff | null } | null
): { diff: PiecesDiff | null; failedPieces: PublishPieceFailure[] } {
const unavailable = { diff: null, failedPieces: completion.failures }
if (!completion.refreshed) return unavailable
if (!publication || publication.jobId !== completion.id) return unavailable
if (publication.state !== 'completed') return unavailable
return { diff: publication.diff, failedPieces: completion.failures }
}
export function recordPublication(
diff: PiecesDiff | null,
failures: PublishPieceFailure[],
previous: PublicationOutcome[] = []
): PublicationOutcome[] {
const outcomes = new Map(previous.map((outcome) => [outcome.filePath, outcome]))
const failed = new Set(failures.map((failure) => failure.filePath))
for (const filePath of diff ? selectablePaths(diff) : []) {
if (!failed.has(filePath)) outcomes.set(filePath, { filePath, state: 'published' })
}
for (const { filePath, message } of failures) {
outcomes.set(filePath, { filePath, state: 'failed', message })
}
for (const filePath of diff?.pieces.pruned ?? []) {
outcomes.set(filePath, { filePath, state: 'removed' })
}
return [...outcomes.values()]
}
export function pendingAfterPublication(
pending: PiecesDiff | null,
diff: PiecesDiff | null,
failures: PublishPieceFailure[]
): PiecesDiff | null {
if (!pending || !diff) return pending
const confirmed = recordPublication(diff, failures)
const confirmedPiecePaths = new Set(
confirmed.filter((outcome) => outcome.state !== 'failed').map((outcome) => outcome.filePath)
)
const removed = new Set(diff.pieces.pruned)
return {
pieces: {
added: pending.pieces.added.filter((file) => !confirmedPiecePaths.has(file)),
updated: pending.pieces.updated.filter((file) => !confirmedPiecePaths.has(file)),
pruned: pending.pieces.pruned.filter((file) => !removed.has(file))
},
schemas: {
added: pending.schemas.added.filter((schema) => !diff.schemas.added.includes(schema)),
updated: pending.schemas.updated.filter((schema) => !diff.schemas.updated.includes(schema)),
pruned: pending.schemas.pruned.filter((schema) => !diff.schemas.pruned.includes(schema))
}
}
}
export function retainSelection(diff: PiecesDiff, paths: string[]): string[] {
const pending = new Set(selectablePaths(diff))
return [...new Set(paths)].filter((file) => pending.has(file))
}
export function initialSelection(diff: PiecesDiff, requestedFile: string | null): string[] {
const paths = selectablePaths(diff)
if (requestedFile === null) return paths
return paths.filter((file) => file === requestedFile)
}
export function toggleSelection(paths: string[], file: string, checked: boolean): string[] {
if (checked) return [...new Set([...paths, file])]
return paths.filter((path) => path !== file)
}
export function canPublishSelection(diff: PiecesDiff, paths: string[]): boolean {
const available = new Set(selectablePaths(diff))
if (paths.some((file) => available.has(file))) return true
return [diff.pieces.pruned, ...Object.values(diff.schemas)].some((list) => list.length > 0)
}
export type ActiveKind = 'audit' | 'publish'
export function getRunKind(workflowName: unknown, fallback: ActiveKind | null): ActiveKind | null {
@@ -8,6 +98,50 @@ export function getRunKind(workflowName: unknown, fallback: ActiveKind | null):
return fallback
}
export type PhaseProgress = {
phase: string
status: string
started_at: number
finished_at: number | null
message: string | null
}
export type PhaseLog = {
job_id: string
phase: string
line_number: number
ts: number
level: string
message: string
}
export function mergePhaseLogs(
current: Record<string, PhaseLog[]>,
incoming: PhaseLog[]
): Record<string, PhaseLog[]> {
const phases = new Map<string, Map<string, PhaseLog>>()
for (const phase of Object.keys(current)) {
phases.set(phase, new Map())
}
for (const rows of [...Object.values(current), incoming]) {
for (const row of rows) {
let phaseRows = phases.get(row.phase)
if (!phaseRows) {
phaseRows = new Map()
phases.set(row.phase, phaseRows)
}
const identity = JSON.stringify([row.job_id, row.line_number])
phaseRows.set(identity, row)
}
}
return Object.fromEntries(
[...phases].map(([phase, rows]) => [
phase,
[...rows.values()].sort((a, b) => a.line_number - b.line_number)
])
)
}
export function latestPhases<T extends { phase: string }>(rows: T[]): T[] {
const latestByPhase = new Map<string, T>()
for (const row of rows) {
@@ -14,11 +14,3 @@ test.each(['publish/+page.svelte', 'piece/[...path]/preview/[jobId]/+layout.svel
expect(source).toMatch(/streamDisconnected = false\s+eventSource = new EventSource/)
}
)
test('Publish uses its existing status title and stops the spinner when disconnected', () => {
const source = readFileSync(new URL('./publish/+page.svelte', import.meta.url), 'utf8')
expect(source).toMatch(
/<div class="status-text">\s*\{#if streamDisconnected\}\s*<div class="status-title">Refresh to reconnect<\/div>/
)
expect(source).toContain("{#if status === 'running' && !streamDisconnected}")
})
+10
View File
@@ -17,6 +17,16 @@ With [authentication configured](config.md#sign-in), open `/admin` to manage
pieces. Save edits, preview them, then use `/admin/publish` to check changes and
publish. Saving a file is not the same as publishing it.
Check lists all pending changes. Choose which added or updated pieces to publish.
Publishing uses saved files, so edits saved after Check may be included.
Unselected edits stay pending.
**Every publish also applies all pending removals**, regardless of selection.
They are listed separately; you can apply removals without selecting any pieces.
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.
Source and text-asset editors start with Standard keybindings. Press
**Ctrl+Alt+V** while focused to toggle Vim mode; the browser remembers your choice.
Use the Save button: Vim save/quit commands do not trigger application actions.
@@ -87,7 +87,9 @@ test.each([
expect(result.body).toContain(long)
expect(result.head).toMatch(new RegExp(`\\.${panel}[^{}]*\\{[^}]*min-width:\\s*0`))
for (const heading of ['h1', 'h2']) {
const rule = result.head.match(new RegExp(`\\.${panel}[^{}]*${heading}[^{}]*\\{([^}]+)`))?.[1]
const rule = result.head.match(
new RegExp(`\\.${panel}[^{}]*[\\s>]${heading}\\b[^{}]*\\{([^}]+)`)
)?.[1]
expect(rule).toMatch(/max-width:\s*100%/)
expect(rule).toMatch(/overflow-wrap:\s*anywhere/)
}