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
5 changes: 5 additions & 0 deletions docs/next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ Focused follow-up work for `@knighted/develop`.
5. **In-browser component testing**
- Explore authoring and running component-focused tests in-browser (for example, a Vitest-compatible flow) using CDN-delivered tooling.
- Define a lightweight test UX that supports writing tests, running them on demand, and displaying results in-app.

6. **CDN failure recovery UX**
- Detect transient CDN/module loading failures and surface a clear recovery action in-app.
- Add a user-triggered retry path (for example, Reload page / Force reload) when runtime bootstrap imports fail.
- Consider an optional automatic one-time retry before showing recovery controls, while avoiding infinite reload loops.
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default [
sourceType: 'module',
ecmaVersion: 'latest',
},
rules: {
'no-unused-vars': 'error',
},
},
{
...playwrightConfig,
Expand Down
280 changes: 270 additions & 10 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,50 @@ const setStylesEditorSource = async (page: Page, source: string) => {
await editorContent.fill(source)
}

const getCollapseButton = (page: Page, panelName: 'component' | 'styles' | 'preview') =>
page.locator(`#collapse-${panelName}`)

const getToolsButton = (page: Page, panelName: 'component' | 'styles') =>
page.locator(`#tools-${panelName}`)

const ensurePanelToolsVisible = async (page: Page, panelName: 'component' | 'styles') => {
const button = getToolsButton(page, panelName)
const isPressed = await button.getAttribute('aria-pressed')
if (isPressed !== 'true') {
await button.click()
}
}

const expectCollapseButtonState = async (
page: Page,
panelName: 'component' | 'styles' | 'preview',
{
axis,
direction,
collapsed,
disabled,
}: {
axis: 'vertical' | 'horizontal'
direction: 'left' | 'right' | 'none'
collapsed: boolean
disabled?: boolean
},
) => {
const button = getCollapseButton(page, panelName)

await expect(button).toHaveAttribute('data-collapse-axis', axis)
await expect(button).toHaveAttribute('data-collapse-direction', direction)
await expect(button).toHaveAttribute('data-collapsed', collapsed ? 'true' : 'false')

if (disabled !== undefined) {
if (disabled) {
await expect(button).toBeDisabled()
} else {
await expect(button).toBeEnabled()
}
}
}

test('renders default playground preview', async ({ page }) => {
await waitForInitialRender(page)

Expand All @@ -54,9 +98,219 @@ test('supports layout and theme toggles', async ({ page }) => {
)
})

test('side layout keeps preview panel height within editor stack height', async ({
page,
}) => {
await waitForInitialRender(page)

await page.getByLabel('Use side preview layout').click()
await expect(page.locator('.app-grid')).toHaveClass(/app-grid--preview-right/)

const metrics = await page.evaluate(() => {
const stack = document.querySelector('.panels-stack--editors')
const previewPanel = document.getElementById('preview-panel')
const stackHeight = stack?.getBoundingClientRect().height ?? 0
const previewHeight = previewPanel?.getBoundingClientRect().height ?? 0
const previewOverflowY = previewPanel ? getComputedStyle(previewPanel).overflowY : ''
return { stackHeight, previewHeight, previewOverflowY }
})

expect(metrics.stackHeight).toBeGreaterThan(0)
expect(metrics.previewHeight).toBeGreaterThan(0)
expect(metrics.previewHeight).toBeLessThanOrEqual(metrics.stackHeight + 2)
expect(metrics.previewOverflowY).toBe('hidden')
})

test('side layout config keeps preview scrolling inside preview host', async ({
page,
}) => {
await waitForInitialRender(page)

await page.getByLabel('Use side preview layout').click()

const scrollConfig = await page.evaluate(() => {
const previewPanel = document.getElementById('preview-panel')
const previewHost = document.getElementById('preview-host')
if (!previewPanel || !previewHost) {
return null
}

const panelStyles = getComputedStyle(previewPanel)
const styles = getComputedStyle(previewHost)
return {
panelOverflowY: panelStyles.overflowY,
panelOverflowX: panelStyles.overflowX,
overflowY: styles.overflowY,
minHeight: styles.minHeight,
}
})

expect(scrollConfig).not.toBeNull()
expect(scrollConfig?.panelOverflowY).toBe('hidden')
expect(scrollConfig?.panelOverflowX).toBe('hidden')
expect(['auto', 'scroll']).toContain(scrollConfig?.overflowY)
expect(scrollConfig?.minHeight).toBe('0px')
})

test('expanded component and styles can shrink consistently in side layouts', async ({
page,
}) => {
await waitForInitialRender(page)

for (const layoutLabel of ['Use side preview layout', 'Use left preview layout']) {
await page.getByLabel(layoutLabel).click()

const minHeights = await page.evaluate(() => {
const component = document.getElementById('component-panel')
const styles = document.getElementById('styles-panel')
return {
component: component
? Number.parseFloat(getComputedStyle(component).minHeight)
: 0,
styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0,
}
})

expect(minHeights.component).toBeGreaterThanOrEqual(0)
expect(minHeights.styles).toBeGreaterThanOrEqual(0)
expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1)
}
})

test('panel collapse axis and direction adapt to active layout', async ({ page }) => {
await waitForInitialRender(page)
await expect(page.locator('.app-grid')).toHaveClass(/app-grid/)

await expectCollapseButtonState(page, 'component', {
axis: 'horizontal',
direction: 'left',
collapsed: false,
})
await expectCollapseButtonState(page, 'styles', {
axis: 'horizontal',
direction: 'right',
collapsed: false,
})
await expectCollapseButtonState(page, 'preview', {
axis: 'vertical',
direction: 'none',
collapsed: false,
})

await page.getByLabel('Use side preview layout').click()
await expectCollapseButtonState(page, 'preview', {
axis: 'horizontal',
direction: 'right',
collapsed: false,
})
await expectCollapseButtonState(page, 'component', {
axis: 'vertical',
direction: 'none',
collapsed: false,
})

await page.getByLabel('Use left preview layout').click()
await expectCollapseButtonState(page, 'preview', {
axis: 'horizontal',
direction: 'left',
collapsed: false,
})
})

test('prevents collapsing all three panels at once', async ({ page }) => {
await waitForInitialRender(page)

await getCollapseButton(page, 'component').click()
await getCollapseButton(page, 'styles').click()

await expect(page.locator('#component-panel')).toHaveClass(
/panel--collapsed-horizontal/,
)
await expect(page.locator('#styles-panel')).toHaveClass(/panel--collapsed-horizontal/)

await expectCollapseButtonState(page, 'preview', {
axis: 'vertical',
direction: 'none',
collapsed: false,
disabled: true,
})
await expect(getCollapseButton(page, 'preview')).toHaveAttribute(
'title',
'At least one panel must remain expanded.',
)

await getCollapseButton(page, 'component').click()
await expectCollapseButtonState(page, 'preview', {
axis: 'vertical',
direction: 'none',
collapsed: false,
disabled: false,
})
})

test('does not persist panel collapse state across reload', async ({ page }) => {
await waitForInitialRender(page)

await getCollapseButton(page, 'component').click()
await expect(page.locator('#component-panel')).toHaveClass(
/panel--collapsed-horizontal/,
)
await expectCollapseButtonState(page, 'component', {
axis: 'horizontal',
direction: 'left',
collapsed: true,
})

await page.reload()
await waitForInitialRender(page)

await expect(page.locator('#component-panel')).not.toHaveClass(
/panel--collapsed-horizontal|panel--collapsed-vertical/,
)
await expectCollapseButtonState(page, 'component', {
axis: 'horizontal',
direction: 'left',
collapsed: false,
})
})

test('gear tools toggles default inactive and switch active/inactive per panel', async ({
page,
}) => {
await waitForInitialRender(page)

const componentPanel = page.locator('#component-panel')
const stylesPanel = page.locator('#styles-panel')
const componentTools = getToolsButton(page, 'component')
const stylesTools = getToolsButton(page, 'styles')

await expect(componentPanel).toHaveClass(/panel--tools-hidden/)
await expect(stylesPanel).toHaveClass(/panel--tools-hidden/)
await expect(componentTools).toHaveAttribute('aria-pressed', 'false')
await expect(stylesTools).toHaveAttribute('aria-pressed', 'false')

await componentTools.click()
await expect(componentPanel).not.toHaveClass(/panel--tools-hidden/)
await expect(componentTools).toHaveAttribute('aria-pressed', 'true')
await expect(componentTools).toHaveAttribute('title', 'Hide component tools')

await componentTools.click()
await expect(componentPanel).toHaveClass(/panel--tools-hidden/)
await expect(componentTools).toHaveAttribute('aria-pressed', 'false')
await expect(componentTools).toHaveAttribute('title', 'Show component tools')

await stylesTools.click()
await expect(stylesPanel).not.toHaveClass(/panel--tools-hidden/)
await expect(stylesTools).toHaveAttribute('aria-pressed', 'true')
await expect(stylesTools).toHaveAttribute('title', 'Hide styles tools')
})

test('renders in react mode with css modules', async ({ page }) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'component')
await ensurePanelToolsVisible(page, 'styles')

await page.getByLabel('ShadowRoot (open)').uncheck()
await page.locator('#render-mode').selectOption('react')
await page.locator('#style-mode').selectOption('module')
Expand Down Expand Up @@ -117,21 +371,21 @@ test('jsx syntax errors affect status but not diagnostics toggle severity', asyn
test('requires render button when auto render is disabled', async ({ page }) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'component')
await ensurePanelToolsVisible(page, 'styles')

const autoRenderToggle = page.getByLabel('Auto render')
const renderButton = page.getByRole('button', { name: 'Render' })
const styleMode = page.locator('#style-mode')
const styleWarning = page.locator('#style-warning')

await expect(styleWarning).toHaveText('')
await autoRenderToggle.uncheck()
await expect(renderButton).toBeVisible()

await styleMode.selectOption('module')
await expect(styleWarning).toHaveText('')

await renderButton.click()
await expect(page.locator('#status')).toHaveText('Rendered')
await expect(styleWarning).toContainText('CSS Modules are compiled in-browser')
await expect(page.locator('#preview-host pre')).toHaveCount(0)
})

