diff --git a/apps/website/content/AGENTS.md.template b/apps/website/content/AGENTS.md.template index fc25c57b1..562bba62e 100644 --- a/apps/website/content/AGENTS.md.template +++ b/apps/website/content/AGENTS.md.template @@ -105,3 +105,5 @@ State Management has an experimental local React preview at https://threadplane. 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`. + +Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`. diff --git a/apps/website/content/CLAUDE.md.template b/apps/website/content/CLAUDE.md.template index fc25c57b1..562bba62e 100644 --- a/apps/website/content/CLAUDE.md.template +++ b/apps/website/content/CLAUDE.md.template @@ -105,3 +105,5 @@ State Management has an experimental local React preview at https://threadplane. 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`. + +Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`. diff --git a/apps/website/e2e/react-element-rendering-preview.spec.ts b/apps/website/e2e/react-element-rendering-preview.spec.ts new file mode 100644 index 000000000..579163efd --- /dev/null +++ b/apps/website/e2e/react-element-rendering-preview.spec.ts @@ -0,0 +1,231 @@ +import { test, expect, type Page } from '@playwright/test'; + +const route = '/docs/render/api/render-spec-component'; +const iframe = (page: Page) => + page.locator( + 'iframe[title="Element Rendering (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:4618(?:[/?#]|$)|\/render\/element-rendering\/react\/?(?:[?#]|$))/ + ); + const example = frame(page); + await expect( + example.getByRole('checkbox', { name: 'Show detail' }) + ).toBeChecked(); + 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: 'Sample' }).selectOption('2'); + await example.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect( + example.locator('[data-element-key="conditional"]') + ).toBeVisible(); + await example.getByRole('checkbox', { name: 'Show detail' }).uncheck(); + await expect(example.locator('[data-element-key="conditional"]')).toHaveCount( + 0 + ); + await expect(example.locator('[data-element-key="always"]')).toBeVisible(); + await expect( + example.getByRole('region', { name: 'Streaming JSON' }).locator('pre') + ).not.toHaveText(''); +} + +test('React Element Rendering 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 Element Rendering preview', + exact: true, + }) + ).toBeVisible(); + await expect( + page.locator( + '[data-example-file="cockpit/render/element-rendering/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-element-rendering-desktop.png'), + }); +}); + +test('React Element Rendering 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:4402(?:[/?#]|$)|\/render\/element-rendering\/?(?:[?#]|$))/ + ); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await expectFreshRun(page); +}); + +test('React Element Rendering 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('combobox', { name: 'Sample' }).selectOption('2'); + await example.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect( + example.locator('[data-element-key="conditional"]') + ).toBeVisible(); + await expect(example.locator('[data-element-key="always"]')).toBeVisible(); +}); + +test('canonical Element Rendering Run uses native children and visibility 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'); + 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 [index, count] of [ + [0, 2], + [1, 1], + [2, 2], + ] as const) { + await frame + .getByRole('combobox', { name: 'Sample' }) + .selectOption(String(index)); + await frame.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('p')).toHaveCount(count); + } + const original = await source.textContent(), + position = await frame.getByRole('slider').inputValue(); + await frame.getByRole('checkbox', { name: 'Show detail' }).uncheck(); + await expect(output.locator('[data-element-key="conditional"]')).toHaveCount( + 0 + ); + await expect(source).toHaveText(original ?? ''); + await expect(frame.getByRole('slider')).toHaveValue(position); + await frame.getByRole('button', { name: 'Reset', exact: true }).click(); + await frame.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect( + frame.getByRole('checkbox', { name: 'Show detail' }) + ).not.toBeChecked(); + await expect(output.locator('[data-element-key="conditional"]')).toHaveCount( + 0 + ); +}); + +test('local React Element Rendering 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: 'Sample' }).selectOption('2'); + await frame.getByRole('checkbox', { name: 'Show detail' }).uncheck(); + await frame.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(frame.locator('[data-element-key="always"]')).toHaveCount(1); + await expect(frame.locator('[data-element-key="conditional"]')).toHaveCount( + 0 + ); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('react-element-rendering-mobile.png'), + }); +}); diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index d213f74b3..d3b4e1afc 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -353,6 +353,13 @@ export const createWebsitePlaywrightConfig = ( reuseExistingServer, timeout: 180_000, }, + { + command: 'node scripts/react-cockpit/serve.mjs render-element-rendering --no-parent', + cwd: '../..', + url: 'http://127.0.0.1:4618', + reuseExistingServer, + timeout: 180_000, + }, ] : []), ] diff --git a/apps/website/project.json b/apps/website/project.json index 351fb8e35..60ef180a5 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -91,7 +91,8 @@ "cockpit-render-spec-rendering-react", "cockpit-render-state-management-react", "cockpit-render-repeat-loops-react", - "cockpit-render-registry-react" + "cockpit-render-registry-react", + "cockpit-render-element-rendering-react" ] } ], diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index f0d54fc20..bc7e96b9b 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -105,3 +105,5 @@ State Management has an experimental local React preview at https://threadplane. 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`. + +Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index f0d54fc20..bc7e96b9b 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -105,3 +105,5 @@ State Management has an experimental local React preview at https://threadplane. 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`. + +Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-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 766ff4754..630d6b55c 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 { ReactElementRenderingPreview } from '../../../../../components/docs/ReactElementRenderingPreview'; import { ReactRegistryPreview } from '../../../../../components/docs/ReactRegistryPreview'; import { ReactRepeatLoopsPreview } from '../../../../../components/docs/ReactRepeatLoopsPreview'; import { ReactStateManagementPreview } from '../../../../../components/docs/ReactStateManagementPreview'; @@ -68,6 +69,35 @@ const route = (library: string, section: string, slug: string, mode?: string) => } as never); describe('unified docs workspace route', () => { + it('selects React Element Rendering Docs with owned state and readonly view sources', async () => { + const tree = await route('render', 'api', 'render-spec-component'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + const article = findElement( + workspace?.props.reactDocsSlot, + ReactElementRenderingPreview as ComponentType + ); + expect(article).not.toBeNull(); + expect(article?.props.exampleCode?.assetPaths).toContain( + 'cockpit/render/element-rendering/react/src/app.tsx' + ); + expect(article?.props.exampleCode?.assetPaths).toContain( + 'cockpit/render/element-rendering/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 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); 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 3804b81fb..f7e993d98 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 { ReactElementRenderingPreview } from '../../../../../components/docs/ReactElementRenderingPreview'; import { ReactRegistryPreview } from '../../../../../components/docs/ReactRegistryPreview'; import { ReactRepeatLoopsPreview } from '../../../../../components/docs/ReactRepeatLoopsPreview'; import { ReactStateManagementPreview } from '../../../../../components/docs/ReactStateManagementPreview'; @@ -272,10 +273,11 @@ export default async function DocsPage({ params }: DocsRouteProps) { reactDocsSlot={ workspacePage.frontendVariants?.react && (library === 'langgraph' || - (library === 'render' && section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || + (library === 'render' && ((section === 'guides' && ['specs','state-store','repeat-loops','registry'].includes(slug)) || (section === 'api' && slug === 'render-spec-component'))) || (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 === 'render-spec-component' ? : slug === 'registry' ? : slug === 'repeat-loops' ? : slug === 'state-store' ? : diff --git a/apps/website/src/components/docs/ReactElementRenderingPreview.tsx b/apps/website/src/components/docs/ReactElementRenderingPreview.tsx new file mode 100644 index 000000000..0e08b3feb --- /dev/null +++ b/apps/website/src/components/docs/ReactElementRenderingPreview.tsx @@ -0,0 +1,60 @@ +import type { ExampleCodeContext } from '../../lib/example-code'; +import { MdxRenderer } from './MdxRenderer'; + +const source = `# React Element Rendering preview + +The native **@threadplane/react/render** component renders an authored tree and supplies its rendered children to readonly React views. This experimental local example covers ordered siblings, nested cards and visibility from caller-owned state. + +## Try children, nesting and visibility + +Choose **Run**, select **Parent + Children**, **Deep Nesting** or **Visibility**, then press **Finish**. Parent + Children displays two ordered Text children beneath a Heading. Deep Nesting displays one Text leaf inside two Card wrappers. Visibility includes one always-visible Text and a conditional detail. + +**Show detail** updates explicit host state. Uncheck it to hide the conditional element, then check it to show the element. Changing the checkbox does not rewrite JSON or move playback. Your choice survives **Play**, **Pause**, **Playback position**, **Finish**, **Reset** and sample changes. Reloading shows detail. No agent requests or saved browser state are used. + + + +## Use native rendered children + +A stable readonly registry maps Heading, Text and Card to ordinary React components. RenderSpec supplies resolved props, elementKey, loading and children. Heading and Card display the supplied children exactly once; they do not walk the source graph themselves. Text is a leaf. The native tree owns sibling ordering and nesting. + + + +The host supplies an immutable state object with a boolean showDetail value. The conditional Text uses the authored visibility binding to /showDetail. Native RenderSpec evaluates the binding; the host does not remove or rewrite source elements. + + + +## Keep partial visibility faithful + +This bounded local projection admits only three literal display types and the one authored conditional visibility path. The conditional node is withheld until its exact visibility metadata is available, preventing hidden text from flashing while JSON streams. Complete samples are admitted before playback; owned frozen copies leave the parser's mutable tree intact. + + + +The private playback owner rebuilds the parser on seek and revokes obsolete callbacks on Pause, Reset, sample changes and page exit. Complete requires finished parser output to match the admitted source. Host visibility state remains independent of this owner. + + + +## Source build and verification + +From a Threadplane checkout with dependencies installed: + +~~~sh +npx nx build cockpit-render-element-rendering-react +npx nx e2e cockpit-render-element-rendering-react +~~~ + +The isolated static build installs local React, core and content artifacts with root-locked presentation dependencies. Browser coverage exercises native children and visibility, partial playback, retained host state, cleanup and narrow screens. Select **Angular** to read and run the existing Angular API example. +`; + +export function ReactElementRenderingPreview({ + 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 2854d6537..26a3cd1fd 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(18); - expect(new Set(prerequisites).size).toBe(18); + expect(prerequisites).toHaveLength(19); + expect(new Set(prerequisites).size).toBe(19); expect([...prerequisites].sort()).toEqual( getCockpitFrontends() .filter(frontend => frontend.frontend === 'react') @@ -29,6 +29,13 @@ describe('Website Playwright configuration', () => { expect(nativeServer?.timeout).toBe(180_000); expect(nativeServer?.reuseExistingServer).toBe(false); }); + it('builds Element Rendering in fresh Nx prerequisites and uses its own static server', () => { + const config = createWebsitePlaywrightConfig({ CI: 'true' }); + const servers = Array.isArray(config.webServer) ? config.webServer : []; + const project = JSON.parse(readFileSync(resolve(import.meta.dirname, '../project.json'), 'utf8')); + expect(project.targets.e2e.dependsOn.find((dependency: { target?: string }) => dependency.target === 'build').projects).toContain('cockpit-render-element-rendering-react'); + expect(servers.find(server => server.url === 'http://127.0.0.1:4618')?.command).toBe('node scripts/react-cockpit/serve.mjs render-element-rendering --no-parent'); + }); it('owns the Tool Views Angular counterpart and twelfth installed React server', () => { const config = createWebsitePlaywrightConfig({CI:'true'}); const servers = Array.isArray(config.webServer) ? config.webServer : []; @@ -37,19 +44,19 @@ 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 eighteen owned React servers after exact fresh Nx prerequisites', () => { + it('starts nineteen 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[01234567])$/.test(server.url ?? '')); - expect(reactServers).toHaveLength(18); + const reactServers = servers.filter(server => /^http:\/\/127\.0\.0\.1:46(?:0\d|1[012345678])$/.test(server.url ?? '')); + expect(reactServers).toHaveLength(19); 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(18); - expect(new Set(projects).size).toBe(18); + expect(projects).toHaveLength(19); + expect(new Set(projects).size).toBe(19); expect(projects).toContain('cockpit-render-state-management-react'); expect(projects).toContain('cockpit-render-repeat-loops-react'); expect(projects).toContain('cockpit-render-registry-react'); @@ -325,6 +332,7 @@ describe('Website Playwright configuration', () => { 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.objectContaining({command:'node scripts/react-cockpit/serve.mjs render-element-rendering --no-parent',url:'http://127.0.0.1:4618'}), ]); expect(config.testIgnore).toEqual([ '**/platform-production-smoke.spec.ts', diff --git a/cockpit/render/element-rendering/react/README.md b/cockpit/render/element-rendering/react/README.md new file mode 100644 index 000000000..52d8d81cc --- /dev/null +++ b/cockpit/render/element-rendering/react/README.md @@ -0,0 +1,12 @@ +# Native React Element Rendering preview + +Three authored local JSON samples render through installed `@threadplane/react/render`: ordered siblings, nested cards and conditional text. Native RenderSpec supplies rendered children to readonly Heading, Text and Card views and evaluates visibility from explicit caller-owned state. + +The Show detail checkbox survives playback, seek, Reset and sample selection. It does not change JSON or parser position; reloading shows detail. The bounded projection withholds the conditional element until its exact visibility binding is available, preventing hidden text from flashing during partial playback. No agent requests are needed. + +```sh +npx nx build cockpit-render-element-rendering-react +npx nx e2e cockpit-render-element-rendering-react +``` + +The closed static build installs local React/core/content artifacts and locked presentation dependencies. Browser checks cover native children and visibility, partial playback, retained host state, page exit and narrow screens. diff --git a/cockpit/render/element-rendering/react/e2e/element-rendering.spec.ts b/cockpit/render/element-rendering/react/e2e/element-rendering.spec.ts new file mode 100644 index 000000000..d3d9678db --- /dev/null +++ b/cockpit/render/element-rendering/react/e2e/element-rendering.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from '@playwright/test'; +import { ELEMENT_RENDERING_SAMPLES } from '../src/specs'; + +test('installed native children preserve ordering and nesting across all three samples', 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 output = page.getByRole('region', { name: 'Render output' }); + const source = page + .getByRole('region', { name: 'Streaming JSON' }) + .locator('pre'); + await expect( + page.getByRole('checkbox', { name: 'Show detail' }) + ).toBeChecked(); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('p')).toHaveText([ + 'First child text element rendered beneath the parent heading.', + 'Second child text element demonstrating sibling rendering.', + ]); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('1'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('article')).toHaveCount(2); + await expect( + output.locator('article').first().locator('article') + ).toHaveCount(1); + await expect( + output.getByText('Deeply nested text inside two levels of card wrappers.', { + exact: true, + }) + ).toHaveCount(1); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('2'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(output.locator('[data-element-key="conditional"]')).toHaveCount( + 1 + ); + const original = await source.textContent(), + position = await page.getByRole('slider').inputValue(); + await page.getByRole('checkbox', { name: 'Show detail' }).uncheck(); + await expect(output.locator('[data-element-key="conditional"]')).toHaveCount( + 0 + ); + await expect(output.locator('[data-element-key="always"]')).toHaveCount(1); + await expect(source).toHaveText(original ?? ''); + await expect(page.getByRole('slider')).toHaveValue(position); + await page.getByRole('checkbox', { name: 'Show detail' }).check(); + await expect(output.locator('[data-element-key="conditional"]')).toHaveCount( + 1 + ); + await page.screenshot({ + path: testInfo.outputPath('element-rendering-desktop.png'), + fullPage: true, + }); + expect(writes).toEqual([]); + expect(errors).toEqual([]); +}); + +test('native partial visibility never displays the conditional node before its binding is admitted', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('2'); + const json = ELEMENT_RENDERING_SAMPLES[2].json; + const beforeBinding = json.indexOf('"visible"'); + const conditional = page.locator('[data-element-key="conditional"]'); + await page.getByRole('slider').fill(String(beforeBinding)); + await expect(conditional).toHaveCount(0); + await page + .getByRole('slider') + .fill(String(json.indexOf('/showDetail"', beforeBinding) + 5)); + await expect(conditional).toHaveCount(0); + await page.getByRole('checkbox', { name: 'Show detail' }).uncheck(); + await page.getByRole('slider').fill(String(json.length - 2)); + await expect(conditional).toHaveCount(0); + await page.getByRole('checkbox', { name: 'Show detail' }).check(); + await expect(conditional).toHaveCount(1); + await expect(conditional).toHaveAttribute('aria-busy', 'true'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(conditional).toHaveAttribute('aria-busy', 'false'); +}); + +test('caller visibility survives paused and active playback, rewind, Reset and sample changes', async ({ + page, +}) => { + await page.goto('/'); + const detail = page.getByRole('checkbox', { name: 'Show detail' }); + const position = page.getByRole('slider'); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('2'); + await detail.uncheck(); + 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 detail.check(); + await expect(position).toHaveValue(paused); + await detail.uncheck(); + await position.fill('0'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(page.locator('[data-element-key="conditional"]')).toHaveCount(0); + await page.getByRole('button', { name: 'Reset', exact: true }).click(); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('0'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('2'); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(detail).not.toBeChecked(); + await expect(page.locator('[data-element-key="conditional"]')).toHaveCount(0); + await page.reload(); + await expect(detail).toBeChecked(); +}); + +test('narrow layout and page exit remain safe with native visibility', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.getByRole('combobox', { name: 'Sample' }).selectOption('2'); + await page.getByRole('checkbox', { name: 'Show detail' }).uncheck(); + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + await expect(page.locator('[data-element-key="always"]')).toHaveCount(1); + await expect(page.locator('[data-element-key="conditional"]')).toHaveCount(0); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + ) + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath('element-rendering-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/element-rendering/react/e2e/playwright.config.ts b/cockpit/render/element-rendering/react/e2e/playwright.config.ts new file mode 100644 index 000000000..2ac828805 --- /dev/null +++ b/cockpit/render/element-rendering/react/e2e/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: '.', + workers: 1, + fullyParallel: false, + use: { baseURL: 'http://127.0.0.1:4618', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: + 'node scripts/react-cockpit/serve.mjs render-element-rendering --no-parent', + cwd: '../../../../..', + url: 'http://127.0.0.1:4618', + reuseExistingServer: false, + timeout: 10000, + }, +}); diff --git a/cockpit/render/element-rendering/react/index.html b/cockpit/render/element-rendering/react/index.html new file mode 100644 index 000000000..677db8a5b --- /dev/null +++ b/cockpit/render/element-rendering/react/index.html @@ -0,0 +1,12 @@ + + + + + + React Component Element Rendering + + +
+ + + diff --git a/cockpit/render/element-rendering/react/project.json b/cockpit/render/element-rendering/react/project.json new file mode 100644 index 000000000..6483d58e7 --- /dev/null +++ b/cockpit/render/element-rendering/react/project.json @@ -0,0 +1,38 @@ +{ + "name": "cockpit-render-element-rendering-react", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/element-rendering/react/src", + "projectType": "application", + "tags": ["scope:cockpit-examples", "scope:cockpit-e2e", "framework:react"], + "targets": { + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "cockpit/render/element-rendering/react/vite.config.mts" + } + }, + "build": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": [ + { "target": "build", "projects": ["core", "content", "react"] } + ], + "outputs": [ + "{workspaceRoot}/dist/cockpit/render/element-rendering/react" + ], + "options": { + "command": "node scripts/react-cockpit/build.mjs render-element-rendering" + } + }, + "lint": { "executor": "@nx/eslint:lint" }, + "e2e": { + "executor": "@nx/playwright:playwright", + "cache": false, + "dependsOn": ["build"], + "options": { + "config": "cockpit/render/element-rendering/react/e2e/playwright.config.ts", + "skipInstall": true + } + } + } +} diff --git a/cockpit/render/element-rendering/react/src/app.spec.tsx b/cockpit/render/element-rendering/react/src/app.spec.tsx new file mode 100644 index 000000000..ef9a34e49 --- /dev/null +++ b/cockpit/render/element-rendering/react/src/app.spec.tsx @@ -0,0 +1,135 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { ElementRenderingDemo } from './app'; +import { createPlayback } from './playback'; +import { ELEMENT_RENDERING_SAMPLES } from './specs'; + +afterEach(cleanup); +function fixture() { + const callbacks: FrameRequestCallback[] = []; + const playback = createPlayback(ELEMENT_RENDERING_SAMPLES, { + request: (callback) => { + callbacks.push(callback); + return callbacks.length; + }, + cancel: () => undefined, + }); + const ready = vi.fn(); + render( + sample.label)} + onReady={ready} + /> + ); + return { playback, callbacks, ready }; +} +const output = () => screen.getByRole('region', { name: 'Render output' }); +const detail = () => + screen.getByRole('checkbox', { name: 'Show detail' }) as HTMLInputElement; +const source = () => + screen.getByRole('region', { name: 'Streaming JSON' }).querySelector('pre') + ?.textContent; +const click = (name: string) => + fireEvent.click(screen.getByRole('button', { name })); +const choose = (value: number) => + fireEvent.change(screen.getByRole('combobox', { name: 'Sample' }), { + target: { value: String(value) }, + }); + +it('starts with caller-owned detail shown and an empty ready playback', () => { + const { playback, ready } = fixture(); + expect(detail().checked).toBe(true); + expect(ready).toHaveBeenCalledTimes(1); + expect(playback.getSnapshot().position).toBe(0); + expect(output().textContent).toContain('Play a sample'); + expect(source()).toBe(''); +}); + +it('renders native ordered siblings and nested supplied children exactly once', () => { + fixture(); + click('Finish'); + expect( + [...output().querySelectorAll('p')].map((node) => node.textContent) + ).toEqual([ + 'First child text element rendered beneath the parent heading.', + 'Second child text element demonstrating sibling rendering.', + ]); + choose(1); + click('Finish'); + const cards = output().querySelectorAll('article'); + expect(cards).toHaveLength(2); + expect(cards[0].contains(cards[1])).toBe(true); + expect(output().querySelectorAll('p')).toHaveLength(1); + expect(output().textContent).toContain('Deeply nested text'); +}); + +it('never flashes hidden conditional text at any actual prefix with detail disabled', () => { + const { playback } = fixture(); + choose(2); + fireEvent.click(detail()); + const json = ELEMENT_RENDERING_SAMPLES[2].json; + for (let position = 0; position <= json.length; position++) { + act(() => playback.seek(position)); + expect( + output().querySelector('[data-element-key="conditional"]') + ).toBeNull(); + expect(source()).toBe(json.slice(0, position)); + } + const before = playback.getSnapshot(); + fireEvent.click(detail()); + expect( + output().querySelector('[data-element-key="conditional"]')?.textContent + ).toContain('conditionally visible'); + expect(playback.getSnapshot()).toBe(before); + fireEvent.click(detail()); + expect(output().querySelector('[data-element-key="conditional"]')).toBeNull(); +}); + +it('shows a conditional node only after native visibility metadata is admitted', () => { + const { playback } = fixture(); + choose(2); + for ( + let position = 0; + position <= ELEMENT_RENDERING_SAMPLES[2].json.length; + position++ + ) { + act(() => playback.seek(position)); + const conditional = playback.getSnapshot().spec?.elements.conditional; + expect( + Boolean(output().querySelector('[data-element-key="conditional"]')) + ).toBe(Boolean(conditional)); + if (conditional) + expect(conditional.visible).toEqual({ $state: '/showDetail' }); + } +}); + +it('retains host state through pause, stale frames, rewind, reset and sample changes', () => { + const { playback, callbacks } = fixture(); + choose(2); + fireEvent.click(detail()); + click('Play'); + act(() => callbacks[0](0)); + click('Pause'); + const paused = playback.getSnapshot(); + act(() => callbacks[0](0)); + expect(playback.getSnapshot()).toBe(paused); + click('Finish'); + click('Reset'); + expect(detail().checked).toBe(false); + choose(0); + click('Finish'); + choose(2); + click('Finish'); + expect(detail().checked).toBe(false); + expect(output().querySelector('[data-element-key="conditional"]')).toBeNull(); + expect(source()).toBe(ELEMENT_RENDERING_SAMPLES[2].json); + act(() => playback.seek(0)); + expect(detail().checked).toBe(false); +}); diff --git a/cockpit/render/element-rendering/react/src/app.tsx b/cockpit/render/element-rendering/react/src/app.tsx new file mode 100644 index 000000000..53af249a5 --- /dev/null +++ b/cockpit/render/element-rendering/react/src/app.tsx @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { RenderSpec } from '@threadplane/react/render'; +import type { Playback } from './playback'; +import { localRegistry } from './views'; + +export function ElementRenderingDemo({ + playback, + samples, + onReady, +}: { + readonly playback: Playback; + readonly samples: readonly string[]; + readonly onReady: () => void; +}) { + const [showDetail, setShowDetail] = useState(true); + const renderState = useMemo( + () => Object.freeze({ showDetail }), + [showDetail] + ); + 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

+

Element Rendering

+
+

+ Render ordered children and nested views. Choose whether the native + renderer shows the conditional detail. +

+
+ +

+ Your choice survives playback and sample changes. Reloading shows + detail. +

+
+
+ +
+ + + +
+ +

+ {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/element-rendering/react/src/main.tsx b/cockpit/render/element-rendering/react/src/main.tsx new file mode 100644 index 000000000..3e78c683e --- /dev/null +++ b/cockpit/render/element-rendering/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 { ElementRenderingDemo } from './app'; +import { createPlayback } from './playback'; +import { ELEMENT_RENDERING_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(ELEMENT_RENDERING_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/element-rendering/react/src/playback-terminal.spec.ts b/cockpit/render/element-rendering/react/src/playback-terminal.spec.ts new file mode 100644 index 000000000..40e1fe100 --- /dev/null +++ b/cockpit/render/element-rendering/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/element-rendering/react/src/playback.spec.ts b/cockpit/render/element-rendering/react/src/playback.spec.ts new file mode 100644 index 000000000..4b35f203a --- /dev/null +++ b/cockpit/render/element-rendering/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/element-rendering/react/src/playback.ts b/cockpit/render/element-rendering/react/src/playback.ts new file mode 100644 index 000000000..3e0dcfd8d --- /dev/null +++ b/cockpit/render/element-rendering/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/element-rendering/react/src/projection.spec.ts b/cockpit/render/element-rendering/react/src/projection.spec.ts new file mode 100644 index 000000000..3799e3c29 --- /dev/null +++ b/cockpit/render/element-rendering/react/src/projection.spec.ts @@ -0,0 +1,274 @@ +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +import { describe, expect, it } from 'vitest'; +import { projectPartialSpec, validateCompleteSpec } from './projection'; +import { ELEMENT_RENDERING_SAMPLES } from './specs'; + +it('owns every actual prefix of all three authored element rendering samples', () => { + expect(ELEMENT_RENDERING_SAMPLES.map((sample) => sample.label)).toEqual([ + 'Parent + Children', + 'Deep Nesting', + 'Visibility', + ]); + for (const sample of ELEMENT_RENDERING_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' } }, + }, +}); + +it('withholds the authored conditional node until its exact visibility binding is available', () => { + for (const visible of [ + undefined, + {}, + { $state: undefined }, + { $state: '' }, + { $state: '/show' }, + ]) { + const source = JSON.parse(ELEMENT_RENDERING_SAMPLES[2].json); + source.elements.conditional.visible = visible; + const projected = projectPartialSpec(source)!; + expect(Object.hasOwn(projected.elements, 'conditional')).toBe(false); + expect(projected.elements.root.children).toEqual(['always']); + expect(() => validateCompleteSpec(source)).toThrow(); + } + const source = JSON.parse(ELEMENT_RENDERING_SAMPLES[2].json); + const projected = validateCompleteSpec(source); + expect(projected.elements.conditional.visible).toEqual({ + $state: '/showDetail', + }); + expect(Object.isFrozen(projected.elements.conditional.visible)).toBe(true); + source.elements.conditional.visible.$state = '/other'; + expect(projected.elements.conditional.visible).toEqual({ + $state: '/showDetail', + }); +}); + +it('never projects the conditional element without visibility at any actual parser prefix', () => { + const parser = createPartialJsonParser(); + let seen = false; + for (const character of ELEMENT_RENDERING_SAMPLES[2].json) { + parser.push(character); + const projected = projectPartialSpec( + parser.root ? materialize(parser.root) : null + ); + const conditional = projected?.elements.conditional; + if (conditional) { + seen = true; + expect(conditional.type).toBe('Text'); + expect(conditional.visible).toEqual({ $state: '/showDetail' }); + } + } + expect(seen).toBe(true); +}); + +it('rejects unsupported visibility and unsafe metadata even when the conditional node is withheld', () => { + for (const visible of [ + true, + { $state: '/other' }, + { $state: '/showDetail', extra: true }, + { $and: [] }, + ]) { + const source = JSON.parse(ELEMENT_RENDERING_SAMPLES[2].json); + source.elements.conditional.visible = visible; + expect(() => projectPartialSpec(source)).toThrow(); + } + for (const extra of [ + { on: {} }, + { repeat: {} }, + { props: { content: { $state: '/showDetail' } } }, + ]) { + const source = JSON.parse(ELEMENT_RENDERING_SAMPLES[2].json); + delete source.elements.conditional.visible; + Object.assign(source.elements.conditional, extra); + expect(() => projectPartialSpec(source)).toThrow(); + } + const source = JSON.parse(ELEMENT_RENDERING_SAMPLES[2].json); + source.elements.conditional.type = 'Heading'; + expect(() => validateCompleteSpec(source)).toThrow(); + const accessor = JSON.parse(ELEMENT_RENDERING_SAMPLES[2].json); + let reads = 0; + Object.defineProperty(accessor.elements.conditional.visible, '$state', { + enumerable: true, + get() { + reads++; + return '/showDetail'; + }, + }); + expect(() => projectPartialSpec(accessor)).toThrow(); + expect(reads).toBe(0); +}); + +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/element-rendering/react/src/projection.ts b/cockpit/render/element-rendering/react/src/projection.ts new file mode 100644 index 000000000..82b6afa4a --- /dev/null +++ b/cockpit/render/element-rendering/react/src/projection.ts @@ -0,0 +1,191 @@ +import type { + RenderElementData, + RenderSpecData, +} from '@threadplane/react/render'; + +const TYPES = ['Heading', 'Text', 'Card']; +const ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; +const MAX_NODES = 32; +const MAX_TEXT = 16 * 1024; +const VISIBILITY_PATH = '/showDetail'; + +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', 'visible']); + if (key !== 'conditional' && Object.hasOwn(element, 'visible')) invalid(); + 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' : '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; + let visible: Readonly<{ $state: string }> | undefined; + if (key === 'conditional') { + if (element.type !== 'Text') invalid(); + if (element.visible === undefined) { + if (complete) invalid(); + continue; + } + const condition = record(element.visible); + fields(condition, ['$state']); + const path = condition.$state; + if (path !== VISIBILITY_PATH) { + if ( + !complete && + (path === undefined || + (typeof path === 'string' && VISIBILITY_PATH.startsWith(path))) + ) + continue; + invalid(); + } + visible = Object.freeze({ $state: VISIBILITY_PATH }); + } + if (children) childLists.set(key, children); + Object.defineProperty(elements, key, { + value: { + type: element.type, + props: Object.freeze(props), + ...(visible ? { visible } : {}), + }, + 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/element-rendering/react/src/specs.ts b/cockpit/render/element-rendering/react/src/specs.ts new file mode 100644 index 000000000..85cb7711c --- /dev/null +++ b/cockpit/render/element-rendering/react/src/specs.ts @@ -0,0 +1,93 @@ +export const ELEMENT_RENDERING_SAMPLES: readonly { + readonly label: string; + readonly json: string; +}[] = [ + { + label: 'Parent + Children', + json: JSON.stringify( + { + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Parent Heading' }, + children: ['child1', 'child2'], + }, + child1: { + type: 'Text', + props: { + content: + 'First child text element rendered beneath the parent heading.', + }, + }, + child2: { + type: 'Text', + props: { + content: + 'Second child text element demonstrating sibling rendering.', + }, + }, + }, + }, + null, + 2 + ), + }, + { + label: 'Deep Nesting', + json: JSON.stringify( + { + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Outer Card' }, + children: ['inner'], + }, + inner: { + type: 'Card', + props: { title: 'Inner Card' }, + children: ['leaf'], + }, + leaf: { + type: 'Text', + props: { + content: 'Deeply nested text inside two levels of card wrappers.', + }, + }, + }, + }, + null, + 2 + ), + }, + { + label: 'Visibility', + json: JSON.stringify( + { + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Visibility Demo' }, + children: ['always', 'conditional'], + }, + always: { + type: 'Text', + props: { content: 'This element is always visible.' }, + }, + conditional: { + type: 'Text', + props: { + content: + 'This element is conditionally visible based on /showDetail.', + }, + visible: { $state: '/showDetail' }, + }, + }, + }, + null, + 2 + ), + }, +]; diff --git a/cockpit/render/element-rendering/react/src/styles.css b/cockpit/render/element-rendering/react/src/styles.css new file mode 100644 index 000000000..22555a0fe --- /dev/null +++ b/cockpit/render/element-rendering/react/src/styles.css @@ -0,0 +1,137 @@ +: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']):not([type='checkbox']), +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; +} +.visibility-toggle { + display: flex; + align-items: center; + gap: 12px; +} +.visibility-toggle input { + width: 20px; + height: 20px; + flex-shrink: 0; + accent-color: #234680; +} +.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/element-rendering/react/src/views.tsx b/cockpit/render/element-rendering/react/src/views.tsx new file mode 100644 index 000000000..763266a5c --- /dev/null +++ b/cockpit/render/element-rendering/react/src/views.tsx @@ -0,0 +1,40 @@ +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, elementKey }: RenderViewProps) { + return ( +

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

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

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

+ {children} +
+ ); +} +export const localRegistry: ReactRenderRegistry = Object.freeze({ + Heading, + Text, + Card, +}); diff --git a/cockpit/render/element-rendering/react/tsconfig.json b/cockpit/render/element-rendering/react/tsconfig.json new file mode 100644 index 000000000..0800f7a04 --- /dev/null +++ b/cockpit/render/element-rendering/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/element-rendering/react/vite.config.mts b/cockpit/render/element-rendering/react/vite.config.mts new file mode 100644 index 000000000..15eeffc3a --- /dev/null +++ b/cockpit/render/element-rendering/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 f900af11d..ca58bf456 100644 --- a/libs/cockpit-registry/src/lib/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -63,7 +63,7 @@ export const capabilities: readonly Capability[] = [ { id: 'da-skills', runtimeAdapter: 'langgraph', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonPort: 5314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'da-skills' }, // Render capabilities { 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-element-rendering', runtimeAdapter: 'none', product: 'render', topic: 'element-rendering', angularProject: 'cockpit-render-element-rendering-angular', port: 4402, react: { project: 'cockpit-render-element-rendering-react', port: 4618 }, 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, 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' }, diff --git a/libs/cockpit-registry/src/lib/content-descriptors.ts b/libs/cockpit-registry/src/lib/content-descriptors.ts index 575e0aef4..7e9130a98 100644 --- a/libs/cockpit-registry/src/lib/content-descriptors.ts +++ b/libs/cockpit-registry/src/lib/content-descriptors.ts @@ -1120,6 +1120,36 @@ const renderRegistryReact = (() => { }); })(); +const renderElementReact = (() => { + const canonical = capabilityModules.find( + (descriptor) => descriptor.id === 'render-element-rendering-python' + ); + const frontend = getCockpitFrontends().find( + (entry) => entry.project === 'cockpit-render-element-rendering-react' + ); + if (!canonical || !frontend) + throw new Error('Element Rendering frontend registration is missing'); + return freezeCapabilityDescriptor({ + ...canonical, + id: 'render-element-rendering-python-react', + frontend: 'react', + title: 'Element Rendering (React preview)', + promptAssetPaths: [], + backendAssetPaths: [], + codeAssetPaths: [ + 'app.tsx', + 'playback.ts', + 'projection.ts', + 'specs.ts', + 'views.tsx', + 'main.tsx', + 'styles.css', + ].map((file) => `cockpit/render/element-rendering/react/src/${file}`), + runtimeUrl: frontend.runtimePath, + devPort: frontend.port, + }); +})(); + export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = Object.freeze([ ...capabilityModules, @@ -1132,6 +1162,7 @@ export const frontendCapabilityModules: readonly RegisteredCapabilityModule[] = renderStateReact, renderRepeatReact, renderRegistryReact, + renderElementReact, ...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 0dd8b7155..ee852f21d 100644 --- a/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts +++ b/libs/cockpit-registry/src/lib/frontend-pilot.spec.ts @@ -15,6 +15,51 @@ const streaming: CockpitManifestIdentity = { language: 'python', }; +it('resolves local React Element Rendering on its canonical API page without Python assets', () => { + const canonical = capabilityModules.find( + (entry) => entry.id === 'render-element-rendering-python' + ); + if (!canonical) throw Error('Missing canonical Element Rendering'); + const react = getFrontendCapabilityDescriptor( + canonical.manifestIdentity as CockpitManifestIdentity, + 'react' + ); + expect(react).toMatchObject({ + docsPath: '/docs/render/api/render-spec-component', + runtimeAdapter: 'none', + frontend: 'react', + runtimeUrl: 'render/element-rendering/react', + devPort: 4618, + promptAssetPaths: [], + backendAssetPaths: [], + }); + expect(react?.codeAssetPaths).toEqual( + [ + 'app.tsx', + 'playback.ts', + 'projection.ts', + 'specs.ts', + 'views.tsx', + 'main.tsx', + 'styles.css', + ].map((file) => `cockpit/render/element-rendering/react/src/${file}`) + ); + expect( + getCockpitFrontends().find( + (entry) => entry.project === 'cockpit-render-element-rendering-react' + ) + ).toMatchObject({ + port: 4618, + runtimePath: 'render/element-rendering/react', + }); + expect( + getFrontendCapabilityDescriptor( + canonical.manifestIdentity as CockpitManifestIdentity, + 'angular' + ) + ).toBe(canonical); +}); + 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'); @@ -113,8 +158,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(18); - expect(getCockpitFrontends()).toHaveLength(59); + expect(getCockpitFrontends().filter(frontend=>frontend.frontend==='react')).toHaveLength(19); + expect(getCockpitFrontends()).toHaveLength(60); 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', () => { @@ -143,9 +188,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(18); - expect(frontends).toHaveLength(59); - expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(59); + expect(frontends.filter(frontend => frontend.frontend === 'react')).toHaveLength(19); + expect(frontends).toHaveLength(60); + expect(new Set(frontends.map(frontend => frontend.project)).size).toBe(60); for (const asset of [...(react?.codeAssetPaths ?? []), ...(react?.backendAssetPaths ?? [])]) expect(existsSync(new URL('../../../../' + asset, import.meta.url))).toBe(true); }); @@ -160,8 +205,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(18); - expect(getCockpitFrontends()).toHaveLength(59); + expect(getCockpitFrontends().filter(x=>x.frontend==='react')).toHaveLength(19); + expect(getCockpitFrontends()).toHaveLength(60); }); it('maps the canonical Deployment guide to the exact Deployment Runtime React sources', () => { const identity = { ...streaming, topic: 'deployment-runtime' }; @@ -189,8 +234,8 @@ describe('registered React public previews', () => { ).toBe('langgraph/deployment-runtime'); expect( getCockpitFrontends().filter((frontend) => frontend.frontend === 'react') - ).toHaveLength(18); - expect(getCockpitFrontends()).toHaveLength(59); + ).toHaveLength(19); + expect(getCockpitFrontends()).toHaveLength(60); }); it('resolves Time Travel with exact owner, checkpoint eligibility and canonical identity sources', () => { const identity = { ...streaming, topic: 'time-travel' }; @@ -450,11 +495,11 @@ describe('registered React public previews', () => { it('enumerates deployable frontend identities with distinct paths and projects', () => { const frontends = getCockpitFrontends(); - 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).toHaveLength(60); + expect(new Set(frontends.map((entry) => entry.runtimePath)).size).toBe(60); + expect(new Set(frontends.map((entry) => entry.project)).size).toBe(60); + expect(new Set(frontends.map((entry) => entry.port)).size).toBe(60); + expect(new Set(frontends.map((entry) => entry.buildOutput)).size).toBe(60); 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 d60f73e95..5d1591262 100644 --- a/scripts/cockpit-frontend-matrix.spec.mjs +++ b/scripts/cockpit-frontend-matrix.spec.mjs @@ -4,6 +4,28 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { deriveCockpitCaps, selectCockpitCaps } from './cockpit-matrix.mjs'; +test('Element Rendering has a closed local nineteenth React identity', async () => { + const { reactCockpitConfiguration } = await import( + './react-cockpit/configuration.mjs' + ); + assert.deepEqual(reactCockpitConfiguration('render-element-rendering'), { + topic: 'element-rendering', + adapter: 'none', + appPath: 'cockpit/render/element-rendering/react', + base: '/render/element-rendering/react/', + port: 4618, + project: 'cockpit-render-element-rendering-react', + }); + for (const key of [ + 'element-rendering', + 'render/element-rendering', + '../render-element-rendering', + ]) + assert.throws( + () => reactCockpitConfiguration(key), + /Unsupported React cockpit topic/ + ); +}); test('Registry has a closed local eighteenth React identity', async () => { const { reactCockpitConfiguration } = await import('./react-cockpit/configuration.mjs'); assert.deepEqual(reactCockpitConfiguration('render-registry'), { diff --git a/scripts/react-cockpit/configuration.mjs b/scripts/react-cockpit/configuration.mjs index d90c86865..6f02994c6 100644 --- a/scripts/react-cockpit/configuration.mjs +++ b/scripts/react-cockpit/configuration.mjs @@ -1,5 +1,14 @@ /** Closed authored frontend selection; callers cannot supply filesystem paths. */ export function reactCockpitConfiguration(topic = 'streaming') { + if (topic === 'render-element-rendering') + return Object.freeze({ + topic: 'element-rendering', + adapter: 'none', + appPath: 'cockpit/render/element-rendering/react', + base: '/render/element-rendering/react/', + port: 4618, + project: 'cockpit-render-element-rendering-react', + }); if (topic === 'render-registry') return Object.freeze({ topic: 'registry', adapter: 'none', appPath: 'cockpit/render/registry/react', diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index edca0699f..bbbd1b12c 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baselineHead": "623eb4f79f646543d278e393acfbc81d9bdcd882", + "baselineHead": "74693837495fd24b6076ba0eae5c2516e2ba853e", "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": "b8140cea4acf05ebb4b70c2291613bd07f20d6a75a0c402d84561d76a0696ed7" + "sha256": "2ccbd49ca041831eff7757337964301d710d61d78eda1f21cc08f975482b89c1" }, { "id": "source:libs/cockpit-registry/src/lib/content-descriptors.ts", "kind": "source", "path": "libs/cockpit-registry/src/lib/content-descriptors.ts", - "sha256": "b707321f13ddb8fe04aadf022f27521c249f882c30296047ec592ccbfda20beb" + "sha256": "006ceec3f913241e497d388f04e43d996f93a75a2f9ab21a2bdab36814d39599" }, { "id": "source:libs/cockpit-registry/src/lib/docs-links.ts",