From a2633a377c49c7c79fea42958f4059e0f9e0538e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 7 Jul 2026 19:40:46 -0700 Subject: [PATCH] fix(cockpit-examples): use CSS sidebar widths --- .../input/angular/src/app/input.component.ts | 2 +- .../angular/src/app/interrupts.component.ts | 2 +- .../angular/src/app/messages.component.ts | 2 +- .../angular/src/app/subagents.component.ts | 2 +- .../angular/src/app/theming.component.ts | 2 +- .../angular/src/app/threads.component.ts | 2 +- .../angular/src/app/timeline.component.ts | 2 +- .../angular/src/app/tool-calls.component.ts | 2 +- .../angular/src/app/filesystem.component.ts | 2 +- .../angular/src/app/memory.component.ts | 2 +- .../angular/src/app/planning.component.ts | 2 +- .../angular/src/app/sandboxes.component.ts | 2 +- .../angular/src/app/skills.component.ts | 2 +- .../angular/src/app/subagents.component.ts | 2 +- .../render-examples-display-coercion.spec.ts | 2 +- .../render-examples-latent-bugs.spec.ts | 96 +++++++++++++++++++ .../render-examples-streaming-harness.spec.ts | 95 ++++++++++++++++++ cockpit/render/shared/to-display-text.ts | 2 +- cockpit/render/tsconfig.spec.json | 24 +++++ cockpit/render/vite.config.mts | 14 +++ 20 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 cockpit/render/shared/render-examples-latent-bugs.spec.ts create mode 100644 cockpit/render/shared/render-examples-streaming-harness.spec.ts create mode 100644 cockpit/render/tsconfig.spec.json create mode 100644 cockpit/render/vite.config.mts diff --git a/cockpit/chat/input/angular/src/app/input.component.ts b/cockpit/chat/input/angular/src/app/input.component.ts index d4ac4f94a..36fe7b184 100644 --- a/cockpit/chat/input/angular/src/app/input.component.ts +++ b/cockpit/chat/input/angular/src/app/input.component.ts @@ -33,7 +33,7 @@ import { injectAgent } from '@threadplane/langgraph'; ExampleChatLayoutComponent, ], template: ` - +

Chat Input Demo

diff --git a/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts b/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts index 20475758e..3cca6ed6e 100644 --- a/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts +++ b/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts @@ -50,7 +50,7 @@ const SUGGESTIONS = [ ExampleChatLayoutComponent, ], template: ` - +
@for (s of suggestions; track s.value) { diff --git a/cockpit/chat/messages/angular/src/app/messages.component.ts b/cockpit/chat/messages/angular/src/app/messages.component.ts index 410424641..690ddfd0a 100644 --- a/cockpit/chat/messages/angular/src/app/messages.component.ts +++ b/cockpit/chat/messages/angular/src/app/messages.component.ts @@ -39,7 +39,7 @@ import { MESSAGES_AGENT, type MessagesState } from './agent-ref'; ExampleChatLayoutComponent, ], template: ` - +

Chat Messages Primitives

diff --git a/cockpit/chat/subagents/angular/src/app/subagents.component.ts b/cockpit/chat/subagents/angular/src/app/subagents.component.ts index 1f2d032aa..78bd2491a 100644 --- a/cockpit/chat/subagents/angular/src/app/subagents.component.ts +++ b/cockpit/chat/subagents/angular/src/app/subagents.component.ts @@ -35,7 +35,7 @@ const SUGGESTIONS = [ ExampleChatLayoutComponent, ], template: ` - +
@for (s of suggestions; track s.value) { diff --git a/cockpit/chat/theming/angular/src/app/theming.component.ts b/cockpit/chat/theming/angular/src/app/theming.component.ts index afa882483..3518a2571 100644 --- a/cockpit/chat/theming/angular/src/app/theming.component.ts +++ b/cockpit/chat/theming/angular/src/app/theming.component.ts @@ -50,7 +50,7 @@ const THEMES: Record> = { standalone: true, imports: [ChatComponent, ExampleChatLayoutComponent, TitleCasePipe], template: ` - +

(null); }), ], template: ` - + +

+
@for (s of suggestions; track s.value) { diff --git a/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts b/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts index 334b0bb58..cbf7647b3 100644 --- a/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts +++ b/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts @@ -31,7 +31,7 @@ interface FileOperation { standalone: true, imports: [ChatComponent, ExampleChatLayoutComponent], template: ` - +

+

+

+

+

+

entry.isDirectory() && entry.name !== 'shared') + .map((entry) => resolve(RENDER_DIR, entry.name, 'angular/src/app/specs.ts')) + .filter((file) => existsSync(file)); +} + +function angularSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = resolve(dir, entry.name); + if (entry.isDirectory()) { + out.push(...angularSourceFiles(path)); + } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.html')) { + out.push(path); + } + } + return out; +} + +function collectDirectiveNames(value: unknown, out = new Set()): Set { + if (!value || typeof value !== 'object') return out; + if (Array.isArray(value)) { + for (const item of value) collectDirectiveNames(item, out); + return out; + } + for (const [key, nested] of Object.entries(value)) { + if (/^\$[A-Za-z][A-Za-z0-9_]*$/.test(key)) out.add(key); + collectDirectiveNames(nested, out); + } + return out; +} + +function parseJsonRenderSpecs(source: string): unknown[] { + const specs: unknown[] = []; + const pattern = /json:\s*JSON\.stringify\(([\s\S]*?),\s*null,\s*2\)/g; + for (const match of source.matchAll(pattern)) { + specs.push(Function(`"use strict"; return (${match[1]});`)()); + } + return specs; +} + +describe('cockpit examples latent bug sweep guards', () => { + it('uses only render directives supported by @json-render/core', () => { + const unsupported: string[] = []; + for (const file of renderSpecFiles()) { + const rel = file.slice(WORKSPACE_ROOT.length + 1); + for (const spec of parseJsonRenderSpecs(readFileSync(file, 'utf8'))) { + for (const directive of collectDirectiveNames(spec)) { + if (!SUPPORTED_RENDER_DIRECTIVES.has(directive)) { + unsupported.push(`${rel}: ${directive}`); + } + } + } + } + + expect(unsupported).toEqual([]); + }); + + it('passes CSS lengths, not Tailwind width classes, to example-chat-layout sidebarWidth', () => { + const offenders: string[] = []; + for (const root of ANGULAR_EXAMPLE_DIRS) { + for (const file of angularSourceFiles(resolve(WORKSPACE_ROOT, root))) { + const source = readFileSync(file, 'utf8'); + if (/sidebarWidth="w-\d+"/.test(source)) { + offenders.push(file.slice(WORKSPACE_ROOT.length + 1)); + } + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/cockpit/render/shared/render-examples-streaming-harness.spec.ts b/cockpit/render/shared/render-examples-streaming-harness.spec.ts new file mode 100644 index 000000000..a2ec5b5d0 --- /dev/null +++ b/cockpit/render/shared/render-examples-streaming-harness.spec.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +import { TestBed } from '@angular/core/testing'; +import { provideRender } from '@threadplane/render'; + +import { COMPUTED_FUNCTIONS_SPECS } from '../computed-functions/angular/src/app/specs'; +import { ComputedFunctionsComponent } from '../computed-functions/angular/src/app/computed-functions.component'; +import { ELEMENT_RENDERING_SPECS } from '../element-rendering/angular/src/app/specs'; +import { ElementRenderingComponent } from '../element-rendering/angular/src/app/element-rendering.component'; +import { REGISTRY_SPECS } from '../registry/angular/src/app/specs'; +import { RegistryComponent } from '../registry/angular/src/app/registry.component'; +import { REPEAT_LOOPS_SPECS } from '../repeat-loops/angular/src/app/specs'; +import { RepeatLoopsComponent } from '../repeat-loops/angular/src/app/repeat-loops.component'; +import { SPEC_RENDERING_SPECS } from '../spec-rendering/angular/src/app/specs'; +import { SpecRenderingComponent } from '../spec-rendering/angular/src/app/spec-rendering.component'; +import { STATE_MANAGEMENT_SPECS } from '../state-management/angular/src/app/specs'; +import { StateManagementComponent } from '../state-management/angular/src/app/state-management.component'; +import { StreamingSimulator } from './streaming-simulator'; + +interface DemoSpec { + label: string; + json: string; +} + +interface RenderExampleHarness { + simulator: StreamingSimulator; +} + +const COMPUTED_FUNCTIONS = { + formatDate: (args: Record) => new Date(args['value'] as string).toLocaleDateString(), + uppercase: (args: Record) => (args['value'] as string).toUpperCase(), + multiply: (args: Record) => (args['a'] as number) * (args['b'] as number), + reverse: (args: Record) => (args['value'] as string).split('').reverse().join(''), +}; + +const EXAMPLES = [ + { name: 'registry', component: RegistryComponent, specs: REGISTRY_SPECS }, + { name: 'spec-rendering', component: SpecRenderingComponent, specs: SPEC_RENDERING_SPECS }, + { name: 'element-rendering', component: ElementRenderingComponent, specs: ELEMENT_RENDERING_SPECS }, + { name: 'state-management', component: StateManagementComponent, specs: STATE_MANAGEMENT_SPECS }, + { name: 'repeat-loops', component: RepeatLoopsComponent, specs: REPEAT_LOOPS_SPECS }, + { name: 'computed-functions', component: ComputedFunctionsComponent, specs: COMPUTED_FUNCTIONS_SPECS }, +]; + +describe('render example streaming fixtures', () => { + for (const example of EXAMPLES) { + describe(example.name, () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [example.component], + providers: [provideRender({ functions: COMPUTED_FUNCTIONS })], + }).compileComponents(); + }); + + for (const spec of example.specs as DemoSpec[]) { + it(`${spec.label} fully streams without object flash text`, async () => { + const fixture = TestBed.createComponent(example.component); + const component = fixture.componentInstance as unknown as RenderExampleHarness; + component.simulator.setSource(spec.json); + component.simulator.seek(spec.json.length); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).not.toContain('[object Object]'); + expect(text).not.toContain('object Object'); + expect(text.trim()).not.toBe(''); + }); + } + }); + } + + it('renders non-string primitive state and computed values as display text', async () => { + await TestBed.configureTestingModule({ + imports: [StateManagementComponent, ComputedFunctionsComponent], + providers: [provideRender({ functions: COMPUTED_FUNCTIONS })], + }).compileComponents(); + + const stateFixture = TestBed.createComponent(StateManagementComponent); + const stateComponent = stateFixture.componentInstance as unknown as RenderExampleHarness; + stateComponent.simulator.setSource(STATE_MANAGEMENT_SPECS[1].json); + stateComponent.simulator.seek(STATE_MANAGEMENT_SPECS[1].json.length); + stateFixture.detectChanges(); + + const computedFixture = TestBed.createComponent(ComputedFunctionsComponent); + const computedComponent = computedFixture.componentInstance as unknown as RenderExampleHarness; + computedComponent.simulator.setSource(COMPUTED_FUNCTIONS_SPECS[1].json); + computedComponent.simulator.seek(COMPUTED_FUNCTIONS_SPECS[1].json.length); + computedFixture.detectChanges(); + + expect((stateFixture.nativeElement as HTMLElement).textContent).toContain('30'); + expect((computedFixture.nativeElement as HTMLElement).textContent).toContain('42'); + }); +}); diff --git a/cockpit/render/shared/to-display-text.ts b/cockpit/render/shared/to-display-text.ts index fca7b5a1f..5e8eb5868 100644 --- a/cockpit/render/shared/to-display-text.ts +++ b/cockpit/render/shared/to-display-text.ts @@ -5,7 +5,7 @@ * for an unresolved/object binding — something else) into display text. * * Demo view components bind props like `content`/`value` that can resolve to - * non-string primitives via `$state`/`$fn` bindings (e.g. a numeric + * non-string primitives via `$state`/`$computed` bindings (e.g. a numeric * `/user/age`). Returning `''` only for strings silently dropped those values; * this renders any primitive and treats objects/null as "no text". */ diff --git a/cockpit/render/tsconfig.spec.json b/cockpit/render/tsconfig.spec.json new file mode 100644 index 000000000..2c5fcb2fe --- /dev/null +++ b/cockpit/render/tsconfig.spec.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "experimentalDecorators": true, + "noPropertyAccessFromIndexSignature": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "declaration": false, + "lib": ["es2022", "dom"], + "types": ["vitest/globals"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": [ + "shared/**/*.ts", + "*/angular/src/**/*.ts", + "../../libs/render/src/**/*.ts" + ] +} diff --git a/cockpit/render/vite.config.mts b/cockpit/render/vite.config.mts new file mode 100644 index 000000000..f0307b0b0 --- /dev/null +++ b/cockpit/render/vite.config.mts @@ -0,0 +1,14 @@ +import angular from '@analogjs/vite-plugin-angular'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [angular({ tsconfig: './tsconfig.spec.json' }), nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['shared/**/*.spec.ts'], + setupFiles: ['../../libs/render/src/test-setup.ts'], + pool: 'forks', + }, +});