diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index 138d706a1..fc25c57b1 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -103,3 +103,5 @@ Render Spec has an experimental local React preview at https://threadplane.ai/do State Management has an experimental local React preview at https://threadplane.ai/docs/render/guides/state-store?frontend=react. The React host owns copied immutable name, age and theme state; installed native RenderSpec resolves the three authored $state paths in read-only views. Edits survive partial playback, seek, Reset and sample selection. Invalid age drafts keep the last valid value, and reload restores the seed. Parser callbacks cannot replace host state; unknown bindings and executable fields are excluded. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-state-management-react` and verify with `npx nx e2e cockpit-render-state-management-react`. Repeat Loops has an experimental local React preview at https://threadplane.ai/docs/render/guides/repeat-loops?frontend=react. The host owns immutable items with stable ids; native RenderSpec renders a repeated Card once and repeats its child rows keyed by item id. Add, remove and reverse items while preserving row identity and playback source. The list is bounded at 32; edits survive seek, Reset and sample changes, and reload restores the seed. Partial repeat metadata waits for its exact path and key. Read-only views exclude unknown expressions, nested repeats and executable fields. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-repeat-loops-react` and verify with `npx nx e2e cockpit-render-repeat-loops-react`. + +Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`. diff --git a/apps/website/content/CLAUDE.md.template b/apps/website/content/CLAUDE.md.template index 138d706a1..fc25c57b1 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -103,3 +103,5 @@ Render Spec has an experimental local React preview at https://threadplane.ai/do State Management has an experimental local React preview at https://threadplane.ai/docs/render/guides/state-store?frontend=react. The React host owns copied immutable name, age and theme state; installed native RenderSpec resolves the three authored $state paths in read-only views. Edits survive partial playback, seek, Reset and sample selection. Invalid age drafts keep the last valid value, and reload restores the seed. Parser callbacks cannot replace host state; unknown bindings and executable fields are excluded. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-state-management-react` and verify with `npx nx e2e cockpit-render-state-management-react`. Repeat Loops has an experimental local React preview at https://threadplane.ai/docs/render/guides/repeat-loops?frontend=react. The host owns immutable items with stable ids; native RenderSpec renders a repeated Card once and repeats its child rows keyed by item id. Add, remove and reverse items while preserving row identity and playback source. The list is bounded at 32; edits survive seek, Reset and sample changes, and reload restores the seed. Partial repeat metadata waits for its exact path and key. Read-only views exclude unknown expressions, nested repeats and executable fields. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-repeat-loops-react` and verify with `npx nx e2e cockpit-render-repeat-loops-react`. + +Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`. diff --git a/apps/website/e2e/react-registry-preview.spec.ts b/apps/website/e2e/react-registry-preview.spec.ts new file mode 100644 index 000000000..e5652d18f --- /dev/null +++ b/apps/website/e2e/react-registry-preview.spec.ts @@ -0,0 +1,227 @@ +import { test, expect, type Page } from '@playwright/test'; + +const route = '/docs/render/guides/registry'; +const iframe = (page: Page) => + page.locator( + 'iframe[title="Component Registry (React preview) live example"]' + ); +const frame = (page: Page) => iframe(page).contentFrame(); + +test.beforeEach(async ({ page }) => { + const executions: string[] = []; + const errors: string[] = []; + page.on('request', (request) => { + if ( + !['GET', 'HEAD'].includes(request.method()) && + /\/agent(?:\/|$)|\/api\/native(?:\/|$)|\/api\/threads(?:\/|$)|\/threads(?:\/|$)/.test( + new URL(request.url()).pathname + ) + ) + executions.push(request.url()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + observers.set(page, { executions, errors }); +}); + +const observers = new WeakMap< + Page, + { executions: string[]; errors: string[] } +>(); +test.afterEach(async ({ page }) => { + const observed = observers.get(page); + expect(observed?.executions).toEqual([]); + expect(observed?.errors).toEqual([]); +}); + +async function expectFreshRun(page: Page) { + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true' + ); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-workspace-mode', + 'Run' + ); + await expect(iframe(page)).toBeVisible(); + await expect(page.getByLabel('Example UI')).toHaveValue('react'); + await expect(page).toHaveURL( + (url) => + url.pathname === route && + url.searchParams.get('frontend') === 'react' && + url.searchParams.get('mode') === 'run' + ); + await expect(iframe(page)).toHaveAttribute( + 'src', + /(?:localhost:4617(?:[/?#]|$)|\/render\/registry\/react\/?(?:[?#]|$))/ + ); + const example = frame(page); + await expect( + example.getByRole('combobox', { name: 'Badge display' }) + ).toHaveValue('registered'); + await expect(example.getByRole('status')).toHaveText('Paused · 0 characters'); + await expect(example.getByRole('combobox', { name: 'Sample' })).toHaveValue( + '0' + ); + await expect( + example.getByRole('slider', { name: 'Playback position' }) + ).toHaveValue('0'); + await expect( + example.getByRole('region', { name: 'Streaming JSON' }).locator('pre') + ).toHaveText(''); + await expect( + example.getByRole('region', { name: 'Render output' }) + ).toContainText('Play a sample to see its view.'); +} + +async function renderChangedSample(page: Page) { + const example = frame(page); + await example + .getByRole('combobox', { name: 'Badge display' }) + .selectOption('fallback'); + await example + .getByRole('combobox', { name: 'Sample' }) + .selectOption({ label: 'Mixed Components' }); + await example.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect( + example.getByRole('heading', { name: 'All Registered Types', exact: true }) + ).toBeVisible(); + await expect(example.locator('[data-registry-fallback]')).toHaveCount(2); + await expect(example.locator('[data-registry-badge]')).toHaveCount(0); + await expect( + example.getByRole('region', { name: 'Streaming JSON' }).locator('pre') + ).not.toHaveText(''); +} + +test('React Component Registry keeps local Docs, Code and Run aligned', async ({ + page, +}, testInfo) => { + await page.goto(`${route}?frontend=react`, { waitUntil: 'domcontentloaded' }); + await expect(page.getByLabel('Example UI')).toHaveValue('react'); + await expect( + page.getByRole('heading', { + name: 'React Component Registry preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/render/registry/react/src/views.tsx"]' + ) + ).toBeVisible(); + await expect(page.locator('[data-example-file*="/python/"]')).toHaveCount(0); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true' + ); + const navigation = page.locator('[data-workspace-desktop-navigation]'); + await navigation.getByRole('button', { name: 'Code', exact: true }).click(); + await expect(page).toHaveURL(/frontend=react/); + await expect(page).toHaveURL(/mode=code/); + await navigation.getByRole('button', { name: /^Run(?:,|$)/ }).click(); + await expectFreshRun(page); + await page.screenshot({ + path: testInfo.outputPath('react-registry-desktop.png'), + }); +}); + +test('React Component Registry restores fresh playback through frontend history', async ({ + page, +}) => { + await page.goto(`${route}?frontend=react&mode=run`, { + waitUntil: 'domcontentloaded', + }); + await expectFreshRun(page); + await renderChangedSample(page); + await page.getByLabel('Example UI').selectOption('angular'); + await expect(page.getByLabel('Example UI')).toHaveValue('angular'); + await expect(page).toHaveURL(`${route}?mode=run`); + await expect(page.locator('iframe')).toBeVisible(); + await expect(page.locator('iframe')).toHaveAttribute( + 'src', + /(?:localhost:4404(?:[/?#]|$)|\/render\/registry\/?(?:[?#]|$))/ + ); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectFreshRun(page); +}); + +test('React Component Registry reloads fresh playback after rendering another sample', async ({ + page, +}) => { + await page.goto(`${route}?frontend=react&mode=run`, { + waitUntil: 'domcontentloaded', + }); + await expectFreshRun(page); + await renderChangedSample(page); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expectFreshRun(page); + const example = frame(page); + await example.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(example.locator('[data-registry-badge]')).toHaveCount(1); + await expect(example.locator('[data-registry-fallback]')).toHaveCount(0); +}); + +test('canonical Registry Run uses all native registry modes without execution or source changes', async ({ + page, +}) => { + await page.goto(`${route}?frontend=react&mode=run`, { + waitUntil: 'domcontentloaded', + }); + await expectFreshRun(page); + const frame = page.frameLocator('iframe'), + choice = frame.getByRole('combobox', { name: 'Badge display' }); + const output = frame.getByRole('region', { name: 'Render output' }); + const source = frame + .getByRole('region', { name: 'Streaming JSON' }) + .locator('pre'); + await expect(frame.getByRole('status')).toHaveText('Paused · 0 characters'); + for (const [label, count] of [ + ['Basic Types', 1], + ['Card Layout', 1], + ['Mixed Components', 2], + ] as const) { + await frame + .getByRole('combobox', { name: 'Sample' }) + .selectOption({ label }); + await choice.selectOption('registered'); + await frame.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('[data-registry-badge]')).toHaveCount(count); + const original = await source.textContent(); + await choice.selectOption('omitted'); + await expect(output.locator('[data-registry-badge]')).toHaveCount(0); + await expect(output.locator('[data-registry-fallback]')).toHaveCount(0); + await choice.selectOption('fallback'); + await expect(output.locator('[data-registry-fallback]')).toHaveCount(count); + await expect(source).toHaveText(original ?? ''); + } + await frame.getByRole('button', { name: 'Reset', exact: true }).click(); + await expect(choice).toHaveValue('fallback'); + await frame.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('[data-registry-fallback]')).toHaveCount(2); +}); + +test('local React Registry remains usable on a narrow screen', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${route}?frontend=react&mode=run`, { + waitUntil: 'domcontentloaded', + }); + await expectFreshRun(page); + await expect(page.getByLabel('Example UI')).toBeVisible(); + const frame = page.frameLocator('iframe'); + await frame + .getByRole('combobox', { name: 'Badge display' }) + .selectOption('fallback'); + await frame.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(frame.locator('[data-registry-fallback]')).toHaveText( + 'Fallback: Registered' + ); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('react-registry-mobile.png'), + }); +}); diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index dda1eda38..d213f74b3 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -346,6 +346,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'node scripts/react-cockpit/serve.mjs render-registry --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4617', + reuseExistingServer, + timeout: 180_000, + }, ] : []), ] diff --git a/apps/website/project.json b/apps/website/project.json index 3d67c4ed4..351fb8e35 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -90,7 +90,8 @@ "cockpit-ag-ui-subagents-react", "cockpit-render-spec-rendering-react", "cockpit-render-state-management-react", - "cockpit-render-repeat-loops-react" + "cockpit-render-repeat-loops-react", + "cockpit-render-registry-react" ] } ], diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index 6df1bc8a5..f0d54fc20 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -103,3 +103,5 @@ Render Spec has an experimental local React preview at https://threadplane.ai/do State Management has an experimental local React preview at https://threadplane.ai/docs/render/guides/state-store?frontend=react. The React host owns copied immutable name, age and theme state; installed native RenderSpec resolves the three authored $state paths in read-only views. Edits survive partial playback, seek, Reset and sample selection. Invalid age drafts keep the last valid value, and reload restores the seed. Parser callbacks cannot replace host state; unknown bindings and executable fields are excluded. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-state-management-react` and verify with `npx nx e2e cockpit-render-state-management-react`. Repeat Loops has an experimental local React preview at https://threadplane.ai/docs/render/guides/repeat-loops?frontend=react. The host owns immutable items with stable ids; native RenderSpec renders a repeated Card once and repeats its child rows keyed by item id. Add, remove and reverse items while preserving row identity and playback source. The list is bounded at 32; edits survive seek, Reset and sample changes, and reload restores the seed. Partial repeat metadata waits for its exact path and key. Read-only views exclude unknown expressions, nested repeats and executable fields. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-repeat-loops-react` and verify with `npx nx e2e cockpit-render-repeat-loops-react`. + +Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index 6df1bc8a5..f0d54fc20 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -103,3 +103,5 @@ Render Spec has an experimental local React preview at https://threadplane.ai/do State Management has an experimental local React preview at https://threadplane.ai/docs/render/guides/state-store?frontend=react. The React host owns copied immutable name, age and theme state; installed native RenderSpec resolves the three authored $state paths in read-only views. Edits survive partial playback, seek, Reset and sample selection. Invalid age drafts keep the last valid value, and reload restores the seed. Parser callbacks cannot replace host state; unknown bindings and executable fields are excluded. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-state-management-react` and verify with `npx nx e2e cockpit-render-state-management-react`. Repeat Loops has an experimental local React preview at https://threadplane.ai/docs/render/guides/repeat-loops?frontend=react. The host owns immutable items with stable ids; native RenderSpec renders a repeated Card once and repeats its child rows keyed by item id. Add, remove and reverse items while preserving row identity and playback source. The list is bounded at 32; edits survive seek, Reset and sample changes, and reload restores the seed. Partial repeat metadata waits for its exact path and key. Read-only views exclude unknown expressions, nested repeats and executable fields. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-repeat-loops-react` and verify with `npx nx e2e cockpit-render-repeat-loops-react`. + +Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`. diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx index 87731c061..766ff4754 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -12,6 +12,7 @@ import { ReactAgUiToolViewsPreview } from '../../../../../components/docs/ReactA import { ReactAgUiJsonRenderPreview } from '../../../../../components/docs/ReactAgUiJsonRenderPreview'; import { ReactAgUiSubagentsPreview } from '../../../../../components/docs/ReactAgUiSubagentsPreview'; import { ReactRenderSpecPreview } from '../../../../../components/docs/ReactRenderSpecPreview'; +import { ReactRegistryPreview } from '../../../../../components/docs/ReactRegistryPreview'; import { ReactRepeatLoopsPreview } from '../../../../../components/docs/ReactRepeatLoopsPreview'; import { ReactStateManagementPreview } from '../../../../../components/docs/ReactStateManagementPreview'; import { ReactInterruptsPreview } from '../../../../../components/docs/ReactInterruptsPreview'; @@ -67,6 +68,16 @@ const route = (library: string, section: string, slug: string, mode?: string) => } as never); describe('unified docs workspace route', () => { + it('selects React Component Registry Docs with owned state and readonly view sources', async () => { + const tree = await route('render', 'guides', 'registry'); + const workspace = findElement(tree, WebsiteWorkspace as ComponentType); + const article = findElement(workspace?.props.reactDocsSlot, ReactRegistryPreview as ComponentType); + expect(article).not.toBeNull(); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/render/registry/react/src/app.tsx'); + expect(article?.props.exampleCode?.assetPaths).toContain('cockpit/render/registry/react/src/views.tsx'); + expect(article?.props.exampleCode?.assetPaths?.some(path => path.includes('/python/'))).toBe(false); + expect(findElement(workspace?.props.reactDocsSlot, ReactRenderSpecPreview as ComponentType)).toBeNull(); + }); it('selects React Repeat Loops Docs with owned state and readonly view sources', async () => { const tree = await route('render', 'guides', 'repeat-loops'); const workspace = findElement(tree, WebsiteWorkspace as ComponentType); diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index 17df1cfbb..3804b81fb 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -16,6 +16,7 @@ import { ReactAgUiToolViewsPreview } from '../../../../../components/docs/ReactA import { ReactAgUiJsonRenderPreview } from '../../../../../components/docs/ReactAgUiJsonRenderPreview'; import { ReactAgUiSubagentsPreview } from '../../../../../components/docs/ReactAgUiSubagentsPreview'; import { ReactRenderSpecPreview } from '../../../../../components/docs/ReactRenderSpecPreview'; +import { ReactRegistryPreview } from '../../../../../components/docs/ReactRegistryPreview'; import { ReactRepeatLoopsPreview } from '../../../../../components/docs/ReactRepeatLoopsPreview'; import { ReactStateManagementPreview } from '../../../../../components/docs/ReactStateManagementPreview'; import { DocsSearch } from '../../../../../components/docs/DocsSearch'; @@ -271,10 +272,11 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || - (library === 'render' && section === 'guides' && ['specs','state-store','repeat-loops'].includes(slug)) || + (library === 'render' && section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || (library === 'ag-ui' && ((section === 'reference' && slug === 'event-mapping') || (section === 'guides' && ['interrupts','tool-views','json-render','subagents'].includes(slug)))) || (library === 'chat' && slug === 'client-tools')) ? ( library === 'render' ? ( + slug === 'registry' ? : slug === 'repeat-loops' ? : slug === 'state-store' ? : diff --git a/apps/website/src/components/docs/ReactRegistryPreview.tsx b/apps/website/src/components/docs/ReactRegistryPreview.tsx new file mode 100644 index 000000000..bc13220d7 --- /dev/null +++ b/apps/website/src/components/docs/ReactRegistryPreview.tsx @@ -0,0 +1,60 @@ +import type { ExampleCodeContext } from '../../lib/example-code'; +import { MdxRenderer } from './MdxRenderer'; + +const source = `# React Component Registry preview + +A React registry maps authored type names to read-only view components. This experimental local example uses the installed **@threadplane/react/render** component and three authored JSON samples to show native registered views, omitted views and an authored fallback. + +## Try each registry mode + +Choose **Run**, select **Basic Types**, **Card Layout** or **Mixed Components**, and press **Finish**. **Badge display** selects **Registered**, **Omitted** or **Fallback**. Registered maps Badge to its view. Omitted leaves Badge out of the registry, so the native renderer leaves it out of the view. Fallback uses the same reduced registry and supplies an authored component to display its label. + +Changing registry mode does not rewrite the JSON or move playback. Your choice survives **Play**, **Pause**, **Playback position**, **Finish**, **Reset** and sample changes. Reloading restores Registered. No agent requests or saved browser state are used. + + + +## Supply components and native children + +The host owns two stable frozen maps: the complete Heading, Text, Card and Badge registry, and a map with Badge omitted. RenderSpec receives the selected registry and the optional fallback. React uses a plain map of view components; this preview does not require Angular registry helpers. + +A registered view receives read-only resolved props, elementKey, loading and children. Heading and Card display the children supplied by RenderSpec exactly once. Text and Badge are leaves. The authored fallback displays a literal label and uses the same loading flag. It is intentionally demonstrated only for the known Badge type omitted from the selected map. + + + + + +## Admit the authored source before playback + +Registry selection and source admission have separate jobs. This example admits the four authored types and literal display props before playing a sample. It rejects unknown types, expressions and executable fields. Then it copies bounded partial data without freezing the parser's mutable tree. + + + +The private playback owner rebuilds its parser on seek and revokes obsolete callbacks on Pause, Reset, selection changes and page exit. Complete requires the finished parser output to match the admitted source. Playback never changes registry choice. + + + +## Source build and verification + +From a Threadplane checkout with dependencies installed: + +~~~sh +npx nx build cockpit-render-registry-react +npx nx e2e cockpit-render-registry-react +~~~ + +The isolated build installs local React, core and content artifacts with root-locked presentation dependencies. Browser coverage exercises all three registry modes, native fallback props and loading, playback retention, cleanup and narrow screens. Select **Angular** to read and run its existing registry implementation. +`; + +export function ReactRegistryPreview({ + exampleCode, +}: { + readonly exampleCode: ExampleCodeContext | null; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/website/src/playwright-config.spec.ts b/apps/website/src/playwright-config.spec.ts index 8b0820cf6..2854d6537 100644 --- a/apps/website/src/playwright-config.spec.ts +++ b/apps/website/src/playwright-config.spec.ts @@ -12,8 +12,8 @@ describe('Website Playwright configuration', () => { const prerequisites = project.targets.e2e.dependsOn?.find( (dependency: { target?: string }) => dependency.target === 'build' )?.projects ?? []; - expect(prerequisites).toHaveLength(17); - expect(new Set(prerequisites).size).toBe(17); + expect(prerequisites).toHaveLength(18); + expect(new Set(prerequisites).size).toBe(18); expect([...prerequisites].sort()).toEqual( getCockpitFrontends() .filter(frontend => frontend.frontend === 'react') @@ -37,21 +37,23 @@ describe('Website Playwright configuration', () => { const project = JSON.parse(readFileSync(resolve(import.meta.dirname,'../project.json'),'utf8')); expect(project.implicitDependencies).toContain('cockpit-ag-ui-tool-views-angular'); }); - it('starts seventeen owned React servers after exact fresh Nx prerequisites', () => { + it('starts eighteen owned React servers after exact fresh Nx prerequisites', () => { const config = createWebsitePlaywrightConfig({ CI: 'true' }); const servers = Array.isArray(config.webServer) ? config.webServer : []; - const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[0123456])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(17); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[01234567])$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(18); expect(reactServers.every(server => server.reuseExistingServer === false)).toBe(true); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4610')?.command).toBe('node scripts/react-cockpit/serve.mjs ag-ui-interrupts --no-parent'); const project = JSON.parse(readFileSync(resolve(import.meta.dirname, '../project.json'), 'utf8')); const projects = project.targets.e2e.dependsOn.find( (dependency: { target?: string }) => dependency.target === 'build' ).projects; - expect(projects).toHaveLength(17); - expect(new Set(projects).size).toBe(17); + expect(projects).toHaveLength(18); + expect(new Set(projects).size).toBe(18); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); + expect(projects).toContain('cockpit-render-registry-react'); + expect(reactServers.find(server => server.url === 'http://127.0.0.1:4617')?.command).toBe('node scripts/react-cockpit/serve.mjs render-registry --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4616')?.command).toBe('node scripts/react-cockpit/serve.mjs render-repeat-loops --no-parent'); expect(reactServers.find(server => server.url === 'http://127.0.0.1:4615')?.command).toBe('node scripts/react-cockpit/serve.mjs render-state-management --no-parent'); expect(projects).toContain('cockpit-ag-ui-interrupts-react'); @@ -322,6 +324,7 @@ describe('Website Playwright configuration', () => { expect.objectContaining({command:'node scripts/react-cockpit/serve.mjs render-spec-rendering --no-parent',url:'http://127.0.0.1:4614'}), expect.objectContaining({command:'node scripts/react-cockpit/serve.mjs render-state-management --no-parent',url:'http://127.0.0.1:4615'}), expect.objectContaining({command:'node scripts/react-cockpit/serve.mjs render-repeat-loops --no-parent',url:'http://127.0.0.1:4616'}), + expect.objectContaining({command:'node scripts/react-cockpit/serve.mjs render-registry --no-parent',url:'http://127.0.0.1:4617'}), ]); expect(config.testIgnore).toEqual([ '**/platform-production-smoke.spec.ts', diff --git a/cockpit/render/registry/react/README.md b/cockpit/render/registry/react/README.md new file mode 100644 index 000000000..5611c248b --- /dev/null +++ b/cockpit/render/registry/react/README.md @@ -0,0 +1,19 @@ +# Native React Component Registry preview + +Three authored local JSON samples render through installed `@threadplane/react/render`. +A host selector chooses a complete readonly component map, a map with Badge +omitted, or the reduced map with an authored fallback. Native RenderSpec supplies +resolved props, element key, loading and recursively rendered children. + +Registry choice survives playback, seek, Reset and sample selection. It does not +change JSON or parser position; reloading restores Registered. The projection +admits only four authored literal types. No agent requests are needed. + +```sh +npx nx build cockpit-render-registry-react +npx nx e2e cockpit-render-registry-react +``` + +The closed static build installs local React/core/content artifacts and locked +presentation dependencies. Browser checks cover registry modes, native fallback +props/loading, supplied children, playback retention, page exit and narrow screens. diff --git a/cockpit/render/registry/react/e2e/playwright.config.ts b/cockpit/render/registry/react/e2e/playwright.config.ts new file mode 100644 index 000000000..ec3fa2f13 --- /dev/null +++ b/cockpit/render/registry/react/e2e/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', + workers: 1, + fullyParallel: false, + use: { baseURL: 'http://127.0.0.1:4617', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'node scripts/react-cockpit/serve.mjs render-registry --no-parent', + cwd: '../../../../..', + url: 'http://127.0.0.1:4617', + reuseExistingServer: false, + timeout: 10000, + }, +}); diff --git a/cockpit/render/registry/react/e2e/registry.spec.ts b/cockpit/render/registry/react/e2e/registry.spec.ts new file mode 100644 index 000000000..96e2b7038 --- /dev/null +++ b/cockpit/render/registry/react/e2e/registry.spec.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test'; +import { REGISTRY_SAMPLES } from '../src/specs'; + +test('installed native registry registers, omits and falls back in all three samples without rewriting source', async ({ + page, +}, testInfo) => { + const writes: string[] = [], + errors: string[] = []; + page.on('request', (request) => { + if (!['GET', 'HEAD'].includes(request.method())) writes.push(request.url()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('/'); + const choice = page.getByRole('combobox', { name: 'Badge display' }); + const output = page.getByRole('region', { name: 'Render output' }); + const source = page + .getByRole('region', { name: 'Streaming JSON' }) + .locator('pre'); + await expect(choice).toHaveValue('registered'); + for (const [sample, count] of [ + ['Basic Types', 1], + ['Card Layout', 1], + ['Mixed Components', 2], + ] as const) { + await page + .getByRole('combobox', { name: 'Sample' }) + .selectOption({ label: sample }); + await choice.selectOption('registered'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('[data-registry-badge]')).toHaveCount(count); + const json = await source.textContent(), + position = await page.getByRole('slider').inputValue(); + await choice.selectOption('omitted'); + await expect(output.locator('[data-registry-badge]')).toHaveCount(0); + await expect(output.locator('[data-registry-fallback]')).toHaveCount(0); + await choice.selectOption('fallback'); + await expect(output.locator('[data-registry-fallback]')).toHaveCount(count); + await expect(source).toHaveText(json ?? ''); + await expect(page.getByRole('slider')).toHaveValue(position); + await choice.selectOption('registered'); + await expect(output.locator('[data-registry-badge]')).toHaveCount(count); + } + await expect(output.locator('article')).toHaveCount(2); + await expect( + output.getByText('Text inside the first card section.', { exact: true }) + ).toHaveCount(1); + await expect( + output.getByText('Text inside the second card section.', { exact: true }) + ).toHaveCount(1); + await page.screenshot({ + path: testInfo.outputPath('registry-desktop.png'), + fullPage: true, + }); + expect(writes).toEqual([]); + expect(errors).toEqual([]); +}); + +test('native fallback receives element key, resolved label and loading while a spec is partial', async ({ + page, +}) => { + await page.goto('/'); + await page + .getByRole('combobox', { name: 'Badge display' }) + .selectOption('fallback'); + await page + .getByRole('slider') + .fill(String(REGISTRY_SAMPLES[0].json.length - 2)); + const fallback = page.locator('[data-registry-fallback]'); + await expect(fallback).toHaveText('Fallback: Registered'); + await expect(fallback).toHaveAttribute('data-fallback-key', 'badge'); + await expect(fallback).toHaveAttribute('aria-busy', 'true'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(fallback).toHaveAttribute('aria-busy', 'false'); +}); + +test('host registry choice survives paused and active playback, rewind, Reset and sample changes', async ({ + page, +}) => { + await page.goto('/'); + const choice = page.getByRole('combobox', { name: 'Badge display' }); + const position = page.getByRole('slider'); + await choice.selectOption('fallback'); + await page.getByRole('button', { name: 'Play', exact: true }).click(); + await expect + .poll(async () => Number(await position.inputValue())) + .toBeGreaterThan(0); + await page.getByRole('button', { name: 'Pause', exact: true }).click(); + const paused = await position.inputValue(); + await choice.selectOption('omitted'); + await expect(position).toHaveValue(paused); + await choice.selectOption('fallback'); + await position.fill('0'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(page.locator('[data-registry-fallback]')).toHaveCount(1); + await page.getByRole('button', { name: 'Reset', exact: true }).click(); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('2'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(choice).toHaveValue('fallback'); + await expect(page.locator('[data-registry-fallback]')).toHaveCount(2); + await page.reload(); + await expect(choice).toHaveValue('registered'); +}); + +test('narrow layout and page exit remain safe with an authored fallback', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page + .getByRole('combobox', { name: 'Badge display' }) + .selectOption('fallback'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(page.locator('[data-registry-fallback]')).toHaveText( + 'Fallback: Registered' + ); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('registry-mobile.png'), + fullPage: true, + }); + await page.getByRole('button', { name: 'Play', exact: true }).click(); + await page.evaluate(() => + window.dispatchEvent(new PageTransitionEvent('pagehide')) + ); + await expect(page.locator('#root')).toBeEmpty(); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + await expect(page.locator('#root')).toBeEmpty(); +}); diff --git a/cockpit/render/registry/react/index.html b/cockpit/render/registry/react/index.html new file mode 100644 index 000000000..803de9378 --- /dev/null +++ b/cockpit/render/registry/react/index.html @@ -0,0 +1,12 @@ + + + + + + React Component Registry + + +
+ + + diff --git a/cockpit/render/registry/react/project.json b/cockpit/render/registry/react/project.json new file mode 100644 index 000000000..dd01fd908 --- /dev/null +++ b/cockpit/render/registry/react/project.json @@ -0,0 +1,36 @@ +{ + "name": "cockpit-render-registry-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/registry/react/src", + "projectType": "application", + "tags": ["scope:cockpit-examples", "scope:cockpit-e2e", "framework:react"], + "targets": { + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/render/registry/react/vite.config.mts" + } + }, + "build": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { "target": "build", "projects": ["core", "content", "react"] } + ], + "outputs": ["{workspaceRoot}/dist/cockpit/render/registry/react"], + "options": { + "command": "node scripts/react-cockpit/build.mjs render-registry" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": ["build"], + "options": { + "config": "cockpit/render/registry/react/e2e/playwright.config.ts", + "skipInstall": true + } + } + } +} diff --git a/cockpit/render/registry/react/src/app.spec.tsx b/cockpit/render/registry/react/src/app.spec.tsx new file mode 100644 index 000000000..4743fa284 --- /dev/null +++ b/cockpit/render/registry/react/src/app.spec.tsx @@ -0,0 +1,171 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { RegistryDemo } from './app'; +import { createPlayback } from './playback'; +import { REGISTRY_SAMPLES } from './specs'; + +afterEach(cleanup); +function fixture() { + const callbacks: FrameRequestCallback[] = []; + const playback = createPlayback(REGISTRY_SAMPLES, { + request: (callback) => { + callbacks.push(callback); + return callbacks.length; + }, + cancel: () => undefined, + }); + const ready = vi.fn(); + render( + sample.label)} + onReady={ready} + /> + ); + return { playback, ready, callbacks }; +} +const click = (name: string) => + fireEvent.click(screen.getByRole('button', { name })); +const output = () => screen.getByRole('region', { name: 'Render output' }); +const mode = (value: string) => + fireEvent.change(screen.getByRole('combobox', { name: 'Badge display' }), { + target: { value }, + }); +const choose = (value: number) => + fireEvent.change(screen.getByRole('combobox', { name: 'Sample' }), { + target: { value: String(value) }, + }); +const source = () => + screen.getByRole('region', { name: 'Streaming JSON' }).querySelector('pre') + ?.textContent; + +it('starts Registered and empty with an owned readiness callback', () => { + const { ready, playback } = fixture(); + expect(ready).toHaveBeenCalledTimes(1); + expect(playback.getSnapshot().position).toBe(0); + expect( + ( + screen.getByRole('combobox', { + name: 'Badge display', + }) as HTMLSelectElement + ).value + ).toBe('registered'); + expect(output().textContent).toContain('Play a sample'); + expect(source()).toBe(''); +}); + +it('uses the native registry to register, omit or fallback for each known Badge without changing source', () => { + const { playback } = fixture(); + for (const [sample, count] of [ + [0, 1], + [1, 1], + [2, 2], + ]) { + choose(sample); + mode('registered'); + click('Finish'); + expect(output().querySelectorAll('[data-registry-badge]')).toHaveLength( + count + ); + expect(output().querySelectorAll('[data-registry-fallback]')).toHaveLength( + 0 + ); + const json = source(), + position = playback.getSnapshot().position; + mode('omitted'); + expect(output().querySelectorAll('[data-registry-badge]')).toHaveLength(0); + expect(output().querySelectorAll('[data-registry-fallback]')).toHaveLength( + 0 + ); + mode('fallback'); + expect(output().querySelectorAll('[data-registry-fallback]')).toHaveLength( + count + ); + expect(source()).toBe(json); + expect(playback.getSnapshot().position).toBe(position); + mode('registered'); + expect(output().querySelectorAll('[data-registry-badge]')).toHaveLength( + count + ); + } +}); + +it('passes native resolved props, exact element key and loading to the authored fallback', () => { + const { playback } = fixture(); + mode('fallback'); + act(() => playback.seek(REGISTRY_SAMPLES[0].json.length - 2)); + const fallback = output().querySelector('[data-registry-fallback]'); + expect(fallback?.getAttribute('data-fallback-key')).toBe('badge'); + expect(fallback?.textContent).toBe('Fallback: Registered'); + expect(fallback?.getAttribute('aria-busy')).toBe('true'); + click('Finish'); + expect( + output() + .querySelector('[data-registry-fallback]') + ?.getAttribute('aria-busy') + ).toBe('false'); +}); + +it('keeps registry choice through active and stale playback, rewind, Reset and selection', () => { + const { playback, callbacks } = fixture(); + mode('fallback'); + click('Play'); + const stale = callbacks.at(-1)!; + click('Pause'); + const paused = playback.getSnapshot(); + act(() => stale(0)); + expect(playback.getSnapshot()).toBe(paused); + click('Finish'); + act(() => playback.seek(0)); + click('Finish'); + expect(output().querySelectorAll('[data-registry-fallback]')).toHaveLength(1); + click('Reset'); + choose(2); + click('Finish'); + expect(output().querySelectorAll('[data-registry-fallback]')).toHaveLength(2); + expect( + ( + screen.getByRole('combobox', { + name: 'Badge display', + }) as HTMLSelectElement + ).value + ).toBe('fallback'); +}); + +it('displays native children exactly once in each authored container', () => { + fixture(); + choose(2); + click('Finish'); + expect(output().querySelectorAll('article')).toHaveLength(2); + for (const text of [ + 'First Section', + 'Second Section', + 'Text inside the first card section.', + 'Text inside the second card section.', + ]) + expect(output().textContent?.split(text)).toHaveLength(2); +}); + +it('mounts every actual prefix through all native registry modes without executable markup', () => { + const { playback } = fixture(); + for (const choice of ['registered', 'omitted', 'fallback']) { + mode(choice); + for (let sample = 0; sample < REGISTRY_SAMPLES.length; sample++) { + choose(sample); + for ( + let position = 0; + position <= REGISTRY_SAMPLES[sample].json.length; + position++ + ) + act(() => playback.seek(position)); + expect(playback.getSnapshot().phase).toBe('complete'); + } + } + expect(output().querySelector('script')).toBeNull(); +}); diff --git a/cockpit/render/registry/react/src/app.tsx b/cockpit/render/registry/react/src/app.tsx new file mode 100644 index 000000000..d57e27e8a --- /dev/null +++ b/cockpit/render/registry/react/src/app.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState, useSyncExternalStore } from 'react'; +import { RenderSpec } from '@threadplane/react/render'; +import type { Playback } from './playback'; +import { localRegistry, withoutBadgeRegistry, FallbackBadge } from './views'; + +export function RegistryDemo({ + playback, + samples, + onReady, +}: { + readonly playback: Playback; + readonly samples: readonly string[]; + readonly onReady: () => void; +}) { + const [mode, setMode] = useState('registered'); + const state = useSyncExternalStore( + playback.subscribe, + playback.getSnapshot, + playback.getSnapshot + ); + useEffect(onReady, [onReady]); + const phase = + state.phase === 'error' + ? 'Unavailable' + : state.phase[0].toUpperCase() + state.phase.slice(1); + return ( +
+
+

React · Experimental

+

Component Registry

+
+

+ Choose how the registry handles Badge elements. The native renderer can + display a registered view, omit it, or use an authored fallback. +

+
+ +

+ Registry choice survives playback and sample changes. Reloading + restores Registered. +

+
+
+ +
+ + + +
+ +

+ {phase} · {state.position} characters +

+ {state.phase === 'error' && ( +

+ This sample could not be displayed. Choose another sample to + continue. +

+ )} +
+
+
+

Render output

+ {state.spec ? ( + + ) : ( +

Play a sample to see its view.

+ )} +
+
+

Streaming JSON

+
{state.rawJson}
+
+
+
+ ); +} diff --git a/cockpit/render/registry/react/src/main.tsx b/cockpit/render/registry/react/src/main.tsx new file mode 100644 index 000000000..e79b5d063 --- /dev/null +++ b/cockpit/render/registry/react/src/main.tsx @@ -0,0 +1,29 @@ +import { createRoot } from 'react-dom/client'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this internal parent health bridge. +import { installRuntimeBridge } from '../../../../../libs/cockpit-runtime-bridge/src/index'; +import { RegistryDemo } from './app'; +import { createPlayback } from './playback'; +import { REGISTRY_SAMPLES } from './specs'; +import './styles.css'; + +const element = document.getElementById('root'); +if (!element) throw new Error('Application root is missing'); +const bridge = installRuntimeBridge(); +const playback = createPlayback(REGISTRY_SAMPLES); +const root = createRoot(element); +window.addEventListener( + 'pagehide', + () => { + playback.dispose(); + root.unmount(); + bridge.dispose(); + }, + { once: true } +); +root.render( + sample.label)} + onReady={() => bridge.markReady()} + /> +); diff --git a/cockpit/render/registry/react/src/playback-terminal.spec.ts b/cockpit/render/registry/react/src/playback-terminal.spec.ts new file mode 100644 index 000000000..40e1fe100 --- /dev/null +++ b/cockpit/render/registry/react/src/playback-terminal.spec.ts @@ -0,0 +1,72 @@ +import { expect, it, vi } from 'vitest'; +const fault = vi.hoisted(() => ({ + mode: 'none' as 'none' | 'incomplete' | 'different', +})); +vi.mock('@cacheplane/partial-json', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@cacheplane/partial-json') + >(); + return { + ...actual, + createPartialJsonParser: () => { + const parser = actual.createPartialJsonParser(); + let finished = false; + const other = actual.createPartialJsonParser(); + other.push( + JSON.stringify({ + root: 'root', + elements: { + root: { type: 'Text', props: { content: 'Different output' } }, + }, + }) + ); + other.finish(); + return { + push: parser.push.bind(parser), + finish() { + parser.finish(); + finished = true; + }, + get root() { + if (finished && fault.mode === 'different') return other.root; + if (finished && fault.mode === 'incomplete' && parser.root) + return { ...parser.root, status: 'streaming' }; + return parser.root; + }, + }; + }, + }; +}); +import { createPlayback } from './playback'; + +it.each(['incomplete', 'different'] as const)( + 'refuses %s terminal parser output and recovers on Reset', + (mode) => { + const source = JSON.stringify({ + root: 'root', + elements: { + root: { type: 'Text', props: { content: 'Authored output' } }, + }, + }); + fault.mode = mode; + const playback = createPlayback([{ label: 'Authored', json: source }]); + const phases: string[] = []; + playback.subscribe(() => phases.push(playback.getSnapshot().phase)); + playback.finish(); + expect(playback.getSnapshot()).toMatchObject({ + phase: 'error', + playing: false, + rawJson: '', + spec: null, + }); + expect(phases).not.toContain('complete'); + fault.mode = 'none'; + playback.reset(); + playback.finish(); + expect(playback.getSnapshot()).toMatchObject({ + phase: 'complete', + rawJson: source, + }); + expect(playback.getSnapshot().spec).toEqual(JSON.parse(source)); + } +); diff --git a/cockpit/render/registry/react/src/playback.spec.ts b/cockpit/render/registry/react/src/playback.spec.ts new file mode 100644 index 000000000..4b35f203a --- /dev/null +++ b/cockpit/render/registry/react/src/playback.spec.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from 'vitest'; +import { createPlayback, type FrameScheduler } from './playback'; + +function samples() { + return [ + { + label: 'First', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'First literal answer' }, + children: ['child'], + }, + child: { type: 'Text', props: { content: 'Later child' } }, + }, + }), + }, + { + label: 'Second', + json: JSON.stringify({ + root: 'root', + elements: { root: { type: 'Card', props: { title: 'Second sample' } } }, + }), + }, + ]; +} + +function frames() { + let next = 0; + const pending = new Map(); + const all: FrameRequestCallback[] = []; + const scheduler: FrameScheduler = { + request(callback) { + const id = ++next; + pending.set(id, callback); + all.push(callback); + return id; + }, + cancel(id) { + pending.delete(id); + }, + }; + return { + scheduler, + all, + count: () => pending.size, + tick() { + const first = pending.entries().next().value; + if (!first) throw Error('No current playback frame'); + pending.delete(first[0]); + first[1](0); + }, + }; +} + +describe('local parser playback ownership', () => { + it('starts empty, stable and paused without scheduling work', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + const initial = store.getSnapshot(); + expect(initial).toMatchObject({ + selected: 0, + label: 'First', + position: 0, + phase: 'paused', + playing: false, + rawJson: '', + spec: null, + }); + expect(initial.total).toBe(samples()[0].json.length); + expect(store.getSnapshot()).toBe(initial); + expect(Object.isFrozen(initial)).toBe(true); + expect(clock.count()).toBe(0); + }); + + it('publishes actual partial text and retains it immutably when paused', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + store.play(); + store.play(); + expect(clock.count()).toBe(1); + let steps = 0; + while ( + !store.getSnapshot().spec?.elements.root.props.content && + steps++ < samples()[0].json.length + ) + clock.tick(); + const retained = store.getSnapshot(); + expect(retained.position).toBeGreaterThan(0); + expect(retained.position).toBeLessThan(retained.total); + expect(retained.spec?.elements.root.props.content).not.toBe( + 'First literal answer' + ); + store.pause(); + const paused = store.getSnapshot(); + expect(paused.playing).toBe(false); + expect(clock.count()).toBe(0); + clock.all.at(-1)!(0); + expect(store.getSnapshot()).toBe(paused); + store.finish(); + expect(store.getSnapshot().phase).toBe('complete'); + expect(store.getSnapshot().spec?.elements.root.props.content).toBe( + 'First literal answer' + ); + expect(retained.spec?.elements.root.props.content).not.toBe( + 'First literal answer' + ); + }); + + it('finishes through the real parser and owns its original source strings', () => { + const borrowed = samples(); + const original = borrowed[0].json; + const clock = frames(); + const store = createPlayback(borrowed, clock.scheduler); + borrowed[0].json = samples()[1].json; + borrowed[0].label = 'Replaced'; + store.finish(); + const final = store.getSnapshot(); + expect(final).toMatchObject({ + label: 'First', + phase: 'complete', + playing: false, + rawJson: original, + position: original.length, + }); + expect(final.spec).toEqual(JSON.parse(original)); + expect(Object.isFrozen(final.spec?.elements)).toBe(true); + expect(clock.count()).toBe(0); + }); + + it('rebuilds parser state on rewind and clamps finite seek positions', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + store.finish(); + store.seek(5.9); + expect(store.getSnapshot()).toMatchObject({ + position: 5, + spec: null, + rawJson: samples()[0].json.slice(0, 5), + phase: 'paused', + }); + const current = store.getSnapshot(); + store.seek(Number.NaN); + store.seek(Number.POSITIVE_INFINITY); + expect(store.getSnapshot()).toBe(current); + store.seek(10000); + expect(store.getSnapshot().phase).toBe('complete'); + store.seek(-2); + expect(store.getSnapshot()).toMatchObject({ + position: 0, + spec: null, + rawJson: '', + phase: 'paused', + }); + }); + + it('revokes prior frames when switching samples and clearing playback', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + store.play(); + clock.tick(); + const stale = clock.all.at(-1)!; + store.select(1); + const empty = store.getSnapshot(); + stale(0); + expect(store.getSnapshot()).toBe(empty); + expect(empty).toMatchObject({ + selected: 1, + label: 'Second', + position: 0, + spec: null, + playing: false, + }); + store.finish(); + expect(store.getSnapshot().spec?.elements.root.props.title).toBe( + 'Second sample' + ); + store.reset(); + expect(store.getSnapshot()).toMatchObject({ + selected: 1, + position: 0, + spec: null, + }); + expect(clock.count()).toBe(0); + }); + + it('replays a completed sample from zero through one current frame', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + store.finish(); + store.play(); + expect(store.getSnapshot()).toMatchObject({ + position: 0, + playing: true, + spec: null, + }); + expect(clock.count()).toBe(1); + let steps = 0; + while (store.getSnapshot().playing && steps++ < samples()[0].json.length) + clock.tick(); + expect(store.getSnapshot().phase).toBe('complete'); + expect(store.getSnapshot().rawJson).toBe(samples()[0].json); + expect(clock.count()).toBe(0); + }); + + it('honors reentrant pause before scheduling the next frame', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + store.subscribe(() => { + if (store.getSnapshot().playing) store.pause(); + }); + store.play(); + expect(store.getSnapshot().playing).toBe(false); + expect(clock.count()).toBe(0); + }); + + it('honors reentrant sample selection during a parser publication', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + let switched = false; + store.subscribe(() => { + if (!switched && store.getSnapshot().position > 0) { + switched = true; + store.select(1); + } + }); + store.play(); + clock.tick(); + expect(store.getSnapshot()).toMatchObject({ + selected: 1, + position: 0, + playing: false, + spec: null, + }); + expect(clock.count()).toBe(0); + }); + + it('contains throwing subscribers and fences every action after disposal', () => { + const clock = frames(); + const store = createPlayback(samples(), clock.scheduler); + let updates = 0; + store.subscribe(() => { + throw Error('private listener'); + }); + store.subscribe(() => { + updates++; + }); + expect(() => store.play()).not.toThrow(); + clock.tick(); + expect(updates).toBeGreaterThan(0); + const count = updates, + retained = store.getSnapshot(); + store.dispose(); + const disposed = store.getSnapshot(); + expect(disposed.playing).toBe(false); + for (const stale of clock.all) stale(0); + store.play(); + store.pause(); + store.seek(9); + store.select(1); + store.finish(); + store.reset(); + store.dispose(); + expect(updates).toBe(count); + expect(clock.count()).toBe(0); + expect(store.getSnapshot()).toBe(disposed); + expect(retained.position).toBeGreaterThan(0); + let late = 0; + store.subscribe(() => late++); + expect(late).toBe(0); + }); + + it('rejects malformed complete samples before playing and permits a valid selection', () => { + for (const json of [ + '{bad}', + '{"root":invalid', + samples()[0].json + ' trailing', + 'x'.repeat(16385), + ]) { + const clock = frames(); + const store = createPlayback( + [{ label: 'Invalid', json }, samples()[1]], + clock.scheduler + ); + expect(store.getSnapshot()).toMatchObject({ + phase: 'error', + playing: false, + spec: null, + rawJson: '', + }); + store.play(); + expect(clock.count()).toBe(0); + store.select(1); + store.finish(); + expect(store.getSnapshot()).toMatchObject({ + phase: 'complete', + label: 'Second', + }); + } + }); +}); diff --git a/cockpit/render/registry/react/src/playback.ts b/cockpit/render/registry/react/src/playback.ts new file mode 100644 index 000000000..3e0dcfd8d --- /dev/null +++ b/cockpit/render/registry/react/src/playback.ts @@ -0,0 +1,229 @@ +import type { RenderSpecData } from '@threadplane/react/render'; +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +import { projectPartialSpec, validateCompleteSpec } from './projection'; + +export interface PlaybackSample { + readonly label: string; + readonly json: string; +} +export interface FrameScheduler { + request(callback: FrameRequestCallback): number; + cancel(id: number): void; +} +export interface PlaybackSnapshot { + readonly selected: number; + readonly label: string; + readonly position: number; + readonly total: number; + readonly phase: 'paused' | 'playing' | 'complete' | 'error'; + readonly playing: boolean; + readonly rawJson: string; + readonly spec: RenderSpecData | null; +} +export interface Playback { + getSnapshot(): PlaybackSnapshot; + subscribe(listener: () => void): () => void; + play(): void; + pause(): void; + seek(position: number): void; + select(index: number): void; + finish(): void; + reset(): void; + dispose(): void; +} + +export function createPlayback( + samples: readonly PlaybackSample[], + scheduler: FrameScheduler = { + request: (callback) => requestAnimationFrame(callback), + cancel: (id) => cancelAnimationFrame(id), + } +): Playback { + const owned = samples.map((sample) => ({ + label: sample.label, + json: sample.json, + })); + const listeners = new Set<() => void>(); + let live = true; + let generation = 0; + let frame: number | undefined; + let parser = createPartialJsonParser(); + let admitted: RenderSpecData | null = null; + let snapshot: PlaybackSnapshot; + + function revoke(): number { + generation++; + if (frame !== undefined) scheduler.cancel(frame); + frame = undefined; + return generation; + } + + function publish(next: PlaybackSnapshot): void { + snapshot = Object.freeze(next); + const current = generation; + for (const listener of [...listeners]) { + if (!live || current !== generation) break; + if (!listeners.has(listener)) continue; + try { + listener(); + } catch { + /* A subscriber cannot take ownership of playback. */ + } + } + } + + function initialize(selected: number, notify: boolean): void { + parser = createPartialJsonParser(); + admitted = null; + const sample = owned[selected]; + try { + if (!sample || sample.json.length > 16 * 1024) + throw Error('Invalid sample'); + // Incremental parsers can retain malformed input as streaming forever. + admitted = validateCompleteSpec(JSON.parse(sample.json)); + } catch { + /* Only authored, bounded complete JSON may be played. */ + } + const next: PlaybackSnapshot = { + selected, + label: sample?.label ?? 'Unavailable sample', + position: 0, + total: sample?.json.length ?? 0, + phase: admitted ? 'paused' : 'error', + playing: false, + rawJson: '', + spec: null, + }; + if (notify) publish(next); + else snapshot = Object.freeze(next); + } + + function advance(position: number, playing: boolean): void { + const source = owned[snapshot.selected].json; + try { + parser.push(source.slice(snapshot.position, position)); + const complete = position === source.length; + if (complete) parser.finish(); + const value = parser.root ? materialize(parser.root) : null; + const spec = complete + ? validateCompleteSpec(value) + : projectPartialSpec(value); + if ( + complete && + (parser.root?.status !== 'complete' || + JSON.stringify(spec) !== JSON.stringify(admitted)) + ) + throw Error('Incomplete sample'); + publish({ + ...snapshot, + position, + rawJson: source.slice(0, position), + spec, + playing: playing && !complete, + phase: complete ? 'complete' : playing ? 'playing' : 'paused', + }); + } catch { + revoke(); + publish({ + ...snapshot, + phase: 'error', + playing: false, + rawJson: '', + spec: null, + }); + } + } + + function schedule(current: number): void { + if ( + !live || + generation !== current || + !snapshot.playing || + frame !== undefined + ) + return; + frame = scheduler.request(() => { + if (!live || generation !== current || !snapshot.playing) return; + frame = undefined; + advance(Math.min(snapshot.total, snapshot.position + 4), true); + schedule(current); + }); + } + + function seek(position: number): void { + if (!live || !Number.isFinite(position) || !admitted) return; + revoke(); + parser = createPartialJsonParser(); + const target = Math.max(0, Math.min(snapshot.total, Math.floor(position))); + // Rebuild privately, then publish once so a subscriber cannot interrupt a reset. + snapshot = Object.freeze({ + ...snapshot, + position: 0, + rawJson: '', + spec: null, + playing: false, + phase: 'paused', + }); + advance(target, false); + } + + initialize(0, false); + return { + getSnapshot: () => snapshot, + subscribe(listener) { + if (live) listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + play() { + if (!live || !admitted || snapshot.playing) return; + const current = revoke(); + if (snapshot.phase === 'complete') { + parser = createPartialJsonParser(); + snapshot = Object.freeze({ + ...snapshot, + position: 0, + rawJson: '', + spec: null, + }); + } + publish({ ...snapshot, playing: true, phase: 'playing' }); + schedule(current); + }, + pause() { + if (!live || !snapshot.playing) return; + revoke(); + publish({ ...snapshot, playing: false, phase: 'paused' }); + }, + seek, + select(index) { + if ( + !live || + !Number.isInteger(index) || + index < 0 || + index >= owned.length + ) + return; + revoke(); + initialize(index, true); + }, + finish() { + seek(snapshot.total); + }, + reset() { + seek(0); + }, + dispose() { + if (!live) return; + live = false; + revoke(); + listeners.clear(); + snapshot = Object.freeze({ + ...snapshot, + playing: false, + phase: snapshot.phase === 'playing' ? 'paused' : snapshot.phase, + }); + }, + }; +} diff --git a/cockpit/render/registry/react/src/projection.spec.ts b/cockpit/render/registry/react/src/projection.spec.ts new file mode 100644 index 000000000..c5e8f8bd8 --- /dev/null +++ b/cockpit/render/registry/react/src/projection.spec.ts @@ -0,0 +1,192 @@ +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +import { describe, expect, it } from 'vitest'; +import { projectPartialSpec, validateCompleteSpec } from './projection'; +import { REGISTRY_SAMPLES } from './specs'; + +it('owns every actual prefix of all three authored registry samples', () => { + expect(REGISTRY_SAMPLES.map((sample) => sample.label)).toEqual([ + 'Basic Types', + 'Card Layout', + 'Mixed Components', + ]); + for (const sample of REGISTRY_SAMPLES) { + const parser = createPartialJsonParser(); + let retained: ReturnType = null, + frozen = ''; + for (const character of sample.json) { + parser.push(character); + const projected = projectPartialSpec( + parser.root ? materialize(parser.root) : null + ); + if (projected && !retained) { + retained = projected; + frozen = JSON.stringify(projected); + } + if (retained) expect(JSON.stringify(retained)).toBe(frozen); + } + parser.finish(); + expect(parser.root?.status).toBe('complete'); + expect(validateCompleteSpec(materialize(parser.root!))).toEqual( + JSON.parse(sample.json) + ); + expect(retained).not.toBeNull(); + } +}); + +const complete = () => ({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Literal ' }, + children: ['text'], + }, + text: { type: 'Text', props: { content: 'Retained child' } }, + }, +}); + +describe('owned local render projection', () => { + it('copies and freezes known literal views without changing borrowed values', () => { + const source = complete(); + const projected = validateCompleteSpec(source); + expect(projected).toEqual(source); + expect(Object.isFrozen(projected)).toBe(true); + expect(Object.isFrozen(projected.elements)).toBe(true); + expect(Object.isFrozen(projected.elements.root.props)).toBe(true); + expect(Object.isFrozen(projected.elements.root.children)).toBe(true); + expect(Object.isFrozen(source.elements.root.props)).toBe(false); + source.elements.root.props.content = 'Changed'; + source.elements.root.children.length = 0; + expect(projected.elements.root.props.content).toBe( + 'Literal ' + ); + expect(projected.elements.root.children).toEqual(['text']); + }); + + it('accepts every actual parser prefix while withholding incomplete types and references', () => { + const parser = createPartialJsonParser(); + const source = JSON.stringify(complete()); + let captured: ReturnType = null; + for (const character of source) { + parser.push(character); + const input = parser.root ? materialize(parser.root) : null; + const projected = projectPartialSpec(input); + if (projected) { + expect(['Heading', 'Text']).toContain(projected.elements.root.type); + for (const element of Object.values(projected.elements)) + for (const child of element.children ?? []) + expect(Object.hasOwn(projected.elements, child)).toBe(true); + if (!captured && projected.elements.root.props.content) + captured = projected; + } + } + parser.finish(); + expect(parser.root?.status).toBe('complete'); + expect(validateCompleteSpec(materialize(parser.root!))).toEqual(complete()); + expect(captured).not.toBeNull(); + expect(captured!.elements.root.props.content).not.toBe( + complete().elements.root.props.content + ); + }); + + it('withholds an absent root or an incomplete view type', () => { + for (const input of [ + null, + {}, + { root: 'root' }, + { root: 'root', elements: {} }, + { root: 'root', elements: { root: { type: 'Hea' } } }, + ]) + expect(projectPartialSpec(input)).toBeNull(); + expect(() => + validateCompleteSpec({ + root: 'root', + elements: { root: { type: 'Hea' } }, + }) + ).toThrow(); + }); + + it('keeps a known partial heading and waits for its missing child', () => { + const projected = projectPartialSpec({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Wel' }, + children: ['pending'], + }, + }, + }); + expect(projected?.elements.root.props.content).toBe('Wel'); + expect(projected?.elements.root.children).toEqual([]); + expect(() => + validateCompleteSpec({ + root: 'root', + elements: { root: { type: 'Heading', children: ['pending'] } }, + }) + ).toThrow(); + }); + + it('rejects executable fields, unsupported literal props, duplicate children and cycles', () => { + for (const extra of [ + { on: {} }, + { watch: {} }, + { repeat: {} }, + { visible: true }, + ]) { + const source = complete(); + Object.assign(source.elements.root, extra); + expect(() => projectPartialSpec(source)).toThrow(); + } + for (const content of [42, {}, [], Number.NaN]) { + const source = complete(); + Object.assign(source.elements.root.props, { content }); + expect(() => projectPartialSpec(source)).toThrow(); + } + const duplicate = complete(); + duplicate.elements.root.children = ['text', 'text']; + expect(() => projectPartialSpec(duplicate)).toThrow(); + const cycle = complete(); + cycle.elements.root.children = ['root']; + expect(() => projectPartialSpec(cycle)).toThrow(); + }); + + it('rejects accessors without invoking them or accepting inherited maps', () => { + let reads = 0; + const source = complete(); + Object.defineProperty(source.elements.root.props, 'content', { + enumerable: true, + get() { + reads++; + return 'private'; + }, + }); + expect(() => projectPartialSpec(source)).toThrow(); + expect(reads).toBe(0); + expect(() => + projectPartialSpec({ + root: 'root', + elements: Object.create({ root: complete().elements.root }), + }) + ).toThrow(); + }); + + it('bounds node counts, child references and literal text', () => { + const source = complete(); + source.elements.root.props.content = 'x'.repeat(16385); + expect(() => projectPartialSpec(source)).toThrow(); + const many = { + root: 'root', + elements: { root: complete().elements.root } as Record, + }; + for (let index = 0; index < 33; index++) + many.elements['node' + index] = { type: 'Text', props: { content: 'x' } }; + expect(() => projectPartialSpec(many)).toThrow(); + const children = complete(); + children.elements.root.children = Array.from( + { length: 33 }, + (_, index) => 'node' + index + ); + expect(() => projectPartialSpec(children)).toThrow(); + }); +}); diff --git a/cockpit/render/registry/react/src/projection.ts b/cockpit/render/registry/react/src/projection.ts new file mode 100644 index 000000000..4578ce38a --- /dev/null +++ b/cockpit/render/registry/react/src/projection.ts @@ -0,0 +1,170 @@ +import type { + RenderElementData, + RenderSpecData, +} from '@threadplane/react/render'; + +const TYPES = ['Heading', 'Text', 'Card', 'Badge']; +const ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; +const MAX_NODES = 32; +const MAX_TEXT = 16 * 1024; + +function invalid(): never { + throw new TypeError('Invalid local render data.'); +} + +function record(input: unknown): Record { + if ( + input === null || + typeof input !== 'object' || + Array.isArray(input) || + ![null, Object.prototype].includes(Object.getPrototypeOf(input)) || + Object.getOwnPropertySymbols(input).length + ) + invalid(); + const entries = Object.entries(Object.getOwnPropertyDescriptors(input)); + if (entries.length > MAX_NODES) invalid(); + const result = Object.create(null) as Record; + for (const [key, descriptor] of entries) { + if (!descriptor.enumerable || !('value' in descriptor)) invalid(); + Object.defineProperty(result, key, { + value: descriptor.value, + enumerable: true, + }); + } + return result; +} + +function fields( + input: Record, + allowed: readonly string[] +): void { + if (Object.keys(input).some((key) => !allowed.includes(key))) invalid(); +} + +function references(input: unknown, complete: boolean): readonly string[] { + if (input === undefined && !complete) return []; + if ( + !Array.isArray(input) || + Object.getPrototypeOf(input) !== Array.prototype || + input.length > MAX_NODES || + Object.getOwnPropertySymbols(input).length + ) + invalid(); + const descriptors = Object.getOwnPropertyDescriptors(input); + for (const [key, descriptor] of Object.entries(descriptors)) + if ( + !('value' in descriptor) || + (key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key)) + ) + invalid(); + const result: string[] = []; + for (let index = 0; index < input.length; index++) { + const value = descriptors[String(index)]?.value; + if ((value === undefined || value === '') && !complete) continue; + if (typeof value !== 'string' || !ID.test(value) || result.includes(value)) + invalid(); + result.push(value); + } + return result; +} + +function project(input: unknown, complete: boolean): RenderSpecData | null { + if (input === null && !complete) return null; + const source = record(input); + fields(source, ['root', 'elements']); + if ( + source.root === undefined || + source.root === '' || + source.elements === undefined + ) { + if (complete) invalid(); + return null; + } + if (typeof source.root !== 'string' || !ID.test(source.root)) invalid(); + const sourceElements = record(source.elements); + const elements = Object.create(null) as Record; + const childLists = new Map(); + let textSize = 0; + for (const [key, inputElement] of Object.entries(sourceElements)) { + if (!ID.test(key)) invalid(); + if (inputElement === undefined && !complete) continue; + const element = record(inputElement); + fields(element, ['type', 'props', 'children']); + if (element.type === undefined && !complete) continue; + if (typeof element.type !== 'string') invalid(); + if (!TYPES.includes(element.type)) { + if (complete) invalid(); + continue; + } + const rawProps = element.props === undefined ? {} : record(element.props); + fields(rawProps, [ + element.type === 'Card' + ? 'title' + : element.type === 'Badge' + ? 'label' + : 'content', + ]); + const props = Object.create(null) as Record; + for (const [prop, value] of Object.entries(rawProps)) { + if (value === undefined && !complete) continue; + if (typeof value !== 'string' || (textSize += value.length) > MAX_TEXT) + invalid(); + Object.defineProperty(props, prop, { value, enumerable: true }); + } + const children = Object.hasOwn(element, 'children') + ? references(element.children, complete) + : undefined; + if (children) childLists.set(key, children); + Object.defineProperty(elements, key, { + value: { type: element.type, props: Object.freeze(props) }, + enumerable: true, + }); + } + for (const [key, children] of childLists) { + if (complete && children.some((child) => !Object.hasOwn(elements, child))) + invalid(); + Object.defineProperty(elements[key], 'children', { + value: Object.freeze( + children.filter((child) => Object.hasOwn(elements, child)) + ), + enumerable: true, + }); + } + const heights = new Map(); + const active = new Set(); + function height(key: string): number { + const known = heights.get(key); + if (known !== undefined) return known; + if (active.has(key)) invalid(); + active.add(key); + let result = 1; + for (const child of elements[key].children ?? []) + result = Math.max(result, 1 + height(child)); + active.delete(key); + if (result > 16) invalid(); + heights.set(key, result); + return result; + } + for (const key of Object.keys(elements)) { + height(key); + Object.freeze(elements[key]); + } + if (!Object.hasOwn(elements, source.root)) { + if (complete) invalid(); + return null; + } + return Object.freeze({ + root: source.root, + elements: Object.freeze(elements), + }); +} + +/** Copy the known display subset; the incremental parser retains its own mutable tree. */ +export function projectPartialSpec(input: unknown): RenderSpecData | null { + return project(input, false); +} + +/** Admit a complete authored local sample before any playback begins. */ +export function validateCompleteSpec(input: unknown): RenderSpecData { + return project(input, true) ?? invalid(); +} diff --git a/cockpit/render/registry/react/src/specs.ts b/cockpit/render/registry/react/src/specs.ts new file mode 100644 index 000000000..bd07d7bfc --- /dev/null +++ b/cockpit/render/registry/react/src/specs.ts @@ -0,0 +1,108 @@ +export const REGISTRY_SAMPLES: readonly { + readonly label: string; + readonly json: string; +}[] = [ + { + label: 'Basic Types', + json: JSON.stringify( + { + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Basic Component Types' }, + children: ['desc', 'badge'], + }, + desc: { + type: 'Text', + props: { + content: + 'Each type string resolves to a registered React view at render time.', + }, + }, + badge: { + type: 'Badge', + props: { label: 'Registered' }, + }, + }, + }, + null, + 2 + ), + }, + { + label: 'Card Layout', + json: JSON.stringify( + { + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Card Layout Demo' }, + children: ['heading', 'body', 'tag'], + }, + heading: { + type: 'Heading', + props: { content: 'Inside a Card' }, + }, + body: { + type: 'Text', + props: { + content: + 'Cards display the children supplied by the native renderer.', + }, + }, + tag: { + type: 'Badge', + props: { label: 'Nested' }, + }, + }, + }, + null, + 2 + ), + }, + { + label: 'Mixed Components', + json: JSON.stringify( + { + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'All Registered Types' }, + children: ['card1', 'card2'], + }, + card1: { + type: 'Card', + props: { title: 'First Section' }, + children: ['text1', 'badge1'], + }, + text1: { + type: 'Text', + props: { content: 'Text inside the first card section.' }, + }, + badge1: { + type: 'Badge', + props: { label: 'Section 1' }, + }, + card2: { + type: 'Card', + props: { title: 'Second Section' }, + children: ['text2', 'badge2'], + }, + text2: { + type: 'Text', + props: { content: 'Text inside the second card section.' }, + }, + badge2: { + type: 'Badge', + props: { label: 'Section 2' }, + }, + }, + }, + null, + 2 + ), + }, +]; diff --git a/cockpit/render/registry/react/src/styles.css b/cockpit/render/registry/react/src/styles.css new file mode 100644 index 000000000..3c1f9b68d --- /dev/null +++ b/cockpit/render/registry/react/src/styles.css @@ -0,0 +1,126 @@ +:root { + font-family: system-ui, sans-serif; + color: #17233b; + background: #f5f7fc; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.spec-demo { + max-width: 1100px; + margin: auto; + padding: 24px; +} +.eyebrow { + color: #52617b; + font-size: 0.85rem; +} +h1 { + margin: 8px 0; +} +.controls, +.panels > section { + border: 1px solid #dce2ed; + border-radius: 12px; + padding: 20px; + background: white; +} +.controls { + display: grid; + gap: 16px; + margin: 24px 0; +} +label { + display: grid; + gap: 8px; + font-weight: 600; +} +select, +input:not([type='range']), +button { + font: inherit; + border: 1px solid #b7c3d7; + border-radius: 6px; + padding: 8px 12px; + background: white; + color: inherit; +} +button { + cursor: pointer; +} +button:disabled { + opacity: 0.5; + cursor: default; +} +.buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +input { + width: 100%; + min-width: 0; +} +.panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} +.panels > section { + min-width: 0; + overflow-wrap: anywhere; +} +h2 { + font-size: 1rem; + color: #52617b; + margin-top: 0; +} +pre { + min-height: 220px; + max-height: 520px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 0.8rem; + line-height: 1.5; +} +.render-card { + padding: 16px; + margin: 16px 0; + background: #f5f7fc; + border: 1px solid #dce2ed; + border-radius: 8px; +} +.render-card h4 { + margin: 0 0 12px; +} +.render-label { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.render-label strong { + min-width: 5rem; +} +.render-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 20px; + background: #e3ecff; + color: #234680; + font-size: 0.85rem; +} +.empty { + color: #66748c; +} +@media (max-width: 700px) { + .spec-demo { + padding: 16px; + } + .panels { + grid-template-columns: 1fr; + } +} diff --git a/cockpit/render/registry/react/src/views.tsx b/cockpit/render/registry/react/src/views.tsx new file mode 100644 index 000000000..4d5c547da --- /dev/null +++ b/cockpit/render/registry/react/src/views.tsx @@ -0,0 +1,63 @@ +import type { + ReactRenderRegistry, + RenderViewProps, +} from '@threadplane/react/render'; + +function literal(value: unknown): string { + return typeof value === 'string' ? value : ''; +} +function Heading({ props, children, loading }: RenderViewProps) { + return ( +
+

+ {literal(props.content) || (loading ? '…' : '')} +

+ {children} +
+ ); +} +function Text({ props, loading }: RenderViewProps) { + return ( +

{literal(props.content) || (loading ? '…' : '')}

+ ); +} +function Card({ props, children, loading }: RenderViewProps) { + return ( +
+

+ {literal(props.title) || (loading ? '…' : '')} +

+ {children} +
+ ); +} +function Badge({ props, loading }: RenderViewProps) { + return ( + + {literal(props.label) || (loading ? '…' : '')} + + ); +} +export const localRegistry: ReactRenderRegistry = Object.freeze({ + Heading, + Text, + Card, + Badge, +}); + +export const withoutBadgeRegistry: ReactRenderRegistry = Object.freeze({ + Heading, + Text, + Card, +}); +export function FallbackBadge({ props, elementKey, loading }: RenderViewProps) { + return ( +

+ Fallback: {literal(props.label) || (loading ? '…' : '')} +

+ ); +} diff --git a/cockpit/render/registry/react/tsconfig.json b/cockpit/render/registry/react/tsconfig.json new file mode 100644 index 000000000..0800f7a04 --- /dev/null +++ b/cockpit/render/registry/react/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "composite": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/cockpit/render/registry/react/vite.config.mts b/cockpit/render/registry/react/vite.config.mts new file mode 100644 index 000000000..15eeffc3a --- /dev/null +++ b/cockpit/render/registry/react/vite.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'jsdom', + include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx'], + maxWorkers: 2, + }, +}); diff --git a/libs/cockpit-registry/src/lib/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts index c75a73cca..f900af11d 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -65,7 +65,7 @@ export const capabilities: readonly Capability[] = [ { id: 'r-spec-rendering', runtimeAdapter: 'none', product: 'render', topic: 'spec-rendering', angularProject: 'cockpit-render-spec-rendering-angular', port: 4401, react: { project: 'cockpit-render-spec-rendering-react', port: 4614 }, pythonPort: 5401, pythonDir: 'cockpit/render/spec-rendering/python', graphName: 'r-spec-rendering' }, { id: 'r-element-rendering', runtimeAdapter: 'none', product: 'render', topic: 'element-rendering', angularProject: 'cockpit-render-element-rendering-angular', port: 4402, pythonPort: 5402, pythonDir: 'cockpit/render/element-rendering/python', graphName: 'r-element-rendering' }, { id: 'r-state-management', runtimeAdapter: 'none', product: 'render', topic: 'state-management', angularProject: 'cockpit-render-state-management-angular', port: 4403, react: { project: 'cockpit-render-state-management-react', port: 4615 }, pythonPort: 5403, pythonDir: 'cockpit/render/state-management/python', graphName: 'r-state-management' }, - { id: 'r-registry', runtimeAdapter: 'none', product: 'render', topic: 'registry', angularProject: 'cockpit-render-registry-angular', port: 4404, pythonPort: 5404, pythonDir: 'cockpit/render/registry/python', graphName: 'r-registry' }, + { id: 'r-registry', runtimeAdapter: 'none', product: 'render', topic: 'registry', angularProject: 'cockpit-render-registry-angular', port: 4404, react: { project: 'cockpit-render-registry-react', port: 4617 }, pythonPort: 5404, pythonDir: 'cockpit/render/registry/python', graphName: 'r-registry' }, { id: 'r-repeat-loops', runtimeAdapter: 'none', product: 'render', topic: 'repeat-loops', angularProject: 'cockpit-render-repeat-loops-angular', port: 4405, react: { project: 'cockpit-render-repeat-loops-react', port: 4616 }, pythonPort: 5405, pythonDir: 'cockpit/render/repeat-loops/python', graphName: 'r-repeat-loops' }, { id: 'r-computed-functions', runtimeAdapter: 'none', product: 'render', topic: 'computed-functions', angularProject: 'cockpit-render-computed-functions-angular', port: 4406, pythonPort: 5406, pythonDir: 'cockpit/render/computed-functions/python', graphName: 'r-computed-functions' }, // Chat capabilities diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index c38adc6a2..575e0aef4 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -1108,6 +1108,18 @@ const renderRepeatReact = (() => { }); })(); +const renderRegistryReact = (() => { + const canonical = capabilityModules.find(descriptor => descriptor.id === 'render-registry-python'); + const frontend = getCockpitFrontends().find(entry => entry.project === 'cockpit-render-registry-react'); + if (!canonical || !frontend) throw new Error('Component Registry frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, id: 'render-registry-python-react', frontend: 'react', title: 'Component Registry (React preview)', + promptAssetPaths: [], backendAssetPaths: [], + codeAssetPaths: ['app.tsx','playback.ts','projection.ts','specs.ts','views.tsx','main.tsx','styles.css'].map(file => `cockpit/render/registry/react/src/${file}`), + runtimeUrl: frontend.runtimePath, devPort: frontend.port, + }); +})(); + export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = Object.freeze([ ...capabilityModules, @@ -1119,6 +1131,7 @@ export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = renderSpecReact, renderStateReact, renderRepeatReact, + renderRegistryReact, ...reactPreviews.map(({ topic, title }) => { const canonical = capabilityModules.find((descriptor) => descriptor.id === `langgraph-${topic}-python`); const frontend = getCockpitFrontends().find((entry) => entry.project === `cockpit-langgraph-${topic}-react`); diff --git a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts index f15bd1f5e..0dd8b7155 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -15,6 +15,16 @@ const streaming: CockpitManifestIdentity = { language: 'python', }; +it('resolves local React Component Registry with host state sources and no Python assets', () => { + const canonical = capabilityModules.find(entry => entry.id === 'render-registry-python'); + if (!canonical) throw Error('Missing canonical Component Registry'); + const react = getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'react'); + expect(react).toMatchObject({ runtimeAdapter: 'none', frontend: 'react', runtimeUrl: 'render/registry/react', devPort: 4617, promptAssetPaths: [], backendAssetPaths: [] }); + expect(react?.codeAssetPaths).toEqual(['app.tsx','playback.ts','projection.ts','specs.ts','views.tsx','main.tsx','styles.css'].map(file => `cockpit/render/registry/react/src/${file}`)); + expect(getCockpitFrontends().find(entry => entry.project === 'cockpit-render-registry-react')).toMatchObject({ port: 4617, runtimePath: 'render/registry/react' }); + expect(getFrontendCapabilityDescriptor(canonical.manifestIdentity as CockpitManifestIdentity, 'angular')).toBe(canonical); +}); + it('resolves local React Repeat Loops with host state sources and no Python assets', () => { const canonical = capabilityModules.find(entry => entry.id === 'render-repeat-loops-python'); if (!canonical) throw Error('Missing canonical Repeat Loops'); @@ -103,8 +113,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toEqual(['graph.py','dashboard_tools.py','server.py'].map(file=>`cockpit/ag-ui/json-render/python/src/${file}`)); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/json-render'); expect(getFrontendCapabilityDescriptor({...identity,topic:'testing'},'react')).toBeUndefined(); - expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(17); - expect(getCockpitFrontends()).toHaveLength(58); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(18); + expect(getCockpitFrontends()).toHaveLength(59); for(const asset of [...(react?.codeAssetPaths??[]),...(react?.backendAssetPaths??[])])expect(existsSync(new URL('../../../../'+asset,import.meta.url))).toBe(true); }); it('resolves AG-UI Tool Views with authored weather policy and unchanged server-tool endpoint', () => { @@ -133,9 +143,9 @@ describe('registered React public previews', () => { expect(getFrontendCapabilityDescriptor(identity, 'angular')?.runtimeUrl).toBe('ag-ui/interrupts'); expect(getFrontendCapabilityDescriptor({ ...identity, product: 'langgraph' }, 'react')?.runtimeUrl).toBe('langgraph/interrupts/react'); const frontends = getCockpitFrontends(); - expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(17); - expect(frontends).toHaveLength(58); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(58); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(18); + expect(frontends).toHaveLength(59); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(59); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -150,8 +160,8 @@ describe('registered React public previews', () => { expect(react?.backendAssetPaths).toContain('cockpit/ag-ui/streaming/python/src/server.py'); expect(getFrontendCapabilityDescriptor(identity,'angular')?.runtimeUrl).toBe('ag-ui/streaming'); expect(getFrontendCapabilityDescriptor(streaming,'react')?.runtimeUrl).toBe('langgraph/streaming/react'); - expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(17); - expect(getCockpitFrontends()).toHaveLength(58); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(18); + expect(getCockpitFrontends()).toHaveLength(59); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -179,8 +189,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(17); - expect(getCockpitFrontends()).toHaveLength(58); + ).toHaveLength(18); + expect(getCockpitFrontends()).toHaveLength(59); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -440,11 +450,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - expect(frontends).toHaveLength(58); - expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(58); - expect(new Set(frontends.map((entry) => entry.project)).size).toBe(58); - expect(new Set(frontends.map((entry) => entry.port)).size).toBe(58); - expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(58); + expect(frontends).toHaveLength(59); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(59); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(59); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(59); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(59); expect( frontends.find( (entry) => entry.project === 'cockpit-langgraph-memory-react' diff --git a/scripts/cockpit-frontend-matrix.spec.mjs b/scripts/cockpit-frontend-matrix.spec.mjs index 2476d8a05..d60f73e95 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -4,6 +4,15 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { deriveCockpitCaps, selectCockpitCaps } from './cockpit-matrix.mjs'; +test('Registry has a closed local eighteenth React identity', async () => { + const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); + assert.deepEqual(reactCockpitConfiguration('render-registry'), { + topic: 'registry', adapter: 'none', appPath: 'cockpit/render/registry/react', + base: '/render/registry/react/', port: 4617, project: 'cockpit-render-registry-react', + }); + for (const key of ['registry', 'render/registry', '../render-registry']) + assert.throws(() => reactCockpitConfiguration(key), /Unsupported React cockpit topic/); +}); test('Repeat Loops has a closed local seventeenth React identity', async () => { const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); assert.deepEqual(reactCockpitConfiguration('render-repeat-loops'), { diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index 53995401f..d90c86865 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,10 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'render-registry') + return Object.freeze({ + topic: 'registry', adapter: 'none', appPath: 'cockpit/render/registry/react', + base: '/render/registry/react/', port: 4617, project: 'cockpit-render-registry-react', + }); if (topic === 'render-repeat-loops') return Object.freeze({ topic: 'repeat-loops', adapter: 'none', appPath: 'cockpit/render/repeat-loops/react', diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 2c69eca7a..edca0699f 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baselineHead": "53d9aea0a775c132ad26572f9e8d2d96822d1f0a", + "baselineHead": "623eb4f79f646543d278e393acfbc81d9bdcd882", "sourceState": { "modified": [ "libs/cockpit-registry/src/lib/capability-registry.ts", @@ -13226,13 +13226,13 @@ "id": "source:libs/cockpit-registry/src/lib/capability-registry.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/capability-registry.ts", - "sha256": "31cf450dbb647a7ac710939aa206bbfed37ba8f6181cc7b75574b63c44f4d24d" + "sha256": "b8140cea4acf05ebb4b70c2291613bd07f20d6a75a0c402d84561d76a0696ed7" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "b8e67521850642f52a73d856a168ebd1ba901c31ec0a41e0d5ffba1cc7221366" + "sha256": "b707321f13ddb8fe04aadf022f27521c249f882c30296047ec592ccbfda20beb" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts",