react-x11 2.51.1 → 2.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -61
- package/docs/README.md +256 -0
- package/docs/accessibility.md +575 -0
- package/docs/appearance.md +317 -0
- package/docs/click-to-component.md +92 -0
- package/docs/clipboard.md +262 -0
- package/docs/components.md +1231 -0
- package/docs/dbus.md +205 -0
- package/docs/debugging.md +371 -0
- package/docs/desktop-calendar.md +247 -0
- package/docs/desktop.md +523 -0
- package/docs/devtools.md +133 -0
- package/docs/drag-and-drop.md +585 -0
- package/docs/ecosystem/animation.md +360 -0
- package/docs/ecosystem/data-fetching.md +186 -0
- package/docs/ecosystem/dev-tooling.md +393 -0
- package/docs/ecosystem/forms.md +277 -0
- package/docs/ecosystem/headless.md +194 -0
- package/docs/ecosystem/i18n.md +86 -0
- package/docs/ecosystem/icons.md +377 -0
- package/docs/ecosystem/layout.md +206 -0
- package/docs/ecosystem/routing.md +135 -0
- package/docs/ecosystem/state.md +344 -0
- package/docs/ecosystem/testing.md +474 -0
- package/docs/ecosystem/theming.md +494 -0
- package/docs/ecosystem.md +439 -0
- package/docs/elements.md +2079 -0
- package/docs/embedding.md +239 -0
- package/docs/events.md +742 -0
- package/docs/extending.md +1700 -0
- package/docs/eyedropper.md +164 -0
- package/docs/filedialog.md +222 -0
- package/docs/frame.md +436 -0
- package/docs/gl.md +173 -0
- package/docs/globalmenu.md +195 -0
- package/docs/macos.md +592 -0
- package/docs/notifications.md +150 -0
- package/docs/packaging.md +468 -0
- package/docs/permissions.md +143 -0
- package/docs/react-features.md +405 -0
- package/docs/remote.md +179 -0
- package/docs/scale.md +225 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1710 -0
- package/docs/system.md +519 -0
- package/docs/testing.md +494 -0
- package/docs/typescript.md +75 -0
- package/docs/uri-schemes.md +340 -0
- package/docs/wayland-backend.md +332 -0
- package/docs/windows-integrations.md +177 -0
- package/docs/windows.md +380 -0
- package/package.json +11 -3
- package/src/Reconciler.js +2 -2
- package/src/a11y.js +1 -1
- package/src/backend/context2d.js +3 -3
- package/src/bootstrap.js +1 -1
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/cocoa/app.js +6 -6
- package/src/cocoa/bezels.js +1 -1
- package/src/cocoa/fonts.js +1 -1
- package/src/cocoa/globalmenu.js +1 -1
- package/src/cocoa/keymap.js +1 -1
- package/src/cocoa/panewindow.js +1 -1
- package/src/cocoa/player.js +1 -1
- package/src/cocoa/presenter.js +2 -2
- package/src/cocoa/promotion.js +1 -1
- package/src/cocoa/relaunch.js +1 -1
- package/src/cocoa/threaded.js +1 -1
- package/src/cocoa/video.js +1 -1
- package/src/cocoa/window.js +4 -4
- package/src/components/Menu.js +7 -2
- package/src/components/Switch.js +1 -1
- package/src/components/native.js +1 -1
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/dnd.js +2 -2
- package/src/filedialog.js +3 -3
- package/src/glnodes.js +1 -1
- package/src/grid.js +1 -1
- package/src/keyboardstate.js +28 -0
- package/src/layouts.js +1 -1
- package/src/node.d.ts +10 -10
- package/src/nodes/animation.js +1 -1
- package/src/nodes/invalidate.js +1 -1
- package/src/nodes/layouthost.js +1 -1
- package/src/nodes/video.js +19 -8
- package/src/nodes/window/droptarget.js +1 -1
- package/src/pacing.js +1 -1
- package/src/portal.js +31 -0
- package/src/refresh/index.js +2 -1
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/types/video.d.ts +5 -2
- package/src/videobackend.js +28 -0
- package/src/videoframes.js +29 -16
- package/src/wayland/app.js +86 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/video.js +376 -0
- package/src/wayland/xkb.js +137 -0
- package/src/win32/a11y.js +1 -1
- package/src/win32/app.js +2 -2
- package/src/win32/fonts.js +1 -1
- package/src/win32/glarea.js +4 -4
- package/src/win32/overlay.js +1 -1
- package/src/win32/panewindow.js +3 -3
- package/src/win32/shell.js +1 -1
- package/src/win32/window.js +2 -2
- package/src/windowid.js +1 -1
- package/src/xvideo.js +189 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Testing Library does not work here and no shim will fix it: its queries are
|
|
4
|
+
built on a DOM document. Its five failure modes are in the
|
|
5
|
+
[negative-results register](../ecosystem.md#render-time); a jsdom shim only
|
|
6
|
+
makes the query fail differently.
|
|
7
|
+
|
|
8
|
+
The replacement ships in the box: **[`react-x11/test`](../testing.md)** —
|
|
9
|
+
`renderX11`, `screen`, `userEvent`, `waitFor` and pixel assertions, over a
|
|
10
|
+
real X server running in your test process. Read that page first. Below is
|
|
11
|
+
the hand-rolled version, for a test that wants the connection itself, and
|
|
12
|
+
the third-party runners and tools that work alongside it.
|
|
13
|
+
|
|
14
|
+
## The harness, by hand {#harness}
|
|
15
|
+
|
|
16
|
+
node-x11 ships a pure-JS X server that runs in your test process, so a test
|
|
17
|
+
needs no `$DISPLAY`, no Xvfb, and no native dependency. This is what
|
|
18
|
+
`renderX11` does, spelled out for a test that holds the pieces directly.
|
|
19
|
+
|
|
20
|
+
```jsx
|
|
21
|
+
// helpers/harness.jsx
|
|
22
|
+
import React from 'react';
|
|
23
|
+
import { createRoot } from 'react-x11';
|
|
24
|
+
import xserver from 'x11/lib/xserver/index.js';
|
|
25
|
+
|
|
26
|
+
export async function createHeadlessRoot() {
|
|
27
|
+
const server = xserver.createServer({ width: 640, height: 480 });
|
|
28
|
+
const [serverEnd, clientEnd] = xserver.createStreamPair();
|
|
29
|
+
server.addClientStream(serverEnd);
|
|
30
|
+
// `stream` reaches a server that is not on the other end of $DISPLAY,
|
|
31
|
+
// which is the whole trick; the root owns the connection and closes it
|
|
32
|
+
// on unmount.
|
|
33
|
+
return await createRoot({ stream: clientEnd });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The render callback hands back the ntk window instance. `root.render`
|
|
37
|
+
* is synchronous, so this stays a plain helper — only obtaining the root
|
|
38
|
+
* is async, and that happens once. */
|
|
39
|
+
export const render = (root, element) =>
|
|
40
|
+
new Promise((resolve) => root.render(element, (wnd) => resolve(wnd)));
|
|
41
|
+
|
|
42
|
+
export const tick = () => new Promise((r) => setImmediate(r));
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```jsx
|
|
46
|
+
import test from 'node:test';
|
|
47
|
+
import assert from 'node:assert/strict';
|
|
48
|
+
import { createHeadlessRoot, render, tick } from './helpers/harness.jsx';
|
|
49
|
+
|
|
50
|
+
test('paints a box', async () => {
|
|
51
|
+
const root = await createHeadlessRoot();
|
|
52
|
+
const wnd = await render(
|
|
53
|
+
root,
|
|
54
|
+
<window width={320} height={240} title="probe">
|
|
55
|
+
<box style={{ flexGrow: 1, backgroundColor: '#204080' }} />
|
|
56
|
+
</window>,
|
|
57
|
+
);
|
|
58
|
+
await tick(); // layout runs on the frame clock, after the commit
|
|
59
|
+
|
|
60
|
+
const node = wnd._reactX11Node; // the root drawn node
|
|
61
|
+
assert.equal(node.children[0].kind, 'box');
|
|
62
|
+
assert.deepEqual(node.children[0].abs, {
|
|
63
|
+
x: 0,
|
|
64
|
+
y: 0,
|
|
65
|
+
width: 320,
|
|
66
|
+
height: 240,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await root.unmount();
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**The `tick()` is not optional.** `render`'s callback resolves at commit;
|
|
74
|
+
layout runs a task later on the frame clock, so every `abs` rect is
|
|
75
|
+
`{x: 0, y: 0, width: 0, height: 0}` until you yield once. Assert on `kind`
|
|
76
|
+
and `props` immediately, never on geometry.
|
|
77
|
+
|
|
78
|
+
`wnd._reactX11Node` is the root of the drawn tree. Each node carries `kind`
|
|
79
|
+
(the element name), `props`, `children`, and `abs` (its laid-out rect):
|
|
80
|
+
walk it. It is not a DOM; `ownerDocument` and `getClientRects()` exist only
|
|
81
|
+
because React DevTools' `measureHostInstance` dereferences them.
|
|
82
|
+
|
|
83
|
+
A `<text>` node holds `textchunk` children whose `.text` is the string:
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
export function textOf(node) {
|
|
87
|
+
const out = [];
|
|
88
|
+
(function walk(n) {
|
|
89
|
+
if (n.kind === 'textchunk') out.push(n.text);
|
|
90
|
+
(n.children ?? []).forEach(walk);
|
|
91
|
+
})(node);
|
|
92
|
+
return out.join('');
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
To run with no X server at all — faster, and enough for most component
|
|
97
|
+
tests — pass `createRoot({ app })` a mock ntk app that records canvas
|
|
98
|
+
operations instead of painting them. `react-x11/test` exports the one the
|
|
99
|
+
renderer's unit tests use, `createMockApp()`, and
|
|
100
|
+
`renderX11(el, { backend: 'mock' })` builds it for you
|
|
101
|
+
([mock backend](../testing.md#the-mock-backend)).
|
|
102
|
+
|
|
103
|
+
## Driving events {#driving-events}
|
|
104
|
+
|
|
105
|
+
`@testing-library/user-event` throws on `setup()`
|
|
106
|
+
([register](../ecosystem.md#render-time)); use `react-x11/test`'s
|
|
107
|
+
`userEvent`, or emit the raw ntk event on the window. react-x11 subscribes
|
|
108
|
+
to the ntk window's emitter, so this exercises the whole synthetic-event
|
|
109
|
+
path: hit testing, capture, bubble, default actions.
|
|
110
|
+
|
|
111
|
+
```jsx
|
|
112
|
+
test('a synthetic click reaches the handler', async () => {
|
|
113
|
+
const root = await createHeadlessRoot();
|
|
114
|
+
let clicks = 0;
|
|
115
|
+
const wnd = await render(
|
|
116
|
+
root,
|
|
117
|
+
<window width={200} height={100}>
|
|
118
|
+
<box style={{ flexGrow: 1 }} onClick={() => (clicks += 1)} />
|
|
119
|
+
</window>,
|
|
120
|
+
);
|
|
121
|
+
await tick();
|
|
122
|
+
|
|
123
|
+
wnd.emit('mousedown', { x: 20, y: 20, button: 1, buttons: 1 });
|
|
124
|
+
wnd.emit('mouseup', { x: 20, y: 20, button: 1, buttons: 0 });
|
|
125
|
+
await tick();
|
|
126
|
+
|
|
127
|
+
assert.equal(clicks, 1);
|
|
128
|
+
await root.unmount();
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Keyboard input is the same shape — `wnd.emit('keydown', { keycode,
|
|
133
|
+
codepoint, buttons })` — with the keysym registered in
|
|
134
|
+
`app.X.keycode2keysyms` so the renderer can resolve it. `button` is the X
|
|
135
|
+
button number: left is `1`, not `0`.
|
|
136
|
+
|
|
137
|
+
## Runners {#runners}
|
|
138
|
+
|
|
139
|
+
**`node:test` (built-in) is the baseline.** react-x11's own suite uses it,
|
|
140
|
+
and it handles the ESM graph without configuration. Run `.jsx` files through
|
|
141
|
+
`tsx`: `node --import tsx --test 'test/**/*.test.jsx'`.
|
|
142
|
+
|
|
143
|
+
### Vitest {#vitest}
|
|
144
|
+
|
|
145
|
+
**Out of the box.** vitest@4.1.10.
|
|
146
|
+
|
|
147
|
+
Native ESM, built-in JSX and TypeScript transform, watch mode, snapshots. A
|
|
148
|
+
`.test.jsx` that mounts a tree, drives a prop update and asserts on the node
|
|
149
|
+
tree and the paint-op log runs green with no flags. The one setting that
|
|
150
|
+
matters:
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
// vitest.config.mjs
|
|
154
|
+
import { defineConfig } from 'vitest/config';
|
|
155
|
+
|
|
156
|
+
export default defineConfig({
|
|
157
|
+
test: { environment: 'node' }, // the default; never 'jsdom'
|
|
158
|
+
esbuild: { jsx: 'automatic' },
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Never `environment: 'jsdom'`.** It _defines_ `window` and `document`,
|
|
163
|
+
which silently flips environment detection in third-party libraries —
|
|
164
|
+
`@tanstack/query-core` decides it is in a browser and changes retry and
|
|
165
|
+
refetch behaviour, for one. `'node'` is the default; do not copy a web
|
|
166
|
+
project's config across.
|
|
167
|
+
|
|
168
|
+
- Vitest runs test files in worker threads by default. The in-process X
|
|
169
|
+
server works there; `pool: 'forks'` is the escape hatch if long-lived
|
|
170
|
+
server streams misbehave.
|
|
171
|
+
- `vi.useFakeTimers()` shares the [sinon caveat](#sinon): pass an explicit
|
|
172
|
+
`toFake` list, or React's scheduling and the renderer's paint scheduling
|
|
173
|
+
freeze.
|
|
174
|
+
|
|
175
|
+
### Jest {#jest}
|
|
176
|
+
|
|
177
|
+
**Works, but only in real ESM mode.** jest@30.4.2.
|
|
178
|
+
|
|
179
|
+
Jest's default CJS path cannot work: `babel-jest` down-compiles to
|
|
180
|
+
CommonJS, and `react-x11/src/Reconciler.js` and `src/DevToolsIntegration.js`
|
|
181
|
+
call `createRequire(import.meta.url)`, which has no CJS equivalent, so Jest's
|
|
182
|
+
own suggestions lead to
|
|
183
|
+
`SyntaxError: Cannot use 'import.meta' outside a module`. Both dead ends are
|
|
184
|
+
in the [register](../ecosystem.md#install-and-import-time). What works:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
// jest.config.mjs — run with NODE_OPTIONS=--experimental-vm-modules
|
|
188
|
+
export default {
|
|
189
|
+
extensionsToTreatAsEsm: ['.jsx'],
|
|
190
|
+
transform: {
|
|
191
|
+
'\\.jsx$': [
|
|
192
|
+
'babel-jest',
|
|
193
|
+
{
|
|
194
|
+
presets: [
|
|
195
|
+
[
|
|
196
|
+
'@babel/preset-env',
|
|
197
|
+
{ modules: false, targets: { node: 'current' } },
|
|
198
|
+
],
|
|
199
|
+
['@babel/preset-react', { runtime: 'automatic' }],
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`modules: false` is the load-bearing part: with no CJS transform, Jest runs
|
|
208
|
+
the same ESM graph as everything else. `node:test` and Vitest need none of
|
|
209
|
+
this.
|
|
210
|
+
|
|
211
|
+
## msw {#msw}
|
|
212
|
+
|
|
213
|
+
**Out of the box — `msw/node`.** msw@2.15.0.
|
|
214
|
+
|
|
215
|
+
Request-level API mocking. In Node, `setupServer` patches `http`, `https`,
|
|
216
|
+
`fetch`/undici and `XMLHttpRequest`, so `http.get(url, resolver)` handlers
|
|
217
|
+
intercept real requests — no service worker, no DOM. A react-x11 app's data
|
|
218
|
+
layer is ordinary Node networking: components call `fetch`, msw answers, the
|
|
219
|
+
renderer repaints.
|
|
220
|
+
|
|
221
|
+
```jsx
|
|
222
|
+
import { setupServer } from 'msw/node';
|
|
223
|
+
import { http, HttpResponse } from 'msw';
|
|
224
|
+
|
|
225
|
+
const server = setupServer(
|
|
226
|
+
http.get('https://api.example.test/user', () =>
|
|
227
|
+
HttpResponse.json({ name: 'Ada' }),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
before(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
231
|
+
after(() => server.close());
|
|
232
|
+
|
|
233
|
+
function User() {
|
|
234
|
+
const [name, setName] = React.useState('loading');
|
|
235
|
+
React.useEffect(() => {
|
|
236
|
+
let alive = true;
|
|
237
|
+
fetch('https://api.example.test/user')
|
|
238
|
+
.then((r) => r.json())
|
|
239
|
+
.then((d) => alive && setName(d.name));
|
|
240
|
+
return () => {
|
|
241
|
+
alive = false;
|
|
242
|
+
};
|
|
243
|
+
}, []);
|
|
244
|
+
return <text>{name}</text>;
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- The fetch → `setState` → commit round trip is asynchronous, and one
|
|
249
|
+
`setImmediate` tick is **not** enough. Poll the tree with a deadline.
|
|
250
|
+
- `server.listen()` patches process-wide networking. Call `server.close()`
|
|
251
|
+
in `after`, or the harness and msw can leave process state between files.
|
|
252
|
+
- The in-process X server connection is a plain stream, not HTTP; msw never
|
|
253
|
+
touches it.
|
|
254
|
+
- `onUnhandledRequest: 'error'` makes any unmocked request a loud failure —
|
|
255
|
+
the right default for hermetic UI tests.
|
|
256
|
+
- nock also works; prefer it only for its scope-level assertions
|
|
257
|
+
(`scope.done()`) on legacy `http.request`-based clients.
|
|
258
|
+
|
|
259
|
+
## fast-check {#fast-check}
|
|
260
|
+
|
|
261
|
+
**Out of the box.** fast-check@4.9.0.
|
|
262
|
+
|
|
263
|
+
Property-based testing: generators plus a property, run against hundreds
|
|
264
|
+
of random inputs, with any failure _shrunk_ to a minimal counterexample and
|
|
265
|
+
a replay seed. The renderer has two oracles that cannot lie:
|
|
266
|
+
|
|
267
|
+
- **Convergence** — `mount(A)` then `update(A → B)` must produce the same
|
|
268
|
+
tree as a fresh `mount(B)`.
|
|
269
|
+
- **Damage soundness** — a partial repaint must equal a forced full repaint.
|
|
270
|
+
|
|
271
|
+
```jsx
|
|
272
|
+
import fc from 'fast-check';
|
|
273
|
+
|
|
274
|
+
const { node } = fc.letrec((tie) => ({
|
|
275
|
+
node: fc.oneof(
|
|
276
|
+
{ maxDepth: 3 },
|
|
277
|
+
fc.record({
|
|
278
|
+
kind: fc.constant('text'),
|
|
279
|
+
label: fc.constantFrom('a', 'hello x11'),
|
|
280
|
+
}),
|
|
281
|
+
fc.record({
|
|
282
|
+
kind: fc.constant('box'),
|
|
283
|
+
style: fc.record(
|
|
284
|
+
{
|
|
285
|
+
width: fc.integer({ min: 10, max: 120 }),
|
|
286
|
+
flexDirection: fc.constantFrom('row', 'column'),
|
|
287
|
+
},
|
|
288
|
+
{ requiredKeys: [] },
|
|
289
|
+
),
|
|
290
|
+
children: fc.array(tie('node'), { maxLength: 3 }),
|
|
291
|
+
}),
|
|
292
|
+
),
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
const toEl = (s, key) =>
|
|
296
|
+
s.kind === 'text' ? (
|
|
297
|
+
<text key={key}>{s.label}</text>
|
|
298
|
+
) : (
|
|
299
|
+
<box key={key} style={s.style}>
|
|
300
|
+
{s.children.map(toEl)}
|
|
301
|
+
</box>
|
|
302
|
+
);
|
|
303
|
+
const win = (kids) => (
|
|
304
|
+
<window width={320} height={240}>
|
|
305
|
+
{kids.map(toEl)}
|
|
306
|
+
</window>
|
|
307
|
+
);
|
|
308
|
+
const snap = (n) => ({
|
|
309
|
+
kind: n.kind,
|
|
310
|
+
abs: { ...n.abs },
|
|
311
|
+
children: n.children.map(snap),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await fc.assert(
|
|
315
|
+
fc.asyncProperty(
|
|
316
|
+
fc.array(node, { maxLength: 4 }),
|
|
317
|
+
fc.array(node, { maxLength: 4 }),
|
|
318
|
+
async (a, b) => {
|
|
319
|
+
const root1 = await createHeadlessRoot();
|
|
320
|
+
const w1 = await render(root1, win(a));
|
|
321
|
+
await tick();
|
|
322
|
+
await render(root1, win(b)); // update path
|
|
323
|
+
await tick();
|
|
324
|
+
const root2 = await createHeadlessRoot();
|
|
325
|
+
const w2 = await render(root2, win(b)); // fresh-mount path
|
|
326
|
+
await tick();
|
|
327
|
+
assert.deepStrictEqual(snap(w1._reactX11Node), snap(w2._reactX11Node));
|
|
328
|
+
},
|
|
329
|
+
),
|
|
330
|
+
{ numRuns: 30 },
|
|
331
|
+
);
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- Compare structure (`kind` plus `abs` rects), not paint-op logs: op logs
|
|
335
|
+
accumulate across paints on the update path, so they only match after a
|
|
336
|
+
forced full repaint on both sides.
|
|
337
|
+
- Keep trees shallow; each run is a real yoga layout pass.
|
|
338
|
+
- Include a mid-sequence unmount/remount and `key` permutations in the
|
|
339
|
+
generators; index keys exercise much less of `insertBefore`/`removeChild`
|
|
340
|
+
than keyed reorders.
|
|
341
|
+
- A seeded PRNG would also do with no dependency; fast-check buys shrinking
|
|
342
|
+
and `{ seed, path }` replay.
|
|
343
|
+
|
|
344
|
+
## sinon {#sinon}
|
|
345
|
+
|
|
346
|
+
**Out of the box, with one required restriction.** sinon@22.1.0.
|
|
347
|
+
|
|
348
|
+
Spies, stubs and `@sinonjs/fake-timers`. Fake timers work — a toast that
|
|
349
|
+
auto-dismisses via `setTimeout` dismisses on `clock.tick(1000)` — but the
|
|
350
|
+
default configuration is a trap.
|
|
351
|
+
|
|
352
|
+
**Never fake `setImmediate` or `queueMicrotask`.** Sinon's default `toFake`
|
|
353
|
+
list includes them; React's scheduler drives work with them in Node, and the
|
|
354
|
+
renderer schedules paints via `setImmediate` when there is no frame clock.
|
|
355
|
+
Faked, every render, effect flush and repaint freezes until a `clock.tick` —
|
|
356
|
+
usually a test that hangs at the first `await`. Pass an explicit list:
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
const clock = sinon.useFakeTimers({
|
|
360
|
+
toFake: ['setTimeout', 'clearTimeout', 'Date'],
|
|
361
|
+
});
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The renderer's animation clock reads `Date.now()` at call time, so a fake
|
|
365
|
+
`Date` reaches style transitions.
|
|
366
|
+
|
|
367
|
+
```jsx
|
|
368
|
+
test('toast auto-dismisses', async () => {
|
|
369
|
+
const clock = sinon.useFakeTimers({
|
|
370
|
+
toFake: ['setTimeout', 'clearTimeout', 'Date'],
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const root = await createHeadlessRoot();
|
|
374
|
+
const wnd = await render(
|
|
375
|
+
root,
|
|
376
|
+
<window width={100} height={50}>
|
|
377
|
+
<Toast />
|
|
378
|
+
</window>,
|
|
379
|
+
);
|
|
380
|
+
await tick(); // mount + passive effect
|
|
381
|
+
clock.tick(1000); // fires the timeout synchronously
|
|
382
|
+
await tick();
|
|
383
|
+
await tick(); // setState -> commit -> repaint: two tasks
|
|
384
|
+
assert.strictEqual(wnd._reactX11Node.children.length, 0);
|
|
385
|
+
} finally {
|
|
386
|
+
clock.restore(); // always; a leaked fake Date poisons the file
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- Two `setImmediate` ticks after `clock.tick`, not one — the timer
|
|
392
|
+
callback's `setState` commits a task later.
|
|
393
|
+
- `clock.restore()` in `finally`, every time. A leaked fake `Date` breaks
|
|
394
|
+
later tests' transitions and timestamps in the same process.
|
|
395
|
+
- The text-input caret blink uses `setInterval`; faking it without ticking
|
|
396
|
+
leaves the caret timer pending — harmless, but it shows in
|
|
397
|
+
`clock.countTimers()`.
|
|
398
|
+
- **Versus `node:test` mock timers**: `t.mock.timers.enable({ apis:
|
|
399
|
+
['setTimeout'] })` plus `t.mock.timers.tick(1000)` handles the plain-timer
|
|
400
|
+
case with no dependency and auto-restores. Prefer it; use sinon for fake
|
|
401
|
+
`Date` (the animation clock), `clock.countTimers()`, or spies and stubs.
|
|
402
|
+
|
|
403
|
+
## pixelmatch {#pixelmatch}
|
|
404
|
+
|
|
405
|
+
**Out of the box.** pixelmatch@7.2.0.
|
|
406
|
+
|
|
407
|
+
Perceptual image diff: two RGBA buffers plus an output buffer in,
|
|
408
|
+
changed-pixel count and a painted diff image out. Anti-aliasing aware, zero
|
|
409
|
+
dependencies. Render through the harness and read pixels back with
|
|
410
|
+
`getImageData` — no display needed. `jest-image-snapshot` cannot do this (it
|
|
411
|
+
wants a Buffer and receives a window object;
|
|
412
|
+
[register](../ecosystem.md#render-time)), but the pipeline under it is four
|
|
413
|
+
lines.
|
|
414
|
+
|
|
415
|
+
`ctx.getImageData(x, y, w, h)` resolves to a canvas `ImageData`; hand
|
|
416
|
+
pixelmatch its `.data`, already straight RGBA in a `Uint8ClampedArray` with
|
|
417
|
+
opaque pixels at alpha 255. Readback races the paint: poll a known pixel
|
|
418
|
+
before taking the comparison frame.
|
|
419
|
+
|
|
420
|
+
```jsx
|
|
421
|
+
import fs from 'node:fs';
|
|
422
|
+
import pixelmatch from 'pixelmatch';
|
|
423
|
+
import { PNG } from 'pngjs';
|
|
424
|
+
|
|
425
|
+
const ctx = wnd.getContext('2d');
|
|
426
|
+
const diff = new PNG({ width: 320, height: 240 });
|
|
427
|
+
|
|
428
|
+
const before = (await ctx.getImageData(0, 0, 320, 240)).data;
|
|
429
|
+
// ...re-render with the changed prop, wait for the repaint...
|
|
430
|
+
const after = (await ctx.getImageData(0, 0, 320, 240)).data;
|
|
431
|
+
const changed = pixelmatch(before, after, diff.data, 320, 240, {
|
|
432
|
+
threshold: 0.1,
|
|
433
|
+
});
|
|
434
|
+
if (changed > 0) fs.writeFileSync('diff.png', PNG.sync.write(diff));
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
- **Fonts are the determinism problem.** Glyph indices travel on the wire,
|
|
438
|
+
so pin every family with ntk's `StaticFontSource`; never let a snapshot script fall through to
|
|
439
|
+
`FontconfigFontSource`. Pinned, two renders of the same tree diff to zero
|
|
440
|
+
pixels; unpinned, the diff is noise.
|
|
441
|
+
- `threshold` is a per-pixel colour distance, not a pass/fail budget; gate
|
|
442
|
+
on the returned count.
|
|
443
|
+
- Buffers must have identical dimensions, or pixelmatch throws. After a
|
|
444
|
+
window resize, re-read rather than crop.
|
|
445
|
+
- fontkit version bumps change hinting and advance rounding. Treat pixel
|
|
446
|
+
diffs as tolerance-based regression tests; gate CI exactly on tree or
|
|
447
|
+
op-log assertions.
|
|
448
|
+
- Under Xvfb, expect nonzero noise from the server's own rendering; the
|
|
449
|
+
zero-noise floor is specific to the in-process JS server.
|
|
450
|
+
|
|
451
|
+
## react-test-renderer {#react-test-renderer}
|
|
452
|
+
|
|
453
|
+
**Works, but do not build on it.** react-test-renderer@19.2.8.
|
|
454
|
+
|
|
455
|
+
React's headless renderer treats _any_ string element type as a host
|
|
456
|
+
element, so react-x11 vocabulary passes through and coarse JSON snapshots
|
|
457
|
+
work. But:
|
|
458
|
+
|
|
459
|
+
1. **It is deprecated.** Importing it prints
|
|
460
|
+
`react-test-renderer is deprecated`; it still ships in 19.2.x, but React
|
|
461
|
+
discourages new usage.
|
|
462
|
+
2. **It bypasses the actual renderer.** No prop validation, style
|
|
463
|
+
resolution, yoga layout or paint, so a tree that snapshots fine can still
|
|
464
|
+
throw `react-x11: <box width=…> is a style property` or lay out wrong
|
|
465
|
+
under react-x11.
|
|
466
|
+
|
|
467
|
+
The harness above is barely more code and exercises the real
|
|
468
|
+
`createInstance`/`commitUpdate` path with layout. Use react-test-renderer
|
|
469
|
+
only as a bridge when porting an existing suite.
|
|
470
|
+
|
|
471
|
+
- Set `globalThis.IS_REACT_ACT_ENVIRONMENT = true` in test setup, or every
|
|
472
|
+
`act` call warns.
|
|
473
|
+
- `toJSON()` records the raw props, so snapshots are of _input_, not of what
|
|
474
|
+
would render.
|