Skip to content

Commit 7901296

Browse files
committed
refactor(ui): replace unsafe stringification fallbacks
1 parent cd09f41 commit 7901296

6 files changed

Lines changed: 88 additions & 25 deletions

File tree

test/scripts/lint-suppressions.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,6 @@ describe("production lint suppressions", () => {
8686
"src/channels/plugins/types.plugin.ts|typescript/no-explicit-any|1",
8787
"src/config/types.channels.ts|@typescript-eslint/no-explicit-any|1",
8888
"src/test-utils/vitest-mock-fn.ts|typescript/no-explicit-any|1",
89-
"ui/src/ui/app-tool-stream.ts|typescript/no-base-to-string|1",
90-
"ui/src/ui/presenter.ts|typescript/no-base-to-string|1",
91-
"ui/src/ui/views/config-form.node.ts|typescript/no-base-to-string|3",
9289
"ui/src/ui/views/overview-log-tail.ts|no-control-regex|1",
9390
]);
9491
});

ui/src/ui/app-tool-stream.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { truncateText } from "./format.ts";
1+
import { formatUnknownText, truncateText } from "./format.ts";
22

33
const TOOL_STREAM_LIMIT = 50;
44
const TOOL_STREAM_THROTTLE_MS = 80;
@@ -160,8 +160,7 @@ function formatToolOutput(value: unknown): string | null {
160160
try {
161161
text = JSON.stringify(value, null, 2);
162162
} catch {
163-
// oxlint-disable typescript/no-base-to-string
164-
text = String(value);
163+
text = formatUnknownText(value);
165164
}
166165
}
167166
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);

ui/src/ui/format.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
2+
import { formatRelativeTimestamp, formatUnknownText, stripThinkingTags } from "./format.ts";
33

44
describe("formatAgo", () => {
55
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
@@ -99,3 +99,19 @@ describe("stripThinkingTags", () => {
9999
expect(stripThinkingTags(input)).toBe("Hello\n");
100100
});
101101
});
102+
103+
describe("formatUnknownText", () => {
104+
it("stringifies plain objects without throwing", () => {
105+
expect(formatUnknownText({ ok: true })).toBe('{"ok":true}');
106+
});
107+
108+
it("falls back to object tags for non-serializable values", () => {
109+
const circular: Record<string, unknown> = {};
110+
circular.self = circular;
111+
expect(formatUnknownText(circular)).toBe("[object Object]");
112+
});
113+
114+
it("formats symbols without relying on object coercion", () => {
115+
expect(formatUnknownText(Symbol("agent"))).toBe("Symbol(agent)");
116+
});
117+
});

ui/src/ui/format.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ import { t } from "../i18n/index.ts";
55

66
export { formatRelativeTimestamp, formatDurationHuman };
77

