From e666e0455d01bd67b7e15a46ebb69c9bfd763d28 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 09:40:57 -0800 Subject: [PATCH] [web] unify admin step logs --- .changeset/admin-step-logs.md | 5 + apps/web/src/lib/components/StepLog.svelte | 285 ++++++++++++++++ apps/web/src/lib/components/StepLog.test.ts | 53 +++ .../preview/[jobId]/PreviewLogs.test.ts | 174 ++-------- .../preview/[jobId]/WorkflowConsole.svelte | 319 +----------------- .../preview/[jobId]/WorkflowConsole.test.ts | 195 +++-------- .../admin/publish/PublishDetails.svelte | 289 ++-------------- .../admin/publish/PublishDetails.test.ts | 279 ++------------- .../routes/(auth)/admin/publish/page.test.ts | 71 +--- docs/web/README.md | 4 + 10 files changed, 498 insertions(+), 1176 deletions(-) create mode 100644 .changeset/admin-step-logs.md create mode 100644 apps/web/src/lib/components/StepLog.svelte create mode 100644 apps/web/src/lib/components/StepLog.test.ts diff --git a/.changeset/admin-step-logs.md b/.changeset/admin-step-logs.md new file mode 100644 index 00000000..4ccf428c --- /dev/null +++ b/.changeset/admin-step-logs.md @@ -0,0 +1,5 @@ +--- +"@luzzle/web": patch +--- + +Share a compact step-log layout between Publish and Preview, with step/status in the header and line count, elapsed seconds and a compact scroll lock below the output. Keep Publish logs visible without a disclosure. diff --git a/apps/web/src/lib/components/StepLog.svelte b/apps/web/src/lib/components/StepLog.svelte new file mode 100644 index 00000000..de9fcb32 --- /dev/null +++ b/apps/web/src/lib/components/StepLog.svelte @@ -0,0 +1,285 @@ + + +
+
+

{phase.phase}