test('persists layout and theme across reload', async ({ page }) => {
Expand All @@ -152,30 +406,30 @@ test('persists layout and theme across reload', async ({ page }) => {
test('renders with less style mode', async ({ page }) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'styles')

await page.getByLabel('ShadowRoot (open)').uncheck()
await page.locator('#style-mode').selectOption('less')
await expect(page.locator('#status')).toHaveText('Rendered')
await expect(page.locator('#style-warning')).toContainText(
'Less is compiled in-browser via @knighted/css/browser.',
)
await expectPreviewHasRenderedContent(page)
})

test('renders with sass style mode', async ({ page }) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'styles')

await page.getByLabel('ShadowRoot (open)').uncheck()
await page.locator('#style-mode').selectOption('sass')
await expect(page.locator('#status')).toHaveText('Rendered')
await expect(page.locator('#style-warning')).toContainText(
'Sass is compiled in-browser via @knighted/css/browser.',
)
await expectPreviewHasRenderedContent(page)
})

test('style compilation errors populate styles diagnostics scope', async ({ page }) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'styles')

await page.locator('#style-mode').selectOption('sass')
await setStylesEditorSource(page, '.card { color: $missing; }')

Expand Down Expand Up @@ -234,6 +488,8 @@ test('clearing styles keeps diagnostics error state but resets status styling',
}) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'component')

await setComponentEditorSource(
page,
["const count: number = 'oops'", 'const App = () => <button>ready</button>'].join(
Expand Down Expand Up @@ -268,6 +524,8 @@ test('clear component diagnostics removes type errors and restores rendered stat
}) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'component')

await setComponentEditorSource(
page,
["const count: number = 'oops'", 'const App = () => <button>ready</button>'].join(
Expand Down Expand Up @@ -298,6 +556,8 @@ test('clear component diagnostics removes type errors and restores rendered stat
test('clear all diagnostics removes style compile diagnostics', async ({ page }) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'styles')

await page.locator('#style-mode').selectOption('sass')
await setStylesEditorSource(page, '.card { color: $missing; }')

Expand Down
Loading