]*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