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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cockpit/chat/input/angular/src/app/input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { injectAgent } from '@threadplane/langgraph';
ExampleChatLayoutComponent,
],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<div main class="flex-1 flex flex-col min-w-0">
<header class="px-4 py-3 border-b" style="border-color: var(--tplane-chat-separator); background: var(--tplane-chat-bg);">
<h1 class="text-sm font-semibold" style="color: var(--tplane-chat-text);">Chat Input Demo</h1>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const SUGGESTIONS = [
ExampleChatLayoutComponent,
],
template: `
<example-chat-layout sidebarWidth="w-80">
<example-chat-layout sidebarWidth="20rem">
<chat main [agent]="agent" class="flex-1 min-w-0">
<div chatWelcomeSuggestions>
@for (s of suggestions; track s.value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { MESSAGES_AGENT, type MessagesState } from './agent-ref';
ExampleChatLayoutComponent,
],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<div main class="flex-1 flex flex-col min-w-0">
<header class="px-4 py-3 border-b" style="border-color: var(--tplane-chat-separator); background: var(--tplane-chat-bg);">
<h1 class="text-sm font-semibold" style="color: var(--tplane-chat-text);">Chat Messages Primitives</h1>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const SUGGESTIONS = [
ExampleChatLayoutComponent,
],
template: `
<example-chat-layout sidebarWidth="w-80">
<example-chat-layout sidebarWidth="20rem">
<chat main [agent]="agent" class="flex-1 min-w-0">
<div chatWelcomeSuggestions>
@for (s of suggestions; track s.value) {
Expand Down
2 changes: 1 addition & 1 deletion cockpit/chat/theming/angular/src/app/theming.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const THEMES: Record<string, Record<string, string>> = {
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent, TitleCasePipe],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-4" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
2 changes: 1 addition & 1 deletion cockpit/chat/threads/angular/src/app/threads.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const activeThreadIdState = signal<string | null>(null);
}),
],
template: `
<example-chat-layout sidebarPosition="left" sidebarWidth="w-64">
<example-chat-layout sidebarPosition="left" sidebarWidth="16rem">
<chat main
[agent]="agent"
[threads]="threadsSvc.threads()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { injectAgent } from '@threadplane/langgraph';
standalone: true,
imports: [ChatComponent, ChatTimelineSliderComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-80">
<example-chat-layout sidebarWidth="20rem">
<chat main [agent]="agent" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-4" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const SUGGESTIONS = [
ExampleChatLayoutComponent,
],
template: `
<example-chat-layout sidebarWidth="w-80">
<example-chat-layout sidebarWidth="20rem">
<chat main [agent]="agent" class="flex-1 min-w-0">
<div chatWelcomeSuggestions>
@for (s of suggestions; track s.value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface FileOperation {
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" [views]="ui" [store]="uiStore" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-2" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { injectAgent } from '@threadplane/langgraph';
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-2" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface PlanStep {
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" [views]="ui" [store]="uiStore" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-2" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface CodeExecution {
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-80">
<example-chat-layout sidebarWidth="20rem">
<chat main [agent]="agent" [views]="ui" [store]="uiStore" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-3" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface SkillInvocation {
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" [views]="ui" [store]="uiStore" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-3" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface Delegation {
standalone: true,
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarWidth="w-72">
<example-chat-layout sidebarWidth="18rem">
<chat main [agent]="agent" class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-2" style="background: var(--tplane-chat-bg); color: var(--tplane-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// bindings dropped, PR #773 "[object Object]" flash for unresolved bindings).
//
// The demo view components in every render/* example bind element props
// (`content`, `value`) that resolve — via $state/$fn — to non-string values
// (`content`, `value`) that resolve — via $state/$computed — to non-string values
// (numbers, or, mid-stream, unresolved objects). Displaying those raw either
// dropped the value (string-only coercion) or rendered "[object Object]".
// The fix routes them through the shared `toDisplayText` helper.
Expand Down
96 changes: 96 additions & 0 deletions cockpit/render/shared/render-examples-latent-bugs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: MIT
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const WORKSPACE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const RENDER_DIR = resolve(WORKSPACE_ROOT, 'cockpit/render');
const ANGULAR_EXAMPLE_DIRS = ['cockpit', 'examples'];
const SUPPORTED_RENDER_DIRECTIVES = new Set([
'$and',
'$bindItem',
'$bindState',
'$computed',
'$cond',
'$else',
'$index',
'$item',
'$or',
'$state',
'$template',
'$then',
]);

function renderSpecFiles(): string[] {
return readdirSync(RENDER_DIR, { withFileTypes: true })
.filter((entry) => 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<string>()): Set<string> {
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]});`)());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function() is effectively eval(). The practical risk for version-controlled fixtures is low, but there's a simpler fix available: each spec file already exports a named *_SPECS array where spec.json is the result of JSON.stringify(obj, null, 2). You can import those arrays statically and round-trip with JSON.parse instead of evaluating source text:

// Replace parseJsonRenderSpecs + this loop with static imports:
import { STATE_MANAGEMENT_SPECS } from '../state-management/angular/src/app/specs';
import { ELEMENT_RENDERING_SPECS } from '../element-rendering/angular/src/app/specs';
// ... other spec arrays

const ALL_SPECS = [
  ...STATE_MANAGEMENT_SPECS,
  ...ELEMENT_RENDERING_SPECS,
  // ...
];
for (const spec of ALL_SPECS) {
  const obj = JSON.parse(spec.json);
  for (const directive of collectDirectiveNames(obj)) { ... }
}

Tradeoff: the static list needs to be updated when new render examples are added, unlike the current dynamic scan. If you want to keep dynamic discovery, dynamic import() over renderSpecFiles() paths avoids Function() while preserving the automation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Function() is effectively eval()

The streaming harness file already statically imports all six *_SPECS arrays. Use the same pattern here — import them, concatenate into ALL_RENDER_SPECS, and round-trip with JSON.parse(spec.json). This removes the eval surface entirely and the error message on failure becomes ${spec.label}: ${directive} instead of a file path, which is more readable.

Suggested change
specs.push(Function(`"use strict"; return (${match[1]});`)());
function parseJsonRenderSpecs(source: string): unknown[] {

Replace renderSpecFiles(), parseJsonRenderSpecs(), and the eval-based loop with:

import { COMPUTED_FUNCTIONS_SPECS } from '../computed-functions/angular/src/app/specs';
import { ELEMENT_RENDERING_SPECS } from '../element-rendering/angular/src/app/specs';
import { REGISTRY_SPECS } from '../registry/angular/src/app/specs';
import { REPEAT_LOOPS_SPECS } from '../repeat-loops/angular/src/app/specs';
import { SPEC_RENDERING_SPECS } from '../spec-rendering/angular/src/app/specs';
import { STATE_MANAGEMENT_SPECS } from '../state-management/angular/src/app/specs';

const ALL_RENDER_SPECS = [
  ...REGISTRY_SPECS, ...SPEC_RENDERING_SPECS, ...ELEMENT_RENDERING_SPECS,
  ...STATE_MANAGEMENT_SPECS, ...REPEAT_LOOPS_SPECS, ...COMPUTED_FUNCTIONS_SPECS,
];

// in the test:
for (const spec of ALL_RENDER_SPECS) {
  for (const directive of collectDirectiveNames(JSON.parse(spec.json))) {
    if (!SUPPORTED_RENDER_DIRECTIVES.has(directive)) {
      unsupported.push(`${spec.label}: ${directive}`);
    }
  }
}

}
return specs;
Comment on lines +57 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function() is effectively eval() over spec source text. For version-controlled fixtures the practical risk is low, but any spec file that happens to contain a JavaScript expression with side-effects (e.g. a computed initializer calling process.exit) will execute at test time. Consider statically importing the spec arrays instead of parsing their source:

// In each specs.ts, export a named constant the test can import directly.
// Then collectDirectiveNames() can run on the imported value without eval.
import { STATE_MANAGEMENT_SPECS } from '../state-management/angular/src/app/specs';
// …and so on for the other render examples.

If the regex-parse approach must stay, at minimum validate that the captured expression only looks like an object/array literal before passing it to Function.

}

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([]);
});
});
95 changes: 95 additions & 0 deletions cockpit/render/shared/render-examples-streaming-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => new Date(args['value'] as string).toLocaleDateString(),
uppercase: (args: Record<string, unknown>) => (args['value'] as string).toUpperCase(),
multiply: (args: Record<string, unknown>) => (args['a'] as number) * (args['b'] as number),
reverse: (args: Record<string, unknown>) => (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();
Comment on lines +82 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here, both confirmed after reading the actual spec arrays:

Magic indicesSTATE_MANAGEMENT_SPECS[1] is "Nested Paths" and COMPUTED_FUNCTIONS_SPECS[1] is "Data Display" right now, so the '30' / '42' assertions are correct. But if either array is reordered, this test silently validates the wrong fixture. The previous review's inline suggestion is the right fix.

Missing await fixture.whenStable() — the loop-based tests above (lines 62–63) call detectChanges() then await fixture.whenStable() then detectChanges() again. This standalone test calls detectChanges() only once per fixture and skips whenStable(). Works today because signal propagation is synchronous, but it's an asymmetry that will bite if an async effect is added to either component.

Suggested change
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();
const statePrimSpec = STATE_MANAGEMENT_SPECS.find((s) => s.label === 'Nested Paths')!;
stateComponent.simulator.setSource(statePrimSpec.json);
stateComponent.simulator.seek(statePrimSpec.json.length);
stateFixture.detectChanges();
await stateFixture.whenStable();
const computedFixture = TestBed.createComponent(ComputedFunctionsComponent);
const computedComponent = computedFixture.componentInstance as unknown as RenderExampleHarness;
const computedPrimSpec = COMPUTED_FUNCTIONS_SPECS.find((s) => s.label === 'Data Display')!;
computedComponent.simulator.setSource(computedPrimSpec.json);
computedComponent.simulator.seek(computedPrimSpec.json.length);
computedFixture.detectChanges();
await computedFixture.whenStable();


expect((stateFixture.nativeElement as HTMLElement).textContent).toContain('30');
expect((computedFixture.nativeElement as HTMLElement).textContent).toContain('42');
Comment on lines +80 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic indices make this test silently validate the wrong fixture if STATE_MANAGEMENT_SPECS or COMPUTED_FUNCTIONS_SPECS are ever reordered or a spec is inserted before index 1.

Suggested change
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');
const statePrimSpec = STATE_MANAGEMENT_SPECS.find((s) => s.label === 'Nested Paths')!;
stateComponent.simulator.setSource(statePrimSpec.json);
stateComponent.simulator.seek(statePrimSpec.json.length);
stateFixture.detectChanges();
const computedFixture = TestBed.createComponent(ComputedFunctionsComponent);
const computedComponent = computedFixture.componentInstance as unknown as RenderExampleHarness;
const computedPrimSpec = COMPUTED_FUNCTIONS_SPECS.find((s) => s.label === 'Data Display')!;
computedComponent.simulator.setSource(computedPrimSpec.json);
computedComponent.simulator.seek(computedPrimSpec.json.length);
computedFixture.detectChanges();

Also note this test doesn't call await fixture.whenStable() after detectChanges(), unlike the loop-based tests above (line 62). Works today because signal propagation is synchronous, but it's an asymmetry worth closing to avoid latent flakiness if an async effect is added.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Magic indices + missing whenStable

STATE_MANAGEMENT_SPECS[1] is "Nested Paths" and COMPUTED_FUNCTIONS_SPECS[1] is "Data Display" today — the '30' / '42' assertions are factually correct. But index coupling means a spec reorder silently validates the wrong fixture, and both detectChanges() calls skip the await whenStable() that the loop tests above use (lines 61–63).

Suggested change
expect((computedFixture.nativeElement as HTMLElement).textContent).toContain('42');
const stateFixture = TestBed.createComponent(StateManagementComponent);
const stateComponent = stateFixture.componentInstance as unknown as RenderExampleHarness;
const statePrimSpec = STATE_MANAGEMENT_SPECS.find((s) => s.label === 'Nested Paths')!;
stateComponent.simulator.setSource(statePrimSpec.json);
stateComponent.simulator.seek(statePrimSpec.json.length);
stateFixture.detectChanges();
await stateFixture.whenStable();
const computedFixture = TestBed.createComponent(ComputedFunctionsComponent);
const computedComponent = computedFixture.componentInstance as unknown as RenderExampleHarness;
const computedPrimSpec = COMPUTED_FUNCTIONS_SPECS.find((s) => s.label === 'Data Display')!;
computedComponent.simulator.setSource(computedPrimSpec.json);
computedComponent.simulator.seek(computedPrimSpec.json.length);
computedFixture.detectChanges();
await computedFixture.whenStable();

});
});
2 changes: 1 addition & 1 deletion cockpit/render/shared/to-display-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
*/
Expand Down
24 changes: 24 additions & 0 deletions cockpit/render/tsconfig.spec.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
14 changes: 14 additions & 0 deletions cockpit/render/vite.config.mts
Original file line number Diff line number Diff line change
@@ -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',
},
});
Loading