8+
export function formatUnknownText(
9+
value: unknown,
10+
opts: { fallback?: string; pretty?: boolean } = {},
11+
): string {
12+
const fallback = opts.fallback ?? "";
13+
if (value == null) {
14+
return fallback;
15+
}
16+
if (typeof value === "string") {
17+
return value;
18+
}
19+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
20+
return String(value);
21+
}
22+
if (typeof value === "symbol") {
23+
return value.description ? `Symbol(${value.description})` : "Symbol()";
24+
}
25+
try {
26+
const serialized = JSON.stringify(value, null, opts.pretty ? 2 : undefined);
27+
if (serialized !== undefined) {
28+
return serialized;
29+
}
30+
} catch {
31+
// Fall back when value is not JSON-serializable.
32+
}
33+
if (value instanceof Error) {
34+
return value.message || value.name;
35+
}
36+
return Object.prototype.toString.call(value);
37+
}
38+
839
export function formatMs(ms?: number | null): string {
940
if (!ms && ms !== 0) {
1041
return t("common.na");

ui/src/ui/presenter.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { t } from "../i18n/index.ts";
2-
import { formatRelativeTimestamp, formatDurationHuman, formatMs } from "./format.ts";
2+
import {
3+
formatRelativeTimestamp,
4+
formatDurationHuman,
5+
formatMs,
6+
formatUnknownText,
7+
} from "./format.ts";
38
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts";
49

510
export function formatPresenceSummary(entry: PresenceEntry): string {
@@ -39,8 +44,7 @@ export function formatEventPayload(payload: unknown): string {
3944
try {
4045
return JSON.stringify(payload, null, 2);
4146
} catch {
42-
// oxlint-disable typescript/no-base-to-string
43-
return String(payload);
47+
return formatUnknownText(payload);
4448
}
4549
}
4650

ui/src/ui/views/config-form.node.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { html, nothing, type TemplateResult } from "lit";
2+
import { formatUnknownText } from "../format.ts";
23
import { icons as sharedIcons } from "../icons.ts";
34
import type { ConfigUiHints } from "../types.ts";
45
import {
@@ -30,6 +31,27 @@ function jsonValue(value: unknown): string {
3031
}
3132
}
3233

34+
function formatComparablePrimitive(value: unknown): string | null {
35+
if (
36+
typeof value === "string" ||
37+
typeof value === "number" ||
38+
typeof value === "boolean" ||
39+
typeof value === "bigint"
40+
) {
41+
return String(value);
42+
}
43+
return null;
44+
}
45+
46+
function matchesComparablePrimitiveValue(left: unknown, right: unknown): boolean {
47+
if (Object.is(left, right)) {
48+
return true;
49+
}
50+
const leftComparable = formatComparablePrimitive(left);
51+
const rightComparable = formatComparablePrimitive(right);
52+
return leftComparable !== null && leftComparable === rightComparable;
53+
}
54+
3355
// SVG Icons as template literals
3456
const icons = {
3557
chevronDown: html`
@@ -474,17 +496,13 @@ export function renderNode(params: {
474496
(lit) => html`
475497
<button
476498
type="button"
477-
class="cfg-segmented__btn ${
478-
// oxlint-disable typescript/no-base-to-string
479-
lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""
480-
}"
499+
class="cfg-segmented__btn ${matchesComparablePrimitiveValue(lit, resolvedValue)
500+
? "active"
501+
: ""}"
481502
?disabled=${disabled}
482503
@click=${() => onPatch(path, lit)}
483504
>
484-
${
485-
// oxlint-disable typescript/no-base-to-string
486-
String(lit)
487-
}
505+
${formatUnknownText(lit)}
488506
</button>
489507
`,
490508
)}
@@ -553,14 +571,13 @@ export function renderNode(params: {
553571
(opt) => html`
554572
<button
555573
type="button"
556-
class="cfg-segmented__btn ${opt === resolvedValue ||
557-
String(opt) === String(resolvedValue)
574+
class="cfg-segmented__btn ${matchesComparablePrimitiveValue(opt, resolvedValue)
558575
? "active"
559576
: ""}"
560577
?disabled=${disabled}
561578
@click=${() => onPatch(path, opt)}
562579
>
563-
${String(opt)}
580+
${formatUnknownText(opt)}
564581
</button>
565582
`,
566583
)}
@@ -666,8 +683,7 @@ function renderTextInput(params: {
666683
: "Structured value (SecretRef) - edit the config file directly"
667684
: REDACTED_PLACEHOLDER
668685
: (hint?.placeholder ??
669-
// oxlint-disable typescript/no-base-to-string
670-
(schema.default !== undefined ? `Default: ${String(schema.default)}` : ""));
686+
(schema.default !== undefined ? `Default: ${formatUnknownText(schema.default)}` : ""));
671687
const displayValue = effectiveRedacted
672688
? ""
673689
: isStructuredValue
@@ -684,7 +700,7 @@ function renderTextInput(params: {
684700
type=${effectiveInputType}
685701
class="cfg-input${effectiveRedacted ? " cfg-input--redacted" : ""}"
686702
placeholder=${placeholder}
687-
.value=${displayValue == null ? "" : String(displayValue)}
703+
.value=${formatUnknownText(displayValue)}
688704
?disabled=${disabled}
689705
?readonly=${effectiveRedacted}
690706
@click=${() => {
@@ -778,7 +794,7 @@ function renderNumberInput(params: {
778794
<input
779795
type="number"
780796
class="cfg-number__input"
781-
.value=${displayValue == null ? "" : String(displayValue)}
797+
.value=${formatUnknownText(displayValue)}
782798
?disabled=${disabled}
783799
@input=${(e: Event) => {
784800
const raw = (e.target as HTMLInputElement).value;

0 commit comments

Comments
 (0)