fix(runtime): sanitize ANSI in screen-reader text, matching Ink (#96)

Ink squashes every ink-text via squashTextNodes, which always returns
sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI while
keeping SGR + OSC. vue's SR squash concatenated raw text-leaf values with no
sanitize, so an embedded control sequence (e.g. \x1b[2J) leaked into screen-reader
output. Wrap the squashed SR text in sanitizeAnsi at squashTextContent and the
standalone <Transform> branch — the SR twin of text-measure.ts:54. The double pass
on nested/transform text is idempotent (sanitizeAnsi is a fixed point), matching
Ink's recursive squashTextNodes.

Tests assert erase stripping in Text/Box/Transform AND that SGR is KEPT (so a
strip-everything regression is caught).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 21:16:51 +08:00
committed by GitHub
parent eea9fb6b72
commit 8091154785
2 changed files with 68 additions and 2 deletions
@@ -522,6 +522,62 @@ describe("Transform accessibility", () => {
});
});
describe("screen-reader ANSI sanitization (Ink parity)", () => {
// Ink squashes every ink-text via squashTextNodes, which ALWAYS returns
// sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI
// (e.g. `\x1b[2J`) while keeping SGR + OSC. vue-tui's SR squash previously
// concatenated raw text-leaf values with NO sanitize, so an embedded control
// sequence survived into screen-reader output. This is the SR twin of
// text-measure.ts:54 (`return sanitizeAnsi(out)`).
// eslint-disable-next-line no-control-regex -- ESC erase code is a control char by definition; testing it is the point
const ERASE_SCREEN = "\x1b[2J";
test("strips an erase CSI embedded in <Text> in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => <Text>{`a${ERASE_SCREEN}b`}</Text>),
{ isScreenReaderEnabled: true },
);
// The erase sequence is stripped; visible chars survive.
expect(output).toBe("ab");
});
test("strips an erase CSI in <Text> nested under <Box> in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Text>{`x${ERASE_SCREEN}y`}</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("xy");
});
test("strips an erase CSI inside a standalone <Transform> in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
<Transform transform={(s: string) => s}>{`p${ERASE_SCREEN}q`}</Transform>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("pq");
});
test("keeps SGR (color) sequences in screen-reader output, matching Ink's sanitizeAnsi", () => {
// sanitizeAnsi strips cursor/erase CSI but KEEPS SGR + OSC — so this would
// FAIL against a strip-everything replacement, proving we mirror Ink's
// sanitizeAnsi (sanitize-ansi.ts), not a blanket ANSI strip.
// eslint-disable-next-line no-control-regex -- SGR codes are control chars; asserting they survive is the point
const colored = "a\x1b[31mb\x1b[39mc";
const output = renderToString(
defineComponent(() => () => <Text>{`${colored}${ERASE_SCREEN}`}</Text>),
{ isScreenReaderEnabled: true },
);
// SGR kept, trailing erase stripped.
expect(output).toBe(colored);
});
});
describe("integration: aria props via render", () => {
test("no unknown prop warnings for aria props", async () => {
// This test verifies that aria props don't trigger the "[vue-tui] unknown prop" warning