Skip to content

Commit 7ccad77

Browse files
authored
test(fresco): bootstrap the JS component test baseline (#3232)
1 parent 8c463f1 commit 7ccad77

14 files changed

Lines changed: 1177 additions & 23 deletions

File tree

npm/fresco/CONTRIBUTING-tests.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Fresco JS test baseline
2+
3+
`pnpm test` runs `node:test` suites (via `vp exec tsx --test 'src/**/*.test.ts'`).
4+
`pnpm check` includes `tsgo --noEmit`, which gates the compile-only type tests.
5+
6+
## The three-part standard
7+
8+
Every suite in `npm/fresco/src` follows three rules (see #3228):
9+
10+
1. **Render-snapshot tests assert the mounted output tree.** Mount through the
11+
real renderer and compare `FrescoNode` trees (or `treeToRenderNodes` /
12+
`renderToString` output) against inline expected structures with
13+
`assert.deepEqual`. Never assert on source text, and avoid giant snapshots:
14+
keep expected trees small enough to read inline.
15+
2. **Type-level tests cover public props/emits.** Compile-only `*.test-d.ts`
16+
files (see `src/components/types.test-d.ts`) pair positive cases with
17+
`@ts-expect-error` negatives; `tsgo` fails when either direction regresses.
18+
3. **Behavior tests cover keyboard/focus ownership.** Drive keys through
19+
`dispatchKey`/`typeChars` (the same `lastKeyEvent` ref the interactive event
20+
loop writes) and assert focus against the `FocusManager` contract.
21+
22+
## The native seam
23+
24+
There is no mock of `@vizejs/fresco-native`. The JS layer is pure up to the
25+
byte boundary that crosses into Rust: `renderer.ts` imports only types from the
26+
native package, and `app.ts` loads it lazily and only in interactive mode.
27+
Tests therefore run the shipped code end-to-end:
28+
29+
- `mountFresco` (in `src/testing/mount.ts`) mounts components with the same
30+
provide set `createApp` installs (app context, focus manager, screen reader
31+
flag, streams, cursor) and exposes the live mounted tree.
32+
- `treeToRenderNodes(root)` returns the exact flat payload the native
33+
`renderTree` call would paint; assert on it for style/appearance mapping.
34+
- `renderToString(root)` covers the plain-text output path.
35+
36+
If a future test needs a runtime native API, add a typed seam instead of
37+
importing the binding: keep the fake at the narrowest boundary possible.
38+
39+
## Adding a component test
40+
41+
1. Create `src/components/YourComponent.test.ts` (colocated, `node:test`).
42+
2. Mount with `mountComponent(YourComponent, props, slot?, options?)`.
43+
3. Assert the tree with `toTreeSnapshot(firstChild(mounted))` against an
44+
inline expected object, or read targeted props off `firstChild(mounted)`.
45+
Note: Boolean-declared props default to `false` and appear on emitted host
46+
nodes; assert them explicitly.
47+
4. For interaction, pass `focused: true` (or use `useFocus` ids) and drive
48+
`await dispatchKey({ key: "enter" })` / `await typeChars("abc")`; reactive
49+
updates need `await nextTick()` before re-reading the tree.
50+
5. Always `mounted.unmount()` so composable lifecycles (focus unregister,
51+
watcher disposal) are exercised.
52+
6. Extend `src/components/types.test-d.ts` with the component's public
53+
props/emits, including at least one `@ts-expect-error` negative per prop
54+
group.

npm/fresco/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,20 @@
4747
"scripts": {
4848
"build": "vp pack",
4949
"dev": "vp pack --watch",
50-
"check": "vp check src vite.config.ts",
50+
"check": "vp check src vite.config.ts && tsgo --noEmit -p tsconfig.json",
5151
"check:fix": "vp check --fix src vite.config.ts",
52-
"fmt": "vp fmt --write src vite.config.ts"
52+
"check:types": "tsgo --noEmit -p tsconfig.json",
53+
"fmt": "vp fmt --write src vite.config.ts",
54+
"test": "vp exec tsx --test 'src/**/*.test.ts'"
5355
},
5456
"dependencies": {
5557
"@vizejs/fresco-native": "workspace:*"
5658
},
5759
"devDependencies": {
5860
"@types/node": "catalog:typescript",
61+
"@typescript/native-preview": "catalog:typescript",
5962
"@vue/runtime-core": "catalog:vue-stable",
63+
"tsx": "catalog:typescript",
6064
"typescript": "catalog:typescript",
6165
"vite-plus": "catalog:vite-stack"
6266
},

npm/fresco/src/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
type Ref,
1414
type VNode,
1515
} from "@vue/runtime-core";
16-
import type { InputEventNapi } from "@vizejs/fresco-native";
16+
import type { InputEventNapi, ModifiersNapi } from "@vizejs/fresco-native";
1717
import {
1818
SCREEN_READER_KEY,
1919
isScreenReaderEnabledByDefault,
@@ -824,7 +824,7 @@ export function createApp(rootComponent: AppRoot, options: AppOptions = {}): App
824824
}
825825

826826
if (event.eventType === "key") {
827-
const modifiers = event.modifiers ?? {};
827+
const modifiers: Partial<ModifiersNapi> = event.modifiers ?? {};
828828
lastKeyEvent.value = {
829829
type: "key",
830830
key: event.key ?? undefined,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { h } from "@vue/runtime-core";
4+
5+
import { firstChild, mountComponent, toTreeSnapshot } from "../testing/mount.js";
6+
import { Box } from "./Box.js";
7+
8+
// Box declares Boolean props, so Vue defaults them to false and the render
9+
// function forwards them onto the host node; they are inert downstream
10+
// (treeToRenderNodes drops falsy appearance) but part of the output tree.
11+
const boxDefaults = { borderDimColor: false, "aria-hidden": false };
12+
13+
void test("renders a box node and forwards children", () => {
14+
const mounted = mountComponent(Box, {}, () => h("text", { text: "inside" }));
15+
16+
assert.deepEqual(toTreeSnapshot(firstChild(mounted)), {
17+
type: "box",
18+
props: { style: {}, ...boxDefaults },
19+
children: [{ type: "text", props: { text: "inside" } }],
20+
});
21+
mounted.unmount();
22+
});
23+
24+
void test("maps layout props onto the style object", () => {
25+
const mounted = mountComponent(Box, {
26+
flexDirection: "column",
27+
justifyContent: "center",
28+
alignItems: "flex-end",
29+
flexGrow: 1,
30+
flexShrink: 0,
31+
width: 40,
32+
height: "50%",
33+
minWidth: 5,
34+
gap: 2,
35+
overflow: "hidden",
36+
});
37+
38+
assert.deepEqual(firstChild(mounted).props.style, {
39+
flexDirection: "column",
40+
justifyContent: "center",
41+
alignItems: "flex-end",
42+
flexGrow: 1,
43+
flexShrink: 0,
44+
width: "40",
45+
height: "50%",
46+
minWidth: "5",
47+
gap: 2,
48+
overflow: "hidden",
49+
});
50+
mounted.unmount();
51+
});
52+
53+
void test("expands padding and margin axis shorthands per side", () => {
54+
const mounted = mountComponent(Box, {
55+
padding: 1,
56+
paddingX: 2,
57+
paddingTop: 3,
58+
marginY: 4,
59+
marginLeft: 5,
60+
});
61+
62+
assert.deepEqual(firstChild(mounted).props.style, {
63+
padding: 1,
64+
paddingTop: 3,
65+
paddingRight: 2,
66+
// paddingBottom stays unset: per-side keys materialize only when that
67+
// side or its axis shorthand is given; the base padding covers the rest.
68+
paddingLeft: 2,
69+
marginTop: 4,
70+
marginBottom: 4,
71+
marginLeft: 5,
72+
});
73+
mounted.unmount();
74+
});
75+
76+
void test("normalizes border style names and prefers the Ink alias", () => {
77+
const withBorder = (props: Record<string, unknown>) => {
78+
const mounted = mountComponent(Box, props);
79+
const border = firstChild(mounted).props.border;
80+
mounted.unmount();
81+
return border;
82+
};
83+
84+
assert.equal(withBorder({ border: "single" }), "single");
85+
assert.equal(withBorder({ border: "round" }), "rounded");
86+
assert.equal(withBorder({ border: "bold" }), "heavy");
87+
assert.equal(withBorder({ borderStyle: "double", border: "single" }), "double");
88+
assert.equal(withBorder({}), undefined);
89+
});
90+
91+
void test("resolves color aliases with Fresco names winning", () => {
92+
const mounted = mountComponent(Box, {
93+
fg: "cyan",
94+
color: "red",
95+
backgroundColor: "blue",
96+
borderColor: "green",
97+
});
98+
99+
const props = firstChild(mounted).props;
100+
assert.equal(props.fg, "cyan");
101+
assert.equal(props.bg, "blue");
102+
assert.equal(props.borderColor, "green");
103+
mounted.unmount();
104+
});
105+
106+
void test("hides aria-hidden subtrees only in screen reader mode", () => {
107+
const visible = mountComponent(Box, { "aria-hidden": true }, () => h("text", { text: "x" }));
108+
assert.equal(firstChild(visible).type, "box");
109+
assert.equal(firstChild(visible).props["aria-hidden"], true);
110+
visible.unmount();
111+
112+
const hidden = mountComponent(Box, { "aria-hidden": true }, () => h("text", { text: "x" }), {
113+
screenReader: true,
114+
});
115+
assert.deepEqual(toTreeSnapshot(firstChild(hidden)), { type: "text" });
116+
hidden.unmount();
117+
});
118+
119+
void test("replaces children with the aria-label in screen reader mode", () => {
120+
const mounted = mountComponent(
121+
Box,
122+
{ "aria-label": "status panel" },
123+
() => h("text", { text: "42%" }),
124+
{ screenReader: true },
125+
);
126+
127+
assert.deepEqual(toTreeSnapshot(firstChild(mounted)), {
128+
type: "box",
129+
props: { style: {}, "aria-label": "status panel", ...boxDefaults },
130+
children: [{ type: "text", props: { text: "status panel" } }],
131+
});
132+
mounted.unmount();
133+
});

npm/fresco/src/components/Box.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -228,16 +228,18 @@ export const Box = defineComponent({
228228
color: String,
229229
bg: String,
230230
backgroundColor: String,
231-
"aria-label": String,
232-
"aria-hidden": Boolean,
233-
"aria-role": String as PropType<BoxProps["aria-role"]>,
234-
"aria-state": Object as PropType<BoxProps["aria-state"]>,
231+
// Declared camelCase so the runtime props object (which Vue camelizes)
232+
// matches these keys; templates and h() may still pass "aria-label" etc.
233+
ariaLabel: String,
234+
ariaHidden: Boolean,
235+
ariaRole: String as PropType<BoxProps["aria-role"]>,
236+
ariaState: Object as PropType<BoxProps["aria-state"]>,
235237
},
236238
setup(props, { slots }) {
237239
const isScreenReaderEnabled = useIsScreenReaderEnabled();
238240

239241
return () => {
240-
if (isScreenReaderEnabled && props["aria-hidden"]) return null;
242+
if (isScreenReaderEnabled && props.ariaHidden) return null;
241243

242244
const style: Record<string, unknown> = {};
243245

@@ -302,8 +304,8 @@ export const Box = defineComponent({
302304
if (props.overflowY) style.overflowY = props.overflowY;
303305

304306
const children =
305-
isScreenReaderEnabled && props["aria-label"]
306-
? [h("text", { text: props["aria-label"] })]
307+
isScreenReaderEnabled && props.ariaLabel
308+
? [h("text", { text: props.ariaLabel })]
307309
: slots.default?.();
308310

309311
return h(
@@ -320,10 +322,10 @@ export const Box = defineComponent({
320322
borderBackgroundColor: props.borderBackgroundColor,
321323
fg: props.fg ?? props.color,
322324
bg: props.bg ?? props.backgroundColor,
323-
"aria-label": props["aria-label"],
324-
"aria-hidden": props["aria-hidden"],
325-
"aria-role": props["aria-role"],
326-
"aria-state": props["aria-state"],
325+
"aria-label": props.ariaLabel,
326+
"aria-hidden": props.ariaHidden,
327+
"aria-role": props.ariaRole,
328+
"aria-state": props.ariaState,
327329
},
328330
children,
329331
);

0 commit comments

Comments
 (0)