import { defineComponent, nextTick, shallowRef, type FunctionalComponent } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { renderToString, Box, Text, Transform, Newline, Static, createApp } from "@vue-tui/runtime";
import { render } from "@vue-tui/testing";
import {
createRoot,
createBox,
createText,
createTextLeaf,
attachYoga,
renderScreenReaderOutput,
type AppContext,
} from "@vue-tui/runtime/internal";
import {
makeFakeStdin,
makeFakeWritable,
captureWrites,
getContentWrites,
} from "../lifecycle/test-streams.ts";
// Strip ANSI control sequences (cursor-hide, log-update erase codes) so the
// live screen-reader frames can be compared as plain text. The SR frame writer
// interleaves erase sequences between commits; we only care about the text.
// eslint-disable-next-line no-control-regex -- terminal ANSI escapes are control chars by definition
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
// Yoga.DIRECTION_LTR = 0
const DIRECTION_LTR = 0;
function createTestAppContext(): AppContext {
return {
exit: () => {},
waitUntilRenderFlush: async () => {},
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin,
debug: false,
interactive: false,
isScreenReaderEnabled: false,
isRawModeSupported: false,
setRawMode: () => {},
writeToStdout: () => {},
writeToStderr: () => {},
cursorPosition: undefined,
setCursorPosition: () => {},
};
}
describe("renderScreenReaderOutput (unit)", () => {
test("renders text content", () => {
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Hello");
leaf.parent = text;
text.children.push(leaf);
text.parent = root;
root.children.push(text);
root.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
expect(output).toBe("Hello");
root.yoga.freeRecursive();
});
test("joins row children with space", () => {
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const box = createBox();
attachYoga(box);
box.props["flexDirection"] = "row";
// Yoga.FLEX_DIRECTION_ROW = 2
box.yoga.setFlexDirection(2);
box.parent = root;
root.children.push(box);
root.yoga.insertChild(box.yoga, 0);
const text1 = createText();
attachYoga(text1);
const leaf1 = createTextLeaf("Hello");
leaf1.parent = text1;
text1.children.push(leaf1);
text1.parent = box;
box.children.push(text1);
box.yoga.insertChild(text1.yoga, 0);
const text2 = createText();
attachYoga(text2);
const leaf2 = createTextLeaf("World");
leaf2.parent = text2;
text2.children.push(leaf2);
text2.parent = box;
box.children.push(text2);
box.yoga.insertChild(text2.yoga, 1);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
expect(output).toBe("Hello World");
root.yoga.freeRecursive();
});
test("prepends role annotation", () => {
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const box = createBox();
attachYoga(box);
box.internal_accessibility = { role: "button" };
box.parent = root;
root.children.push(box);
root.yoga.insertChild(box.yoga, 0);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Click me");
leaf.parent = text;
text.children.push(leaf);
text.parent = box;
box.children.push(text);
box.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
expect(output).toBe("button: Click me");
root.yoga.freeRecursive();
});
test("prepends state annotation", () => {
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const box = createBox();
attachYoga(box);
box.internal_accessibility = {
role: "checkbox",
state: { checked: true, disabled: false },
};
box.parent = root;
root.children.push(box);
root.yoga.insertChild(box.yoga, 0);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Option");
leaf.parent = text;
text.children.push(leaf);
text.parent = box;
box.children.push(text);
box.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
expect(output).toBe("checkbox: (checked) Option");
root.yoga.freeRecursive();
});
test("skips display: none nodes", () => {
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const box = createBox();
attachYoga(box);
// Yoga.DISPLAY_NONE = 1
box.yoga.setDisplay(1);
box.parent = root;
root.children.push(box);
root.yoga.insertChild(box.yoga, 0);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Hidden");
leaf.parent = text;
text.children.push(leaf);
text.parent = box;
box.children.push(text);
box.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
expect(output).toBe("");
root.yoga.freeRecursive();
});
test("does not duplicate parent role on child with same role", () => {
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const outerBox = createBox();
attachYoga(outerBox);
outerBox.internal_accessibility = { role: "list" };
outerBox.parent = root;
root.children.push(outerBox);
root.yoga.insertChild(outerBox.yoga, 0);
const innerBox = createBox();
attachYoga(innerBox);
innerBox.internal_accessibility = { role: "list" };
innerBox.parent = outerBox;
outerBox.children.push(innerBox);
outerBox.yoga.insertChild(innerBox.yoga, 0);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Item");
leaf.parent = text;
text.children.push(leaf);
text.parent = innerBox;
innerBox.children.push(text);
innerBox.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
// Inner box has same role as parent, so role is only shown on outer
expect(output).toBe("list: Item");
root.yoga.freeRecursive();
});
// G22 (Ink parity): dedup is only against the IMMEDIATE parent's role.
// A role-less intermediate box must reset the inherited parentRole to undefined
// so a grandchild with the same role as the grandparent IS still announced.
// Ink passes `node.internal_accessibility?.role` (no ?? fallback) to children.
test("G22: grandchild with same role as grandparent IS announced when immediate parent has no role", () => {
// Structure: grandparent[role=list] → middle(no role) → grandchild[role=list]
// Expected: both "list" annotations appear (grandchild is NOT wrongly deduped
// against the grandparent through the role-less intermediate).
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const grandparent = createBox();
attachYoga(grandparent);
grandparent.internal_accessibility = { role: "list" };
grandparent.parent = root;
root.children.push(grandparent);
root.yoga.insertChild(grandparent.yoga, 0);
// Role-less intermediate box — must reset parentRole to undefined for its children.
const middle = createBox();
attachYoga(middle);
// No internal_accessibility on middle (no role).
middle.parent = grandparent;
grandparent.children.push(middle);
grandparent.yoga.insertChild(middle.yoga, 0);
const grandchild = createBox();
attachYoga(grandchild);
grandchild.internal_accessibility = { role: "list" };
grandchild.parent = middle;
middle.children.push(grandchild);
middle.yoga.insertChild(grandchild.yoga, 0);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Item");
leaf.parent = text;
text.children.push(leaf);
text.parent = grandchild;
grandchild.children.push(text);
grandchild.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
// grandchild shares role with grandparent but NOT with its immediate parent
// (which has no role). It must NOT be deduped — both annotations must appear.
expect(output).toBe("list: list: Item");
root.yoga.freeRecursive();
});
// G22 control: immediate-parent dedup is still in effect.
// A child whose DIRECT parent has the same role must still be suppressed.
test("G22 control: child role matching immediate parent is still deduped", () => {
// Structure: parent[role=nav] → child[role=nav]
// Expected: only one "nav:" annotation (child is deduped against immediate parent).
const root = createRoot(createTestAppContext());
attachYoga(root);
root.yoga.setWidth(80);
const parent = createBox();
attachYoga(parent);
parent.internal_accessibility = { role: "nav" };
parent.parent = root;
root.children.push(parent);
root.yoga.insertChild(parent.yoga, 0);
const child = createBox();
attachYoga(child);
child.internal_accessibility = { role: "nav" };
child.parent = parent;
parent.children.push(child);
parent.yoga.insertChild(child.yoga, 0);
const text = createText();
attachYoga(text);
const leaf = createTextLeaf("Link");
leaf.parent = text;
text.children.push(leaf);
text.parent = child;
child.children.push(text);
child.yoga.insertChild(text.yoga, 0);
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
const output = renderScreenReaderOutput(root);
// Child shares role with immediate parent — deduped to single annotation.
expect(output).toBe("nav: Link");
root.yoga.freeRecursive();
});
});
describe("Box aria props", () => {
test("renders aria-role and aria-state on box node", () => {
const output = renderToString(
defineComponent(() => () => (
Click
)),
{ columns: 40 },
);
// The visual output should still contain the text
expect(output).toContain("Click");
});
test("aria-label does not affect normal rendering (screen reader disabled)", () => {
const output = renderToString(
defineComponent(() => () => (
visible text
)),
{ columns: 40 },
);
expect(output).toContain("visible text");
});
test("aria-hidden does not hide box when screen reader is disabled", () => {
const output = renderToString(
defineComponent(() => () => (
still visible
)),
{ columns: 40 },
);
expect(output).toContain("still visible");
});
});
describe("Text aria props", () => {
test("renders normally with aria-label when screen reader is disabled", () => {
const output = renderToString(
defineComponent(() => () => (
original text
)),
{ columns: 40 },
);
expect(output).toContain("original text");
});
test("renders normally with aria-hidden when screen reader is disabled", () => {
const output = renderToString(
defineComponent(() => () => (
hidden text
)),
{ columns: 40 },
);
expect(output).toContain("hidden text");
});
});
describe("Transform accessibility", () => {
// G21 follow-up, finding 2: squashTextContent must pass the transform's
// positional sibling index (not hardcoded 0) so SR output matches paint.
test("nested as 2nd child of gets index 1 in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
a `${s}[${i}]`}>b
)),
{ isScreenReaderEnabled: true },
);
// Transform is the 2nd child (index 1) of the Text node — must receive 1,
// not 0, matching paint.ts and Ink squash-text-nodes.ts:13,38 behavior.
expect(output).toBe("ab[1]");
});
// G23: a directly under a in screen-reader mode must
// CONCATENATE its children with "" (not newline-join them). This matches Ink:
// a is an `ink-text` node, so the SR path squashes it via
// squashTextNodes (squash-text-nodes.ts), which concatenates child text with
// "". Verified empirically against Ink 7.0.4: the transform node's OWN
// internal_transform is NOT applied when it is the top-level node handed to
// squashTextNodes (squash only applies the internal_transform of *child*
// nodes, line 34-39) — so a Transform directly under a Box yields the bare
// concatenated children, no wrapping.
test(" under concatenates children with no newline in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
`[${s}]`}>
a
b
)),
{ isScreenReaderEnabled: true },
);
// Children are concatenated ("ab"), NOT newline-joined ("a\nb").
expect(output).toBe("ab");
});
// G58 (SR twin): a standalone with DIRECT bare-string children
// must include that text in screen-reader output. Ink's SR path squashes the
// ink-text via squashTextNodes, which includes #text children — the
// transform's OWN fn is NOT applied at the top level (only child transforms
// are). Ink reference: ``<${s}>`}>ab` SR
// → "ab". Previously vue-tui dropped bare text-leaf children of a transform in
// the SR squash, yielding "".
test("G58: standalone with bare-string children appears in screen-reader output", () => {
const output = renderToString(
defineComponent(() => () => `<${s}>`}>ab),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("ab");
});
// G58 follow-up — MUST-FIX 3: a CHILD (nested) inside a standalone
// outer must have its OWN fn APPLIED in SR — it is a *child* being
// squashed, and Ink's squashTextNodes applies child internal_transform
// (squash-text-nodes.ts:34). The OUTER transform's own fn is still NOT applied
// (it is the top-level node handed to squash). Ink reference: outer fn skipped,
// inner fn `s=>`{${s}}`` applied to "x" → "{x}". Previously vue-tui rendered the
// child transform via the top-level SR rule (which skips the child's own fn),
// yielding "x".
test("G58 MF3: nested 's own fn IS applied inside a standalone (SR)", () => {
const output = renderToString(
defineComponent(() => () => (
`(outer ${s})`}>
`{${s}}`}>x
)),
{ isScreenReaderEnabled: true },
);
// Inner (child) fn applied → "{x}"; outer (top-level) fn NOT applied.
expect(output).toBe("{x}");
});
// G58 (SR Newline twin): a directly inside a standalone
// contributes its line break to the squashed SR text. Ink reference:
// `ab` SR → "a\nb".
test("G58: standalone with appears in screen-reader output", () => {
const output = renderToString(
defineComponent(() => () => (
`<${s}>`}>
ab
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("a\nb");
});
// G52: Vue materializes a null/v-if/false render as a COMMENT host node that
// occupies a positional slot. React never produces a childNode for such
// children (Ink squash-text-nodes.ts:13 never advances index past them), so
// the SR squash path must skip comment nodes when indexing the transform —
// staying in lockstep with paint and measurement.
test("G52: null sibling does not shift nested index in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
a{null}
`${s}[${i}]`}>b
)),
{ isScreenReaderEnabled: true },
);
// The null produces a comment that must NOT take a slot: a=0, Transform=1.
expect(output).toBe("ab[1]");
});
test("renders children normally when screen reader is disabled", () => {
const output = renderToString(
defineComponent(() => () => (
s.toUpperCase()} accessibilityLabel="accessible label">
lowercase
)),
{ columns: 40 },
);
expect(output).toContain("LOWERCASE");
});
});
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 in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => {`a${ERASE_SCREEN}b`}),
{ isScreenReaderEnabled: true },
);
// The erase sequence is stripped; visible chars survive.
expect(output).toBe("ab");
});
test("strips an erase CSI in nested under in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
{`x${ERASE_SCREEN}y`}
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("xy");
});
test("strips an erase CSI inside a standalone in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
s}>{`p${ERASE_SCREEN}q`}
)),
{ 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(() => () => {`${colored}${ERASE_SCREEN}`}),
{ 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
const App = defineComponent(() => () => (
Hello
));
const { lastFrame } = await render(App, { columns: 40 });
expect(lastFrame()).toContain("Hello");
});
test("all aria props render without errors", async () => {
const App = defineComponent(() => () => (
Item 1
Item 2
s} accessibilityLabel="transform label">
content
));
const { lastFrame } = await render(App, { columns: 40 });
expect(lastFrame()).toContain("Item 1");
expect(lastFrame()).toContain("Item 2");
expect(lastFrame()).toContain("content");
});
});
describe("screen reader enabled mode", () => {
test("render aria-label on Text in screen-reader mode replaces children", () => {
const output = renderToString(
defineComponent(() => () => (
visible text
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Screen-reader only");
});
test("render aria-label on Box in screen-reader mode replaces children", () => {
const output = renderToString(
defineComponent(() => () => (
Not visible to screen readers
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Screen-reader only");
});
test("omit ANSI styling in screen-reader output", () => {
const output = renderToString(
defineComponent(() => () => (
Styled content
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Styled content");
});
test("render multiple Text components", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
World
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Hello\nWorld");
});
// G39 (Ink parity): a default (no explicit flexDirection) lays out as
// a row (yoga/Box default is FLEX_DIRECTION_ROW), so its SR children must be
// joined with a SPACE — matching Ink, which hardcodes flexDirection:'row' in
// Box.tsx and derives the SR separator from style. The yoga row default is NOT
// mirrored into node.props, so an absent flexDirection must be treated as
// "row" in screen-reader.ts. (Buggy behavior joined with "\n".)
test("render default Box (no flexDirection) joins children with space", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
World
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Hello World");
});
// G39 follow-up: pin the yoga-enum mapping + reverse-order branch that
// resolveBoxFlexDirection / renderScreenReaderOutput now own. row-reverse must
// join with a SPACE *and* reverse child order (Ink parity: reverse directions
// flip the visual/announced order).
test("render Box flexDirection=row-reverse joins with space and reverses children", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
World
)),
{ isScreenReaderEnabled: true },
);
// row-reverse: space separator + reversed order → "World Hello".
expect(output).toBe("World Hello");
});
// G39 follow-up: column-reverse must join with a NEWLINE (non-row separator)
// *and* reverse child order.
test("render Box flexDirection=column-reverse joins with newline and reverses children", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
World
)),
{ isScreenReaderEnabled: true },
);
// column-reverse: newline separator + reversed order → "World\nHello".
expect(output).toBe("World\nHello");
});
// G39 stale-read guard (the core reason resolveBoxFlexDirection reads yoga, not
// node.props): when a Box's flexDirection is dynamically REMOVED, the yoga node
// resets to its row default (host/yoga.ts: flexDirection == null → ROW). Since
// node-ops never mirrors flexDirection into node.props (not a STYLE_PROP), the
// SR separator MUST be derived from the live yoga state, not a stale prop. This
// uses the live commit path (isScreenReaderEnabled mount) so the yoga reset is
// real — the old pure-props logic would read undefined → "\n" and FAIL the
// post-removal assertion below.
test.sequential("live SR Box resolves to row default (space) after flexDirection is dynamically removed", async () => {
// shallowRef holding the reactive flexDirection: start "column", then clear.
// Typed as the Box FlexDirection union (not `string`) so the JSX prop
// typechecks under `vp run ci` — FlexDirection isn't exported from the
// public index, so we inline the literal union here.
const flexDirection = shallowRef<
"row" | "row-reverse" | "column" | "column-reverse" | undefined
>("column");
const App = defineComponent(() => () => (
Hello
World
));
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
isScreenReaderEnabled: true,
});
await nextTick();
await nextTick();
// column → newline separator, forward order.
const beforeRemoval = stripAnsi(getContentWrites(writes).join(""));
expect(beforeRemoval).toContain("Hello\nWorld");
expect(beforeRemoval).not.toContain("Hello World");
// Drop flexDirection. node-ops resets the yoga node to its row default;
// node.props.flexDirection is undefined (never mirrored), so the SR path
// must read yoga (ROW) → space separator.
writes.length = 0;
flexDirection.value = undefined;
await nextTick();
await nextTick();
const afterRemoval = stripAnsi(getContentWrites(writes).join(""));
// Yoga reset to row → SPACE separator. (Stale-prop logic would emit "\n".)
expect(afterRemoval).toContain("Hello World");
expect(afterRemoval).not.toContain("Hello\nWorld");
app.unmount();
});
test("render nested Box components with Text", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
World
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Hello\nWorld");
});
test("render component that returns null", () => {
const NullComponent: FunctionalComponent = () => null;
NullComponent.displayName = "NullComponent";
const output = renderToString(
defineComponent(() => () => (
Hello
World
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Hello\nWorld");
});
test("render with aria-state.busy", () => {
const output = renderToString(
defineComponent(() => () => (
Loading
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("(busy) Loading");
});
test("render with aria-state.disabled", () => {
const output = renderToString(
defineComponent(() => () => (
Submit
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("button: (disabled) Submit");
});
test("render with aria-state.expanded", () => {
const output = renderToString(
defineComponent(() => () => (
Select
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("combobox: (expanded) Select");
});
test("render multi-line text with roles", () => {
const output = renderToString(
defineComponent(() => () => (
Item 1
Item 2
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("list: listitem: Item 1\nlistitem: Item 2");
});
test("render text for screen readers with aria-hidden", () => {
const output = renderToString(
defineComponent(() => () => (
Not visible to screen readers
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("");
});
test("render select input for screen readers", () => {
const items = ["Red", "Green", "Blue"];
const selectedIndex = 1;
const output = renderToString(
defineComponent(() => () => (
Select a color:
{items.map((item, index) => (
{item}
))}
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe(
"list: Select a color:\nlistitem: 1. Red\nlistitem: (selected) 2. Green\nlistitem: 3. Blue",
);
});
test("render with aria-state.multiline", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("textbox: (multiline) Hello");
});
test("render with aria-state.readonly", () => {
const output = renderToString(
defineComponent(() => () => (
Hello
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("textbox: (readonly) Hello");
});
test("render with aria-state.required", () => {
const output = renderToString(
defineComponent(() => () => (
Name
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("textbox: (required) Name");
});
test("render nested multi-line text", () => {
const output = renderToString(
defineComponent(() => () => (
Line 1
Line 2
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("Line 1\nLine 2");
});
test("render listbox with multiselectable options", () => {
const output = renderToString(
defineComponent(() => () => (
Option 1
Option 2
Option 3
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe(
"listbox: (multiselectable) option: (selected) Option 1\noption: Option 2\noption: (selected) Option 3",
);
});
// G17 follow-up, finding 2 (Ink parity): SR renderToString must NOT drop
// content. The SR static flush linearizes static items, but the SR
// return branch previously returned only the dynamic output (rendered with
// skipStaticElements:true), discarding the captured static output. Ink's SR
// renderer returns staticOutput when node.staticNode exists (renderer.ts:24-33).
test("renderToString in SR mode includes item text (does not drop it)", () => {
const output = renderToString(
defineComponent(() => {
const items = ["First", "Second"];
return () => (
{{
default: ({ item }: { item: string }) => (
{item}
),
}}
Live
);
}),
{ isScreenReaderEnabled: true },
);
// Static content must be present (it was previously discarded).
expect(output).toContain("First");
expect(output).toContain("Second");
// Dynamic content still present.
expect(output).toContain("Live");
// SR mode linearizes — no border glyphs from the bordered static items.
for (const glyph of ["╭", "╮", "╰", "╯", "─", "│"]) {
expect(output).not.toContain(glyph);
}
});
// G17 follow-up, finding 1 (Ink parity): the SR static linearization must
// honor the 's resolved flexDirection for separator + child order,
// matching how screen-reader.ts linearizes a container (row/row-reverse → " ",
// *-reverse reverses children). The default column case still joins with "\n".
test("renderToString in SR mode honors Static flexDirection=row (space separator)", () => {
const output = renderToString(
defineComponent(() => {
const items = ["Alpha", "Beta"];
return () => (
{{
default: ({ item }: { item: string }) => {item},
}}
);
}),
{ isScreenReaderEnabled: true },
);
// Row direction uses a space separator (screen-reader.ts:76), not "\n".
expect(output).toBe("Alpha Beta");
});
test("renderToString in SR mode honors Static flexDirection=row-reverse (reversed, space)", () => {
const output = renderToString(
defineComponent(() => {
const items = ["Alpha", "Beta"];
return () => (
{{
default: ({ item }: { item: string }) => {item},
}}
);
}),
{ isScreenReaderEnabled: true },
);
// row-reverse reverses child order (screen-reader.ts:79-82) + space separator.
expect(output).toBe("Beta Alpha");
});
test("renderToString in SR mode honors Static flexDirection=column-reverse (reversed, newline)", () => {
const output = renderToString(
defineComponent(() => {
const items = ["Alpha", "Beta"];
return () => (
{{
default: ({ item }: { item: string }) => {item},
}}
);
}),
{ isScreenReaderEnabled: true },
);
// column-reverse reverses order, newline separator (default non-row).
expect(output).toBe("Beta\nAlpha");
});
test("renderToString in SR mode default Static (column) joins with newline, forward order", () => {
const output = renderToString(
defineComponent(() => {
const items = ["Alpha", "Beta"];
return () => (
{{
default: ({ item }: { item: string }) => {item},
}}
);
}),
{ isScreenReaderEnabled: true },
);
// Default column: forward order, newline separator (unchanged behavior).
expect(output).toBe("Alpha\nBeta");
});
});