+ + {#if waiting} + Queued + {:else if phase.status === 'running'} + Running + {:else if phase.status === 'completed'} + Completed + {:else if phase.status === 'failed'} + Failed + {:else if phase.status === 'skipped'} + Skipped + {:else if phase.status === 'cancelled' || phase.status === 'canceled'} + Cancelled + {:else if phase.status === 'expired'} + Expired + {:else if phase.status} + {phase.status} + {:else} + Unknown + {/if} + +
+
+ {#if phase.message} +

{phase.message}

+ {/if} + {#each logs as log (JSON.stringify([log.job_id ?? '', log.line_number]))} + {@const timestamp = new Date(log.ts).toISOString()} +
+ + {log.message} +
+ {:else} +

+ {#if phase.status === 'running' || waiting} + Waiting for output… + {:else} + No output recorded. + {/if} +

+ {/each} +
+
+ {logs.length} + {#if logs.length === 1}line{:else}lines{/if} + +
+
+ + diff --git a/apps/web/src/lib/components/StepLog.test.ts b/apps/web/src/lib/components/StepLog.test.ts new file mode 100644 index 00000000..8841e31a --- /dev/null +++ b/apps/web/src/lib/components/StepLog.test.ts @@ -0,0 +1,53 @@ +import { render } from 'svelte/server' +import { expect, test } from 'vitest' +import StepLog from './StepLog.svelte' + +test('puts status above escaped terminal output and line count, seconds and lock below it', () => { + const started = Date.parse('2026-01-02T03:04:00Z') + const { body } = render(StepLog, { + props: { + phase: { + phase: 'render', + status: 'failed', + started_at: started, + finished_at: started + 65_000, + message: '' + }, + logs: [ + { + phase: 'render', + line_number: 1, + ts: started, + level: 'stderr', + message: ' & output' + } + ] + } + }) + + const header = body.match(/]*>([\s\S]*?)<\/header>/)?.[1] ?? '' + const footer = body.match(/]*>([\s\S]*?)<\/footer>/)?.[1] ?? '' + const headerStart = body.indexOf(']*>render<\/h3>/) + expect(header).toContain('Failed') + expect(header).not.toContain('aria-label="Auto-scroll"') + expect(terminal).toContain('<script>Failure & details</script>') + expect(terminal).toContain('<img src=x> & output') + expect(body).not.toMatch(/<(?:script|img)\b/) + expect( + footer + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + ).toBe('1 line 65s') + expect(footer).toMatch(/]*aria-label="Auto-scroll"/) + expect(footer).toContain('aria-pressed="true"') + expect(footer).toContain('title="Auto-scroll locked"') +}) diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts index 27ea2a0b..735a94d3 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts @@ -1,146 +1,42 @@ import { expect, test } from 'vitest' import { render } from 'svelte/server' -import type { ComponentProps } from 'svelte' -import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js' import PreviewLogs from './PreviewLogs.svelte' -const started = Date.parse('2026-01-02T03:04:00Z') -const phases: ProgressPhase[] = [ - { - phase: 'render', - status: 'completed', - started_at: started, - finished_at: started + 65_000, - message: 'Rendered successfully' - }, - { - phase: 'assets', - status: 'failed', - started_at: started + 65_000, - finished_at: started + 90_000, - message: 'Asset failed' - } -] -const logs: Record = { - render: [ - { phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' }, - { - phase: 'render', - line_number: 2, - ts: started + 1000, - level: 'info', - message: 'Recorded render detail' +// This wrapper owns the labelled section and forwarding, not StepLog's controls. +test('labels the preview log section and forwards phase, output and failure details', () => { + const started = Date.parse('2026-01-02T03:04:00Z') + const { body } = render(PreviewLogs, { + props: { + status: 'failed', + errorMessage: 'Preview job failed', + phases: [ + { + phase: 'render', + status: 'failed', + started_at: started, + finished_at: started + 65_000, + message: 'Renderer stopped' + } + ], + logs: { + render: [ + { + phase: 'render', + line_number: 1, + ts: started, + level: 'stderr', + message: 'Missing template' + } + ] + } } - ], - assets: [ - { - phase: 'assets', - line_number: 1, - ts: started + 65_000, - level: 'stderr', - message: 'Recorded asset error' - } - ] -} + }) -function props( - overrides: Partial> = {} -): ComponentProps { - return { status: 'completed', phases, logs, ...overrides } -} - -function text(html: string) { - return html - .replace(/<[^>]+>/g, '') - .replace(/\s+/g, ' ') - .trim() -} - -// SSR checks delegation and initial controls, not browser scrolling, SSE, or onMount behavior. -test('logs are a labelled section with an instance-scoped heading, not a main or H1', () => { - const first = render(PreviewLogs, { props: props(), idPrefix: 'first' }).body - const second = render(PreviewLogs, { props: props(), idPrefix: 'second' }).body - const headingId = (html: string) => html.match(/]*id="([^"]+)"/)?.[1] - for (const html of [first, second]) { - expect(headingId(html)).toBeTruthy() - expect(html).toContain(`aria-labelledby="${headingId(html)}"`) - expect(html).toMatch(/]*>Preview logs<\/h2>/) - expect(html).not.toContain(' { - const html = render(PreviewLogs, { props: props({ status, phases: [], logs: {} }) }).body - expect(html).not.toContain('status-card') - expect(html).not.toContain('status-title') - expect(html).not.toContain('status-sub') - expect(text(html)).toBe('Preview logs') - } -) - -test('preserves native phase groups, timing, log content and initial lock controls', () => { - const html = render(PreviewLogs, { props: props() }).body - const content = text(html) - for (const expected of [ - 'render', - 'assets', - 'Rendered successfully', - 'Asset failed', - '1m 5s', - '25s', - '2 lines', - '1 lines', - '03:04:00', - '03:04:01', - '03:05:05', - 'Output ready', - 'Recorded render detail', - 'Recorded asset error' - ]) - expect(content).toContain(expected) - expect(html.match(/class="log-viewport/g)).toHaveLength(2) - expect(html.match(/ - - -
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} @@ -178,195 +52,6 @@ display: flex; flex-direction: column; gap: var(--space-4); - } - - .phase { - display: flex; - flex-direction: column; - gap: 2px; - } - - .phase-row { - padding: var(--space-2) 0; - display: flex; - align-items: center; - gap: var(--space-3); - } - - .phase-icon-wrap { - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - } - - .phase-icon-wrap :global(.phase-icon) { - font-size: 18px; - } - - .phase-icon-wrap.status-completed { - color: var(--color-primary); - } - - .phase-icon-wrap.status-running { - color: var(--color-secondary); - } - - .phase-icon-wrap.status-failed { - color: var(--color-error); - } - - .phase-icon-wrap.status-skipped { - color: var(--color-on-surface-variant); - } - - .phase-dot { - width: 8px; - height: 8px; - border-radius: 50%; - border: 1.5px solid var(--color-outline-variant); - } - - .phase-name { - font-size: var(--font-size-small); - font-weight: var(--font-weight-medium); - color: var(--color-on-surface); - flex-shrink: 0; - } - - .phase-message { - font-size: var(--font-size-xxs); - color: var(--color-on-surface-variant); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; min-width: 0; } - - .phase-message.is-error { - color: var(--color-error); - } - - .phase-aside { - margin-left: auto; - display: flex; - align-items: center; - gap: var(--space-3); - flex-shrink: 0; - } - - .phase-time { - font-family: var(--font-mono); - font-size: var(--font-size-xxs); - color: var(--color-on-surface-variant); - min-width: 36px; - text-align: right; - } - - .log-console { - margin: 4px 0 0 0; - background-color: var(--color-surface); - border: 1px solid var(--color-outline-variant); - border-radius: var(--radius-small); - overflow: hidden; - } - - .log-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--space-3); - padding: 4px var(--space-3); - background-color: var(--color-surface-container-low); - border-bottom: 1px solid var(--color-outline-variant); - font-family: var(--font-mono); - font-size: var(--font-size-xxs); - font-weight: var(--font-weight-semibold); - color: var(--color-on-surface-variant); - letter-spacing: 0.08em; - text-transform: uppercase; - } - - .log-duration { - color: var(--color-on-surface); - } - - .log-header-right { - display: flex; - align-items: center; - gap: var(--space-3); - } - - .scroll-lock-btn { - background: none; - border: none; - padding: 2px; - margin: 0; - color: var(--color-on-surface-variant); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-small); - transition: - background-color 0.15s, - color 0.15s; - } - - .scroll-lock-btn:hover { - background-color: var(--color-surface-container-highest); - color: var(--color-on-surface); - } - - .scroll-lock-btn :global(.lock-icon) { - font-size: 14px; - } - - .log-viewport { - padding: var(--space-2) var(--space-3); - max-height: 260px; - overflow-y: auto; - font-family: var(--font-mono); - font-size: var(--font-size-xxs); - line-height: 1.6; - color: var(--color-on-surface-variant); - } - - .log-row { - display: flex; - gap: var(--space-3); - } - - .log-row.is-error { - background-color: color-mix(in srgb, var(--color-error) 10%, transparent); - color: var(--color-error); - margin: 0 calc(-1 * var(--space-3)); - padding: 0 var(--space-3); - } - - .log-timestamp { - color: var(--color-outline); - flex-shrink: 0; - opacity: 0.7; - } - - .log-body { - white-space: pre-wrap; - word-break: break-all; - } - - .spin { - animation: spin 1s linear infinite; - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/WorkflowConsole.test.ts b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/WorkflowConsole.test.ts index 4097a46d..ca71d50e 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/WorkflowConsole.test.ts +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/preview/[jobId]/WorkflowConsole.test.ts @@ -1,22 +1,15 @@ -import { describe, expect, test } from 'vitest' +import { expect, test } from 'vitest' import { render } from 'svelte/server' import WorkflowConsole from './WorkflowConsole.svelte' -import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js' +import type { ProgressPhase } from '#lib/components/progress.js' const started = Date.parse('2026-01-02T03:04:00Z') -const phases: ProgressPhase[] = [ - { - phase: 'render', - status: 'completed', - started_at: started, - finished_at: started + 65_000, - message: 'Rendered successfully' - } -] -const logs: Record = { - render: [ - { phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' } - ] +const phase: ProgressPhase = { + phase: 'render', + status: 'completed', + started_at: started, + finished_at: started + 65_000, + message: 'Retry succeeded' } function text(html: string) { @@ -26,147 +19,43 @@ function text(html: string) { .trim() } -describe('preview error reporting', () => { - test('preserves an empty summary for an unrecognized status', () => { - const result = render(WorkflowConsole, { props: { status: 'unknown', phases: [], logs: {} } }) - expect(result.body).not.toContain('status-title') - expect(result.body).not.toContain('status-sub') +test('renders the latest attempt with its phase logs and keeps logless steps visible', () => { + const { body } = render(WorkflowConsole, { + props: { + status: 'completed', + phases: [ + { ...phase, status: 'failed', message: 'First attempt failed' }, + { ...phase, phase: 'assets', message: 'Assets ready' }, + phase + ], + logs: { + render: [ + { phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' } + ] + } + } }) - test.each(['failed', 'running', 'completed', 'expired'])( - 'shows the existing error strip only for failed status: %s', - (status) => { - const result = render(WorkflowConsole, { - props: { status, phases: [], logs: {}, errorMessage: 'Render failed' } - }) - expect(result.body.includes('error-strip')).toBe(status === 'failed') - expect(text(result.body).includes('Render failed')).toBe(status === 'failed') - } - ) + const headings = [...body.matchAll(/]*>([\s\S]*?)<\/h3>/g)].map((match) => text(match[1])) + expect(headings).toEqual(['render', 'assets']) + expect(text(body)).not.toContain('First attempt failed') + expect(text(body)).toContain('Retry succeeded') + expect(text(body)).toContain('Output ready') + expect(text(body)).toContain('Assets ready') + expect(body).toContain('aria-label="assets output"') + expect(text(body)).toContain('No output recorded.') }) -describe('preview phases and logs', () => { - test('renders preview logs with auto-scroll locked by default', () => { - const result = render(WorkflowConsole, { - props: { - status: 'completed', - phases, - logs - } - }) - expect(result.body).not.toContain('Completed') - expect(text(result.body)).toContain('1 lines') - expect(result.body).toContain('Auto-scroll locked') - }) +test('shows an escaped job error only when the preview has failed', () => { + const props = { + phases: [], + logs: {}, + errorMessage: '' + } + const failed = render(WorkflowConsole, { props: { ...props, status: 'failed' } }).body + const running = render(WorkflowConsole, { props: { ...props, status: 'running' } }).body - test('omits the timeline without phases and log panels without logs', () => { - const empty = render(WorkflowConsole, { - props: { status: 'enqueued', phases: [], logs: {} } - }) - expect(empty.body).not.toContain('class="timeline') - const withoutLogs = render(WorkflowConsole, { - props: { status: 'completed', phases, logs: {} } - }) - expect(withoutLogs.body).toContain('class="timeline') - expect(withoutLogs.body).not.toContain('class="log-console') - }) - - test('renders one timeline row for a retried phase using its latest state', () => { - const result = render(WorkflowConsole, { - props: { - status: 'completed', - phases: [ - { ...phases[0], status: 'failed', message: 'First attempt failed' }, - { ...phases[0], message: 'Retry succeeded' } - ], - logs - } - }) - expect(result.body.match(/class="phase-name\b/g)).toHaveLength(1) - expect(text(result.body)).toContain('Retry succeeded') - expect(text(result.body)).not.toContain('First attempt failed') - }) - - test('scopes log viewport IDs to the rendered console', () => { - const props = { status: 'completed', phases, logs } - const first = render(WorkflowConsole, { props, idPrefix: 'first' }) - const second = render(WorkflowConsole, { props, idPrefix: 'second' }) - const viewportId = (body: string) => body.match(/id="([^"]+-log-container-render)"/)?.[1] - expect(viewportId(first.body)).toBeTruthy() - expect(viewportId(second.body)).toBeTruthy() - expect(viewportId(first.body)).not.toBe(viewportId(second.body)) - }) - - test('shows each phase duration rather than a total preview time', () => { - const result = render(WorkflowConsole, { - props: { - status: 'completed', - phases: [ - phases[0], - { - ...phases[0], - phase: 'publish', - started_at: started + 65_000, - finished_at: started + 90_000 - } - ], - logs: {} - } - }) - expect(result.body).not.toContain('total time') - expect(text(result.body)).toContain('25s') - }) - - test('retains phase statuses and stderr/error highlighting', () => { - const items = ['running', 'failed', 'skipped', 'waiting'].map((status) => ({ - ...phases[0], - phase: status, - status - })) - const result = render(WorkflowConsole, { - props: { - status: 'failed', - phases: items, - logs: { - failed: [ - { ...logs.render[0], phase: 'failed', level: 'stderr', message: 'Failure details' } - ] - } - } - }) - for (const { status } of items) expect(result.body).toContain(`status-${status}`) - expect(result.body).toContain('spin') - expect(result.body).toContain('is-error') - expect(text(result.body)).toContain('Failure details') - }) - - test.each(['stdout', 'info', 'stderr', 'error'])( - 'highlights only error log levels: %s', - (level) => { - const result = render(WorkflowConsole, { - props: { - status: 'completed', - phases, - logs: { render: [{ ...logs.render[0], level }] } - } - }) - const rowClasses = result.body.match(/class="(log-row[^"]*)"/)?.[1] - expect(rowClasses).toBeTruthy() - expect(rowClasses?.includes('is-error')).toBe(level === 'stderr' || level === 'error') - } - ) - - test('escapes preview errors, phase messages, and logs', () => { - const unsafe = '' - const result = render(WorkflowConsole, { - props: { - status: 'failed', - errorMessage: unsafe, - phases: [{ ...phases[0], message: unsafe }], - logs: { render: [{ ...logs.render[0], message: unsafe }] } - } - }) - expect(result.body).not.toContain(' {#if phases.length > 0 || hasSchemaChanges} -
- - Details - +
{#if hasSchemaChanges && schemas}

@@ -108,123 +44,30 @@

{/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 timelinePhases.length > 0} +
+

Run logs

+
+ {#each timelinePhases as phase (phase.phase)} + + {/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 index afd89c76..0c9be64e 100644 --- a/apps/web/src/routes/(auth)/admin/publish/PublishDetails.test.ts +++ b/apps/web/src/routes/(auth)/admin/publish/PublishDetails.test.ts @@ -1,23 +1,19 @@ -import type { PiecesDiff } from '@luzzle/core' import type { ComponentProps } from 'svelte' import { render } from 'svelte/server' -import { expect, test, vi } from 'vitest' +import { expect, test } from 'vitest' import PublishDetails from './PublishDetails.svelte' import type { PhaseProgress, PhaseLog } from './report.js' type Props = ComponentProps const started = Date.parse('2026-01-02T03:04:00Z') -function phase( - overrides: Partial = {} -): PhaseProgress & { job_id: string } { +function phase(overrides: Partial = {}): PhaseProgress { return { - job_id: 'publish-job', phase: 'render', status: 'completed', started_at: started, finished_at: started + 65_000, - message: null, + message: 'Rendered successfully', ...overrides } } @@ -34,8 +30,8 @@ function log(overrides: Partial = {}): PhaseLog { } } -function details(props: Props = {}) { - return render(PublishDetails, { props }).body.replace(//g, '') +function details(props: Props) { + return render(PublishDetails, { props }).body } function text(html: string) { @@ -45,254 +41,53 @@ function text(html: string) { .trim() } -function rows(html: string) { - return [...html.matchAll(/]*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(/ { +test('schema changes and step output are visible without opening a disclosure', () => { const output = details({ - schemas: { added: ['books'], updated: ['films'], pruned: ['notes'] } + schemas: { added: ['books'], updated: ['films'], pruned: ['notes'] }, + schemasAlwaysIncluded: true, + phases: [phase()], + logs: { render: [log()] } }) - expect(output).toMatch(/]*>Piece types<\/h2>/) + + expect(output).not.toMatch(/<(?:details|summary)\b/) + expect(text(output)).toContain('Piece types · always included') 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(/ { +test('latest phase attempts replace old messages while retaining logs from both jobs', () => { const output = details({ phases: [ - phase({ status: 'failed', finished_at: started + 10_000, message: 'Old render failure' }), - phase({ - phase: 'assets', - started_at: started + 10_000, - finished_at: started + 20_000, - message: 'Assets ready' - }), - phase({ - job_id: 'retry-job', - started_at: started + 20_000, - finished_at: started + 90_000, - message: 'Retry succeeded' - }) - ] - }) - const content = text(output) - expect(content).toContain('2 stages · 1m 30s') - expect(content).not.toContain('Old render failure') - expect(content.match(/render/g)).toHaveLength(1) - expect(content.match(/assets/g)).toHaveLength(1) - expect(content.indexOf('render')).toBeLessThan(content.indexOf('assets')) - expect(content).toContain('Retry succeeded') - expect(content).toContain('Assets ready') -}) - -test.each([ - { finished: started + 59_999, duration: '59s' }, - { finished: started + 60_000, duration: '1m 0s' }, - { finished: started - 1000, duration: '0s' } -])('metadata formats total duration as $duration', ({ finished, duration }) => { - expect(text(details({ phases: [phase({ finished_at: finished })] }))).toContain( - `1 stages · ${duration}` - ) -}) - -test('missing start time yields zero elapsed duration', () => { - expect(text(details({ phases: [phase({ started_at: 0 })] }))).toContain('1 stages · 0s') -}) - -test('unfinished phase duration uses the current time when rendered', () => { - const now = vi.spyOn(Date, 'now').mockReturnValue(started + 125_000) - try { - expect(text(details({ phases: [phase({ status: 'running', finished_at: null })] }))).toContain( - '1 stages · 2m 5s' - ) - } finally { - now.mockRestore() - } -}) - -test.each(['completed', 'running', 'failed', 'skipped', 'waiting', 'future-state'])( - 'phase status %s preserves its message and known-status icon', - (status) => { - const output = details({ phases: [phase({ status, message: `Message for ${status}` })] }) - expect(text(output)).toContain(`Message for ${status}`) - // The summary always has a caret; known phase statuses add their own icon. - const known = ['completed', 'running', 'failed', 'skipped'].includes(status) - expect(output.match(/ { - const output = details({ - phases: [phase()], + phase({ status: 'failed', message: 'First attempt failed' }), + phase({ phase: 'assets', message: 'Assets ready' }), + phase({ message: 'Retry succeeded' }) + ], logs: { render: [ - log(), - log({ line_number: 2, ts: started + 1000, level: 'error', message: 'Error detail' }), - log({ line_number: 3, ts: started + 2000, level: 'stderr', message: 'Stderr detail' }), - log({ line_number: 4, ts: started + 3000, level: 'info', message: 'Info detail' }) - ] - } - }) - const content = text(output) - for (const expected of [ - '4 lines', - '03:04:00', - '03:04:01', - '03:04:02', - '03:04:03', - 'Output ready', - 'Error detail', - 'Stderr detail', - 'Info detail' - ]) - expect(content).toContain(expected) - const button = output.match(/]*)>([\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' }) + log({ message: 'First job output' }), + log({ job_id: 'retry-job', message: 'Retry job output' }) ], - assets: [log({ job_id: 'job:1', phase: 'assets', message: 'Assets line one' })], - orphan: [log({ phase: 'orphan', message: 'Not a visible phase' })] + assets: [log({ phase: 'assets', message: 'Asset output' })] } }) - const content = text(output) - expect(rows(output)).toHaveLength(4) - expect(content).toContain('3 lines') - expect(content).toContain('1 lines') - const messages = [ - 'First job line one', - 'Retry job line one', - 'Retry job line two', - 'Assets line one' - ] - for (const message of messages) expect(content.split(message)).toHaveLength(2) - for (let index = 1; index < messages.length; index++) { - expect(content.indexOf(messages[index - 1])).toBeLessThan(content.indexOf(messages[index])) - } - expect(content).not.toContain('Not a visible phase') - expect(output.match(/ { - 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 + const headings = [...output.matchAll(/]*>([\s\S]*?)<\/h3>/g)].map((match) => + text(match[1]) ) - expect(output).toContain('<img src=x> & phase') + expect(headings).toEqual(['render', 'assets']) + expect(text(output)).not.toContain('First attempt failed') + expect(text(output)).toContain('Retry succeeded') + expect(text(output)).toContain('Assets ready') + const content = text(output) + expect(content).toContain('First job output') + expect(content).toContain('Retry job output') + expect(content).toContain('Asset output') + expect(content.indexOf('First job output')).toBeLessThan(content.indexOf('Retry job output')) + expect(content.indexOf('Retry job output')).toBeLessThan(content.indexOf('Asset output')) }) - -test('readonly parent arrays and DTO extra properties are accepted without input mutation', () => { - const phases = Object.freeze([Object.freeze(phase({ job_id: 'parent-job' }))]) - const renderLogs = Object.freeze([Object.freeze(log({ job_id: 'parent-job' }))]) - const logs = Object.freeze({ render: renderLogs }) - const output = details({ phases, logs }) - expect(text(output)).toContain('Output ready') - expect(phases[0].job_id).toBe('parent-job') - expect(logs.render).toBe(renderLogs) -}) - -// SSR covers rendering and initial controls, not DOM identity across client updates -// or browser scrolling. Manually review disclosure/keyboard toggles, opening after -// completion, locked appends, manual unlock/relock, button-off position preservation, -// independent phases/instances, and viewport cleanup when logs or Details disappear. diff --git a/apps/web/src/routes/(auth)/admin/publish/page.test.ts b/apps/web/src/routes/(auth)/admin/publish/page.test.ts index ba5ec7b7..bf740c4f 100644 --- a/apps/web/src/routes/(auth)/admin/publish/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/publish/page.test.ts @@ -120,17 +120,6 @@ function expectEditLink(output: string, file: string, href: string) { 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(/ { - const phase = { - job_id: 'publish-id', - phase: 'cdn.sync', - started_at: 1000, - finished_at: 2000, - message: null - } - const output = html({ - phases: [ - { ...phase, status: 'failed', message: 'Earlier CDN attempt failed' }, - { - ...phase, - status: 'completed', - started_at: 2000, - finished_at: 3000, - message: 'Latest CDN attempt completed' - } - ] - }) - const details = disclosure(output, 'Details') - expect(output).toContain('Published with errors') - expect(text(details).match(/cdn\.sync/g)).toHaveLength(1) - expect(text(details)).toContain('1 stages') - expect(text(details)).toContain('Latest CDN attempt completed') - expect(output).not.toContain('Earlier CDN attempt failed') -}) - -test('schemas and phase logs stay inside collapsed native Details', () => { +test('schema changes and phase logs stay visible on the publish page', () => { const output = html({ phases: [ { @@ -285,27 +246,11 @@ test('schemas and phase logs stay inside collapsed native Details', () => { } ] }) - const details = disclosure(output, 'Details') - expect(details).toContain('book.schema.json') + expect(output).not.toMatch(/<(?:details|summary)\b/) expect(text(output).match(/book\.schema\.json/g)).toHaveLength(1) - expect(details).toContain('CDN assets synchronized') expect(text(output).match(/CDN assets synchronized/g)).toHaveLength(1) }) -test.each([ - { kind: 'added', badge: 'New' }, - { kind: 'updated', badge: 'Updated' }, - { kind: 'pruned', badge: 'Removed' } -] as const)('schema $kind changes keep their $badge badge inside Details', ({ kind, badge }) => { - const name = `${kind}.schema.json` - const output = html({ schemas: { added: [], updated: [], pruned: [], [kind]: [name] } }) - const details = disclosure(output, 'Details') - const rows = [...withoutComments(details).matchAll(/]*>([\s\S]*?)<\/li>/g)] - const schemaRows = rows.filter((row) => text(row[1]).includes(name)) - expect(schemaRows).toHaveLength(1) - expect(text(schemaRows[0][1]).trim().replace(/\s+/g, ' ')).toBe(`${badge} ${name}`) -}) - test('local and remote checks are separate header actions without an Options menu or result checkboxes', () => { const output = html() expectHeaderChecksOnly(output) @@ -382,7 +327,7 @@ test.each(['running', 'waiting'])( ) test.each(['partial', 'successful'])( - 'a resumed audit preserves the %s publication’s collapsed Details instead of audit progress', + 'a resumed audit preserves the %s publication’s logs instead of audit progress', (result) => { const resumedAudit = audit('running') resumedAudit.phases = [ @@ -431,12 +376,10 @@ test.each(['partial', 'successful'])( ], audit: resumedAudit }) - const details = disclosure(output, 'Details') - expect(text(details)).toContain('1 stages') - expect(text(details)).toContain('cdn.sync') - expect(text(details)).toContain('Publication CDN sync completed') - expect(text(details)).toContain('Published assets synchronized') - expect(text(details)).toContain('book.schema.json') + expect(text(output)).toContain('cdn.sync') + expect(text(output)).toContain('Publication CDN sync completed') + expect(text(output)).toContain('Published assets synchronized') + expect(text(output)).toContain('book.schema.json') expect(text(output).match(/Published assets synchronized/g)).toHaveLength(1) expect(text(output)).not.toContain('archive.diff') expect(text(output)).not.toContain('Audit is comparing changes') diff --git a/docs/web/README.md b/docs/web/README.md index ceafea46..be89844d 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -52,6 +52,10 @@ Schema changes also apply globally. A renamed file looks like a removal and an addition: the old public version can disappear before you select its replacement. Check alone does not change the public site. +Publish and Preview show each step's logs directly. The header contains the step +name and status; the footer shows line count, elapsed seconds and a compact auto-scroll lock. +Scroll up to stop following new output, or lock auto-scroll to return to the latest lines. + Source and text-asset editors default to Standard keybindings. Toggle **Vim mode** in the piece editor's **More** menu; the browser remembers your choice. The piece editor's bottom bar shows the active Vim mode on the left and publication -- 2.54.0