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
59 changes: 59 additions & 0 deletions cockpit/render/shared/render-examples-display-coercion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: MIT
//
// Fitness guard for the value-coercion bug class (PR #771 numeric $state
// 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
// (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.
//
// This test statically asserts that invariant across ALL render example demo
// components so a new or edited example can't silently reintroduce the bug —
// it runs in the plain node vitest env (no Angular/browser needed), unlike the
// render examples themselves which have no e2e coverage (aimock N/A: no LLM).
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const RENDER_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');

function exampleComponentFiles(): string[] {
const files: string[] = [];
for (const entry of readdirSync(RENDER_DIR, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'shared') continue;
const appDir = resolve(RENDER_DIR, entry.name, 'angular/src/app');
if (!existsSync(appDir)) continue;
for (const f of readdirSync(appDir)) {
if (f.endsWith('.component.ts')) files.push(resolve(appDir, f));
}
}
return files;
}

describe('render example demo components route displayed props through toDisplayText', () => {
const files = exampleComponentFiles();

it('discovers the render example component files', () => {
// Guards the guard: if the glob breaks, the assertions below vacuously pass.
expect(files.length).toBeGreaterThanOrEqual(6);
});

for (const file of files) {
const rel = file.slice(file.indexOf('cockpit/'));
const src = readFileSync(file, 'utf8');

it(`${rel}: never raw-interpolates content()/value() in a template`, () => {
// Anti-pattern: `{{ content() }}` / `{{ value() }}` — must go through a
// display*() computed backed by toDisplayText. `{{ displayContent() }}`
// does NOT match (capital C after "display").
const raw = src.match(/\{\{\s*(?:content|value)\(\)\s*\}\}/g);
expect(raw, `raw prop interpolation found: ${raw?.join(', ')}`).toBeNull();
});

it(`${rel}: imports toDisplayText (used for content/value coercion)`, () => {
expect(src).toContain('toDisplayText');
});
}
});
11 changes: 11 additions & 0 deletions cockpit/render/shared/to-display-text.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,15 @@ describe('toDisplayText', () => {
expect(toDisplayText({ a: 1 })).toBe('');
expect(toDisplayText([1, 2])).toBe('');
});

it('never leaks "[object Object]" for any object-ish value (the headline bug)', () => {
for (const v of [{ a: 1 }, [1, 2], new Date(), () => 0, { toString: () => 'x' }]) {
expect(toDisplayText(v)).not.toContain('[object');
expect(toDisplayText(v)).toBe('');
}
});

it('stringifies bigint', () => {
expect(toDisplayText(10n)).toBe('10');
});
});
Loading