[web] simplify publication selection and results #164
No files matched your search
@@ -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(/<script>not executable & still visible<\/script>/g)).toHaveLength(
|
||||
3
|
||||
)
|
||||
expect(output).toContain('<img src=x> & 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('<img')
|
||||
expect(output).toContain('& notes.books.md')
|
||||
expect(section(output, 'Removals')).toContain('removed/<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('<img')
|
||||
expect(output).toContain('& notes.books.md')
|
||||
expect(output).toContain('<script')
|
||||
expect(output).toContain('& 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(?:39|x27);/g, "'")
|
||||
.replace(/&/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('<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('<script')
|
||||
expect(row).toContain('<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}")
|
||||
})
|
||||
@@ -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/)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user