From d2583999fd4f1bedc7fcc529a726b70c1e076984 Mon Sep 17 00:00:00 2001 From: eleith-gitea-agent Date: Wed, 28 Oct 2026 17:39:55 +0000 Subject: [PATCH] [web] simplify publication selection and results --- .changeset/publish-selection-screen.md | 7 + .../routes/(auth)/admin/publish/+page.svelte | 1350 +++++------------ .../admin/publish/PublishDetails.svelte | 327 ++++ .../admin/publish/PublishDetails.test.ts | 298 ++++ .../(auth)/admin/publish/PublishTable.svelte | 377 +++++ .../(auth)/admin/publish/PublishTable.test.ts | 537 +++++++ .../routes/(auth)/admin/publish/page.test.ts | 556 ++++++- .../(auth)/admin/publish/report.test.ts | 420 ++++- .../src/routes/(auth)/admin/publish/report.ts | 134 ++ .../routes/(auth)/admin/stream-status.test.ts | 8 - docs/web/README.md | 10 + .../web/worker/src/pieces/compile.test.ts | 4 +- 12 files changed, 3036 insertions(+), 992 deletions(-) create mode 100644 .changeset/publish-selection-screen.md create mode 100644 apps/web/src/routes/(auth)/admin/publish/PublishDetails.svelte create mode 100644 apps/web/src/routes/(auth)/admin/publish/PublishDetails.test.ts create mode 100644 apps/web/src/routes/(auth)/admin/publish/PublishTable.svelte create mode 100644 apps/web/src/routes/(auth)/admin/publish/PublishTable.test.ts diff --git a/.changeset/publish-selection-screen.md b/.changeset/publish-selection-screen.md new file mode 100644 index 00000000..479f4572 --- /dev/null +++ b/.changeset/publish-selection-screen.md @@ -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. diff --git a/apps/web/src/routes/(auth)/admin/publish/+page.svelte b/apps/web/src/routes/(auth)/admin/publish/+page.svelte index 12c2f55d..696c1472 100644 --- a/apps/web/src/routes/(auth)/admin/publish/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/publish/+page.svelte @@ -1,166 +1,223 @@

Publish

