Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/e2e/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a lay

SITE-014 note: focused Bun coverage spans the dependency panel, auto-resolve hook, client envelope validation, runtime handler normalization, module dependency/importmap filtering, script import analysis, runtime config, site runtime build, dependency resolver/cache, package importmap/server, and runtime asset publish injection. `tests/e2e/runtime-dependencies.e2e.ts` covers browser authoring of a site script import, Dependencies-panel missing package Add, live `canvas-confetti` registry/cache resolution, save/publish, public importmap emission, browser loading of the emitted `/_instatic/runtime/cache/...` package URL, and a 390px mobile path that authors a missing import, opens Dependencies, verifies no horizontal overflow, and confirms the Add action is reachable. Live registry/install failure UX permutations remain operator-run.

SITE-016 note: `tests/e2e/preview-live.e2e.ts` creates a disposable page, publishes version A, saves draft version B without publishing, verifies the Preview page overlay iframe renders draft B, verifies the toolbar Open live page popup still serves published version A without editor chrome, and repeats preview opening at 390px to confirm the overlay remains reachable without document overflow. Template-target and Content-entry live-path permutations remain lower-level or future browser coverage.
SITE-016 note: `tests/e2e/preview-live.e2e.ts` creates a disposable page, publishes version A, saves draft version B without publishing, verifies the Preview page overlay iframe renders draft B, verifies the toolbar Open live page popup still serves published version A without editor chrome, and repeats preview opening at 390px to confirm the overlay remains reachable without document overflow. Issue #234 additionally gates Preview page through the server runtime-preview path so loop and media prefetch matches public rendering. Template-target and Content-entry live-path permutations remain lower-level or future browser coverage.

## Public Serving