+
+ + +
- {#if errorMessage && status === 'failed'} -
- - {errorMessage} -
- {/if} - -
-
-

- {#if status === 'idle'} - Publish changes - {:else if busy} - {#if activeKind === 'publish'} - Publishing... +
+ {#if showStatus} +
+

- {#if phases.length > 0 && !busy} - - {#if status === 'completed' && activeKind === 'publish'} - {totalStages} stages · {formatDuration(overallDuration)} - {:else if status === 'completed' && activeKind === 'audit'} - checked in {formatDuration(overallDuration)} + {/if} - {/if} -
- - {#if status === 'enqueued' || status === 'running'} - {#if activeKind === 'audit'} -
- - Comparing local files with the database... -
- {:else} -
- - Syncing database, generating assets, and deploying... -
- {/if} - {:else if status === 'completed'} - {#if activeKind === 'publish' && publishFailures.length > 0} - - {/if} - {#if activeKind === 'publish' && publishDiff} - {@render changeList(publishDiff, publishedHref)} - {:else if activeKind === 'audit' && auditDiff} - {@render changeList(auditDiff, editorHref)} - {:else} -

No changes to display.

- {/if} - {:else if status === 'failed'} -

- An error occurred. Check the timeline and logs below for details. -

- {:else} -

- Select an action below to check for changes or publish immediately. -

+ + {#if streamDisconnected} + Connection lost — refresh to reconnect. + {:else if displayFailure} + {#if displayFailure.afterPublication}Could not refresh pending changes. + {/if} + {#if displayFailure.reason === 'pending-refresh'} + Could not refresh pending changes. Check again. + {:else if displayFailure.reason === 'checked-changes'} + Could not load checked changes. Check again. + {:else if displayFailure.reason === 'publication-refresh'} + Could not refresh publication results. Check again. + {:else if displayFailure.reason === 'conflict'} + Another publication operation is running. Try again when it finishes. + {:else if displayFailure.reason === 'server'} + Server error: {displayFailure.status} + {:else if displayFailure.reason === 'stream'} + {#if displayFailure.details}{displayFailure.details}{:else}Stream error{/if} + {:else} + {displayFailure.details} + {/if} + {:else if busy && checkingAfterPublication} + Checking remaining changes… + {:else if status === 'idle'} + Checking changes… + {:else if status === 'submitting'} + Starting… + {:else if status === 'enqueued'} + Waiting for worker… + {:else if status === 'running'} + {#if activeKind === 'publish'} + Publishing{#if totalStages > 0} + · {completedStages}/{totalStages}{:else}…{/if} + {:else} + Checking changes… + {/if} + {:else if publicationState === 'failed'} + {#if publicationError}{publicationError}{:else}Publish failed{/if} + {:else if publicationState === 'complete'} + {#if outcomes.some((outcome) => outcome.state === 'failed')}Published with errors{:else}Published{/if} + {:else if status === 'failed'} + {#if activeKind === 'publish'}Publish failed{:else}Check failed{/if} + {:else if auditRunId && missingPreselection} + No pending additions or updates for this item. + {:else} + Up to date + {/if} + +
{/if} -
-
- - - - -
-
- - -
-
+ + {/if} - {#if busy || status === 'failed'} -
-
- - {#if status === 'running' && !streamDisconnected} - - {:else if status === 'enqueued' || streamDisconnected} - - {:else} - - {/if} - - -
- {#if streamDisconnected} -
Refresh to reconnect
- {:else if status === 'enqueued'} -
Enqueued
-
Waiting for worker…
- {:else if status === 'running'} -
- {#if activeKind === 'publish'}Publishing{:else}Checking for changes{/if} -
-
- {completedStages}/{totalStages} stages · - {formatDuration(overallDuration)} elapsed -
- {:else} -
- {#if activeKind === 'publish'}Publishing failed{:else}Checking for changes failed{/if} -
-
- {completedStages}/{totalStages} stages · {formatDuration(overallDuration)} -
- {/if} -
-
-
- {/if} - - {#if phases.length > 0} -
- {#each timelinePhases as phase (phase.phase)} - {@const hasLogs = (logs[phase.phase]?.length ?? 0) > 0} -
-
- - {#if phase.status === 'completed'} - - {:else if phase.status === 'running'} - - {:else if phase.status === 'failed'} - - {:else if phase.status === 'skipped'} - - {:else} - - {/if} - - - {phase.phase} - - {#if phase.message} - - {phase.message} - - {/if} - - {#if phase.status === 'skipped'} - — - {/if} -
- - {#if hasLogs} -
-
- {logs[phase.phase]?.length || 0} lines -
- {#if phase.started_at} - - {formatDuration((phase.finished_at || Date.now()) - phase.started_at)} - - {/if} - -
-
-
handleScroll(phase.phase, e)} - > - {#each logs[phase.phase] ?? [] as log (log.line_number)} -
- - {new Date(log.ts).toISOString().split('T')[1].slice(0, -5)} - - {log.message} -
- {/each} -
-
- {/if} -
- {/each} -
- {/if} +
-{#snippet changeList(diff: PiecesDiff, hrefFor: (file: string) => string | null)} - {#if hasChanges(diff)} -
- {@render changeGroup('added', 'Added', diff.schemas.added, diff.pieces.added, hrefFor)} - {@render changeGroup( - 'updated', - 'Updated', - diff.schemas.updated, - diff.pieces.updated, - hrefFor - )} - {@render changeGroup('pruned', 'Pruned', diff.schemas.pruned, diff.pieces.pruned, noHref)} -
- {:else} -
- Up to date — no pending changes. -
- {/if} -{/snippet} - -{#snippet changeGroup( - kind: string, - label: string, - schemas: string[], - pieces: string[], - hrefFor: (file: string) => string | null -)} - {#if schemas.length > 0 || pieces.length > 0} -
- {label} - -
- {/if} -{/snippet} - diff --git a/apps/web/src/routes/(auth)/admin/publish/PublishDetails.svelte b/apps/web/src/routes/(auth)/admin/publish/PublishDetails.svelte new file mode 100644 index 00000000..d35cf173 --- /dev/null +++ b/apps/web/src/routes/(auth)/admin/publish/PublishDetails.svelte @@ -0,0 +1,327 @@ + + +{#if phases.length > 0 || hasSchemaChanges} +
+ + Details + + {#if hasSchemaChanges && schemas} +
+

+ {#if schemasAlwaysIncluded} + Piece types · always included + {:else} + Piece types + {/if} +

+
    + {#each schemaKinds as kind (kind)} + {#each schemas[kind] as name (name)} +
  • + + {#if kind === 'added'}New{:else if kind === 'updated'}Updated{:else}Removed{/if} + + {name} +
  • + {/each} + {/each} +
+
+ {/if} + {#if phases.length > 0} +

{timelinePhases.length} stages · {formatDuration(overallDuration)}

+
+ {#each timelinePhases as phase (phase.phase)} +
+
+ + {phase.phase} + {#if phase.message}{phase.message}{/if} +
+ {#if logs[phase.phase]?.length} +
+
+ {logs[phase.phase].length} lines + +
+
handleScroll(phase.phase, event)} + > + {#each logs[phase.phase] as log (JSON.stringify([log.job_id, log.line_number]))} +
+ {new Date(log.ts).toISOString().split('T')[1].slice(0, -5)} + {log.message} +
+ {/each} +
+
+ {/if} +
+ {/each} +
+ {/if} +
+{/if} + + diff --git a/apps/web/src/routes/(auth)/admin/publish/PublishDetails.test.ts b/apps/web/src/routes/(auth)/admin/publish/PublishDetails.test.ts new file mode 100644 index 00000000..afd89c76 --- /dev/null +++ b/apps/web/src/routes/(auth)/admin/publish/PublishDetails.test.ts @@ -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 +const started = Date.parse('2026-01-02T03:04:00Z') + +function phase( + overrides: Partial = {} +): 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 { + 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(//g, '') +} + +function text(html: string) { + return html + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +function rows(html: string) { + return [...html.matchAll(/]*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(/]*>/)?.[0] + expect(disclosure).toBeDefined() + expect(disclosure).not.toMatch(/\sopen(?:\s|=|>)/) + expect(output.match(/]*>([\s\S]*?)<\/summary>/)?.[1] ?? '' + expect(text(summary)).toBe('Details') + expect(summary).toMatch(/ { + const output = details({ + schemas: { added: ['books'], updated: ['films'], pruned: ['notes'] } + }) + expect(output).toMatch(/]*>Piece types<\/h2>/) + const entries = [...output.matchAll(/]*>([\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(/ { + const output = details({ schemas: { ...emptySchemas, [kind]: ['only-type'] } }) + expect(output).toMatch(/ { + const output = details({ + schemas: { ...emptySchemas, updated: ['books'] }, + schemasAlwaysIncluded: true, + phases: [phase()] + }) + const heading = output.match(/]*>([\s\S]*?)<\/h2>/)?.[1] ?? '' + expect(text(heading)).toBe('Piece types · always included') + expect(output.indexOf('')).toBeLessThan(output.indexOf('stages')) + expect(output.match(/ { + const output = details({ schemas: emptySchemas, schemasAlwaysIncluded: true, phases: [phase()] }) + expect(output).not.toMatch(/ { + 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(/ { + 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(/]*)>([\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(/ { + 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(/ { + 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(/ { + const unsafe = '' + const phaseName = ' & 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. diff --git a/apps/web/src/routes/(auth)/admin/publish/PublishTable.svelte b/apps/web/src/routes/(auth)/admin/publish/PublishTable.svelte new file mode 100644 index 00000000..339dd22a --- /dev/null +++ b/apps/web/src/routes/(auth)/admin/publish/PublishTable.svelte @@ -0,0 +1,377 @@ + + +
+ {#if displayOrder.length > 0} +
+ {#if selectablePaths.length > 0} + + {:else} +

Pieces

+ {/if} +
    + {#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()} + + {file} + {#if failed && !publishing && outcome?.message} + {outcome.message} + {/if} + + {#if publishing} + Publishing + {:else if published} + Published + {:else} + + {#if failed} + Failed + {:else if diff?.pieces.added.includes(file)} + New + {:else} + Updated + {/if} + + {/if} + {/snippet} +
  • + {#if pending} + + {:else} +
    + + {#if published} + {@render details()} +
    + {/if} + + +
  • + {/each} +
+
+ {/if} + + {#if removals.length > 0} +
+

+ {#if pendingRemovals.size > 0} + Removals · always included + {:else} + Removals + {/if} +

+
    + {#each removals as file (file)} +
  • + {file} + {#if !pendingRemovals.has(file)}Removed{/if} +
  • + {/each} +
+
+ {/if} + + {#if displayOrder.length === 0 && removals.length === 0} +

No piece changes.

+ {/if} +
+ + diff --git a/apps/web/src/routes/(auth)/admin/publish/PublishTable.test.ts b/apps/web/src/routes/(auth)/admin/publish/PublishTable.test.ts new file mode 100644 index 00000000..22773089 --- /dev/null +++ b/apps/web/src/routes/(auth)/admin/publish/PublishTable.test.ts @@ -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 { + 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(//g, '') +} + +function html(changes = diff(), selectedPaths?: string[]) { + return render(PublishTable, { + props: { diff: changes, ...(selectedPaths === undefined ? {} : { selectedPaths }) } + }).body.replace(//g, '') +} + +function labels(output: string): string[] { + return [...output.matchAll(/]*>([\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(/]*>/)?.[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(/]*)>([\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(/\\s*${badge}\\s*`)) + expect(label).not.toMatch(/ { + const pending = section(html(), 'Pieces') + const row = [...pending.matchAll(/]*>([\s\S]*?)<\/li>/g)].find((match) => + match[1].includes(added) + )?.[1] + if (!row) throw new Error('Missing added row') + const link = row.match(/]*)>([\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(/]*>[\s\S]*?<\/svg>/g, '').trim()).toBe('') + expect(row.indexOf('')).toBeLessThan(row.indexOf(' { + 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(/ { + 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(/ { + const output = html(diff(), [removed]) + const removals = section(output, 'Removals') + expect(removals).toContain('Removals · always included') + expect(removals).toContain(removed) + expect(removals.match(/ { + 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 = ' & notes.books.md' + const output = html( + diff({ added: [unsafe], updated: [`updated/${unsafe}`], pruned: [`removed/${unsafe}`] }) + ) + expect(output).not.toMatch(/]*>([\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(/ { + const output = table({ outcomes: [{ filePath: added, state: 'published' }] }) + const pieces = section(output, 'Pieces') + expect(pieces).toMatch(/]*>Pieces<\/h3>/) + expect(pieces).not.toContain('Select all') + expect(pieces).not.toMatch(/<(?:input|label)\b/) + const row = rowFor(pieces, added) + const links = [...row.matchAll(/]*)>([\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(/${added}`) + 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(/ { + 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(/]*>([\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(/]*>\s*Removed\s*<\/span>/) + expect(completed).toMatch(/]*>\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(/]*>Removals<\/h3>/) + expect(removals).not.toContain('always included') + expect(removals.match(/ { + 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(/]*)>/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(/ { + const output = table({ + outcomes: [{ filePath: added, state: 'failed', message: 'Cannot parse YAML' }] + }) + const pieces = section(output, 'Pieces') + expect(pieces).toMatch(/]*>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(/ { + const unsafe = ' & notes.books.md' + const message = ' & 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(/ { + 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(/ { + 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(/ { + 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(/ { + 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(/ { + 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(/ ({ 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['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['pieces'] + publish?: boolean + state?: RunView['state'] + schemas?: NonNullable['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 }).body } -test('a completed partial publish stays visible after reload without an all-success claim', () => { +function withoutComments(output: string) { + return output.replace(//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(/]*)>([\s\S]*?)<\/button>/g)] +} + +function links(output: string) { + return [...withoutComments(output).matchAll(/])*)>([\s\S]*?)<\/a>/g)] +} + +function region(output: string, label: string) { + const matches = [ + ...withoutComments(output).matchAll( + /]*\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(/]*>([\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(/]*)>([\s\S]*?)<\/details>/g)] + const match = details.find((item) => { + const label = item[2].match(/]*>([\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(/]*>/g)).toHaveLength(1) + expect(markup).toMatch(/]*>\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(/]*>/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(/gone\.book\.md<\/span>/) + const output = html({ pieces: { added: [], updated: [], pruned: [file] } }) + const removals = region(output, 'Removals') + expect(removals).toMatch(/]*>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(//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: '' }]) - expect(output).not.toContain('') - 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(/]*>([\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(/]*\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(/]*\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(/]*\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(/]*\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(/]*\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/ & "quote".book.md' + const message = ' & 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('