Expand Down
2 changes: 1 addition & 1 deletion docs/e2e/feature-validation.tsv

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/features/loops.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@ async function prefetchLoops(page, site, db) {

The map is passed into `RenderConfig.loopData`. The walker reads from it; no async at render time.

The public renderer and the editor's full-page **Preview page** overlay both
use this server-side prefetch path. Preview sends the current in-memory draft
to `/admin/api/cms/runtime/preview`; the server resolves loop and media data
before calling `publishPage`. Calling the pure publisher without `loopData`
is intentionally not a preview fallback — the loop emits its missing-data
marker instead.

---

## Editor canvas preview
Expand Down
47 changes: 27 additions & 20 deletions src/__tests__/persistence/cmsRuntimeClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'
import { ApiError } from '@core/http'
import { buildCmsRuntimePreview, resolveCmsRuntimeDependencies } from '@core/persistence/cmsRuntime'
import { buildCmsRuntimePreview, resolveCmsRuntimeDependencies } from '@core/persistence'

describe('CMS runtime client', () => {
it('posts dependency manifests to the runtime resolve endpoint', async () => {
Expand Down Expand Up @@ -86,38 +86,44 @@ describe('CMS runtime client', () => {
await expect(
buildCmsRuntimePreview(
{ site: { id: 's' }, pageId: 'p' },
async () =>
new Response(
JSON.stringify({
html: '<x>',
assets: [],
// scripts must be an array of script-asset objects, not a string.
runtimeAssets: { scripts: 'nope' },
diagnostics: [],
}),
{ status: 200 },
),
{
fetchImpl: async () =>
new Response(
JSON.stringify({
html: '<x>',
assets: [],
// scripts must be an array of script-asset objects, not a string.
runtimeAssets: { scripts: 'nope' },
diagnostics: [],
}),
{ status: 200 },
),
},
),
).rejects.toThrow()
})

it('posts site preview requests to the runtime preview endpoint', async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
const controller = new AbortController()
const result = await buildCmsRuntimePreview(
{
site: { id: 'site_1' },
pageId: 'page_1',
breakpointId: 'mobile',
templateContext: { entryStack: [] },
},
async (input, init) => {
calls.push({ input, init })
return new Response(JSON.stringify({
html: '<!DOCTYPE html>',
assets: [],
runtimeAssets: { scripts: [] },
diagnostics: [],
}), { status: 200 })
{
signal: controller.signal,
fetchImpl: async (input, init) => {
calls.push({ input, init })
return new Response(JSON.stringify({
html: '<!DOCTYPE html>',
assets: [],
runtimeAssets: { scripts: [] },
diagnostics: [],
}), { status: 200 })
},
},
)

Expand All @@ -126,6 +132,7 @@ describe('CMS runtime client', () => {
input: '/admin/api/cms/runtime/preview',
init: { method: 'POST', credentials: 'include' },
})
expect(calls[0].init?.signal).toBe(controller.signal)
expect(calls[0].init?.body).toBe(JSON.stringify({
site: { id: 'site_1' },
pageId: 'page_1',
Expand Down
91 changes: 69 additions & 22 deletions src/__tests__/publisher/previewOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import React from 'react'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react'
import { readFileSync } from 'fs'
import { PreviewOverlay } from '@site/preview/PreviewOverlay'
import { useEditorStore } from '@site/store/store'
Expand Down Expand Up @@ -78,8 +78,29 @@ function openPreviewWithSite() {
} as Parameters<typeof useEditorStore.setState>[0])
}

beforeEach(resetStore)
afterEach(cleanup)
const originalFetch = globalThis.fetch
const runtimePreviewCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
let runtimePreviewHtml = '<!DOCTYPE html><html><head><title>Test Site</title></head><body><h1>Welcome</h1></body></html>'

beforeEach(() => {
resetStore()
runtimePreviewCalls.length = 0
runtimePreviewHtml = '<!DOCTYPE html><html><head><title>Test Site</title></head><body><h1>Welcome</h1></body></html>'
globalThis.fetch = async (input, init) => {
runtimePreviewCalls.push({ input, init })
return new Response(JSON.stringify({
html: runtimePreviewHtml,
assets: [],
runtimeAssets: { scripts: [] },
diagnostics: [],
}), { status: 200 })
}
})

afterEach(() => {
cleanup()
globalThis.fetch = originalFetch
})

// ---------------------------------------------------------------------------
// 1 — uiSlice preview actions
Expand Down Expand Up @@ -113,95 +134,120 @@ describe('uiSlice — preview state', () => {
// ---------------------------------------------------------------------------

describe('PreviewOverlay — DOM rendering', () => {
it('renders nothing when previewOpen is false', () => {
it('renders nothing when previewOpen is false', async () => {
render(<PreviewOverlay />)
expect(document.querySelector('[data-testid="preview-overlay"]')).toBeNull()
expect(document.querySelector('[data-testid="preview-iframe"]')).toBeNull()
await act(async () => {})
})

it('renders nothing when previewOpen=true but no site is loaded', () => {
it('renders nothing when previewOpen=true but no site is loaded', async () => {
useEditorStore.setState({ previewOpen: true } as Parameters<typeof useEditorStore.setState>[0])
render(<PreviewOverlay />)
expect(document.querySelector('[data-testid="preview-overlay"]')).toBeNull()
await act(async () => {})
})

it('renders the dialog overlay when previewOpen=true with a site', () => {
it('renders the dialog overlay when previewOpen=true with a site', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
expect(document.querySelector('[data-testid="preview-overlay"]')).not.toBeNull()
await screen.findByTestId('preview-iframe')
})

it('overlay has role="dialog" and aria-modal="true"', () => {
it('overlay has role="dialog" and aria-modal="true"', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
const dialog = screen.getByRole('dialog')
expect(dialog).toBeDefined()
expect(dialog.getAttribute('aria-modal')).toBe('true')
await screen.findByTestId('preview-iframe')
})

it('renders the preview iframe inside the dialog', () => {
it('renders the preview iframe inside the dialog', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
const iframe = document.querySelector('[data-testid="preview-iframe"]') as HTMLIFrameElement | null
const iframe = await screen.findByTestId('preview-iframe')
expect(iframe).not.toBeNull()
})

it('iframe has a non-empty srcdoc attribute', () => {
it('iframe has a non-empty srcdoc attribute', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
const iframe = document.querySelector('[data-testid="preview-iframe"]') as HTMLIFrameElement | null
const srcdoc = iframe?.getAttribute('srcdoc') ?? ''
const iframe = await screen.findByTestId('preview-iframe')
const srcdoc = iframe.getAttribute('srcdoc') ?? ''
expect(srcdoc.length).toBeGreaterThan(0)
expect(srcdoc).toContain('<!DOCTYPE html>')
})

it('iframe srcdoc contains the page title', () => {
it('iframe srcdoc contains the page title', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
const iframe = document.querySelector('[data-testid="preview-iframe"]') as HTMLIFrameElement | null
const srcdoc = iframe?.getAttribute('srcdoc') ?? ''
const iframe = await screen.findByTestId('preview-iframe')
const srcdoc = iframe.getAttribute('srcdoc') ?? ''
// The site name "Test Site" should appear as the page title
expect(srcdoc).toMatch(/<title>[^<]*<\/title>/)
})

it('close button has aria-label="Close preview"', () => {
it('renders server-resolved loop content from the current draft (ISS-234)', async () => {
runtimePreviewHtml = '<!DOCTYPE html><html><body><p>ISS-234 LOOP ROW</p></body></html>'
openPreviewWithSite()
const currentSite = useEditorStore.getState().site
render(<PreviewOverlay />)

const iframe = await screen.findByTestId('preview-iframe')
expect(iframe.getAttribute('srcdoc')).toContain('ISS-234 LOOP ROW')
expect(runtimePreviewCalls).toHaveLength(1)
expect(runtimePreviewCalls[0]?.input).toBe('/admin/api/cms/runtime/preview')
expect(JSON.parse(String(runtimePreviewCalls[0]?.init?.body))).toMatchObject({
site: currentSite,
pageId: 'page-1',
})
})

it('close button has aria-label="Close preview"', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
const closeBtn = screen.getByLabelText('Close preview')
expect(closeBtn).toBeDefined()
await screen.findByTestId('preview-iframe')
})

it('clicking the close button closes the overlay (sets previewOpen=false)', () => {
it('clicking the close button closes the overlay (sets previewOpen=false)', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
await screen.findByTestId('preview-iframe')
const closeBtn = screen.getByLabelText('Close preview')
fireEvent.click(closeBtn)
expect(useEditorStore.getState().previewOpen).toBe(false)
})

it('pressing Escape closes the overlay', () => {
it('pressing Escape closes the overlay', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
await screen.findByTestId('preview-iframe')
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' })
expect(useEditorStore.getState().previewOpen).toBe(false)
})

it('clicking the backdrop closes the overlay', () => {
it('clicking the backdrop closes the overlay', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
await screen.findByTestId('preview-iframe')
// Backdrop is the first aria-hidden element
const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement | null
expect(backdrop).not.toBeNull()
fireEvent.click(backdrop!)
expect(useEditorStore.getState().previewOpen).toBe(false)
})

it('overlay header shows page title', () => {
it('overlay header shows page title', async () => {
openPreviewWithSite()
render(<PreviewOverlay />)
// Header reads "Preview — {page.title}"
expect(document.body.textContent).toContain('Preview — Home')
await screen.findByTestId('preview-iframe')
})
})

Expand Down Expand Up @@ -258,8 +304,9 @@ describe('PreviewOverlay — source enforcement', () => {
expect(overlaySrc).toContain('aria-hidden="true"')
})

it('calls publishPage() to generate iframe content', () => {
expect(overlaySrc).toContain('publishPage(')
it('uses the CMS runtime preview boundary instead of bypassing server prefetch', () => {
expect(overlaySrc).toContain('buildCmsRuntimePreview(')
expect(overlaySrc).not.toContain('publishPage(')
})
})

Expand Down
78 changes: 78 additions & 0 deletions src/__tests__/server/cmsRuntimeHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { SESSION_COOKIE_NAME } from '../../../server/auth/tokens'
import type { DbClient, DbResult } from '../../../server/db'
import { handleCmsRequest } from '../../../server/handlers/cms'
import type { SiteDocument } from '@core/page-tree'
import '@core/loops/sources'
import '@modules/base'

function makeFakeDb(): DbClient {
const handle = async <Row extends Record<string, unknown> = Record<string, unknown>>(
Expand Down Expand Up @@ -144,6 +146,70 @@ function siteWithVC(): SiteDocument {
}
}

function siteWithLoop(): SiteDocument {
const base = site()
return {
...base,
pages: [
{
...base.pages[0],
nodes: {
root: {
id: 'root',
moduleId: 'base.body',
props: {},
breakpointOverrides: {},
children: ['loop'],
},
loop: {
id: 'loop',
moduleId: 'base.loop',
props: {
sourceId: 'site.pages',
filters: {},
orderBy: 'definition',
direction: 'asc',
limit: 10,
offset: 0,
pagination: 'none',
pageSize: 10,
tag: 'div',
customTag: '',
},
breakpointOverrides: {},
children: ['loop_text'],
},
loop_text: {
id: 'loop_text',
moduleId: 'base.text',
props: { text: 'Fallback' },
dynamicBindings: {
text: { source: 'currentEntry', field: 'title' },
},
breakpointOverrides: {},
children: [],
},
},
},
{
id: 'page_loop_item',
title: 'ISS-234 SERVER LOOP ROW',
slug: 'loop-row',
rootNodeId: 'loop_item_root',
nodes: {
loop_item_root: {
id: 'loop_item_root',
moduleId: 'base.body',
props: {},
breakpointOverrides: {},
children: [],
},
},
},
],
}
}

describe('CMS runtime handlers', () => {
it('resolves an empty runtime dependency manifest', async () => {
const res = await handleCmsRequest(runtimeRequest(
Expand Down Expand Up @@ -200,6 +266,18 @@ describe('CMS runtime handlers', () => {
})
})

it('prefetches and renders loop rows in the runtime preview (ISS-234)', async () => {
const res = await handleCmsRequest(runtimeRequest(
'http://localhost/admin/api/cms/runtime/preview',
{ site: siteWithLoop(), pageId: 'page_1' },
), makeFakeDb())

expect(res.status).toBe(200)
const body = await res.text()
expect(body).toContain('ISS-234 SERVER LOOP ROW')
expect(body).not.toContain('instatic: loop data missing')
})

it('builds a runtime preview from a VC virtual page id when the editor is in VC canvas mode', async () => {
const res = await handleCmsRequest(runtimeRequest(
'http://localhost/admin/api/cms/runtime/preview',
Expand Down
Loading
Loading