react-x11 2.45.1 → 2.45.2
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/package.json +1 -1
- package/src/testing/events.js +122 -16
- package/src/testing/harness.js +79 -5
- package/src/testing/index.d.ts +18 -2
- package/src/testing/queries.js +18 -5
package/package.json
CHANGED
package/src/testing/events.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// async and `fireEvent` is not.
|
|
16
16
|
|
|
17
17
|
import { act } from './harness.js';
|
|
18
|
+
import { describeNode } from './queries.js';
|
|
18
19
|
import { keysymOf, MOD, XK_RETURN } from '../keysyms.js';
|
|
19
20
|
|
|
20
21
|
/** The window node that owns a drawn node (a node, or a window itself). */
|
|
@@ -22,15 +23,23 @@ function rootWindow(node) {
|
|
|
22
23
|
return node?.isWindow ? node : (node?.root ?? null);
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const NOT_IN_A_WINDOW =
|
|
27
|
+
'react-x11/test: the target is not inside a realized window — did you ' +
|
|
28
|
+
'await renderX11(), or has it unmounted?';
|
|
29
|
+
|
|
25
30
|
/** A node's centre in **screen** coordinates, which is what injection takes. */
|
|
26
31
|
export function screenPointOf(node, { dx = 0, dy = 0 } = {}) {
|
|
27
32
|
const win = rootWindow(node);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
// Asked before the rect, which neither kind of node gives up: one that
|
|
34
|
+
// has unmounted, or that something hides, keeps the rect it was last laid
|
|
35
|
+
// out at, and what is at that spot now is whatever was behind it. The hit
|
|
36
|
+
// test passes over a hidden node, and an unmounted one is in no tree, so
|
|
37
|
+
// a pointer aimed there could only reach something else, and nothing
|
|
38
|
+
// would say so.
|
|
39
|
+
if (node?.destroyed || hiddenBy(node)) {
|
|
40
|
+
throw new Error(noRectMessage(node, win));
|
|
33
41
|
}
|
|
42
|
+
if (!win?.window) throw new Error(NOT_IN_A_WINDOW);
|
|
34
43
|
const origin = win.window._screenOrigin ?? {
|
|
35
44
|
x: win.window.x ?? 0,
|
|
36
45
|
y: win.window.y ?? 0,
|
|
@@ -44,10 +53,7 @@ export function screenPointOf(node, { dx = 0, dy = 0 } = {}) {
|
|
|
44
53
|
}
|
|
45
54
|
const abs = node.abs;
|
|
46
55
|
if (!abs || (abs.width === 0 && abs.height === 0)) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`react-x11/test: <${node.kind}> has no laid-out rect yet. Layout runs ` +
|
|
49
|
-
'on the frame clock, so `await act()` before pointing at it.',
|
|
50
|
-
);
|
|
56
|
+
throw new Error(noRectMessage(node, win));
|
|
51
57
|
}
|
|
52
58
|
return {
|
|
53
59
|
x: Math.round(origin.x + abs.x + abs.width / 2 + dx),
|
|
@@ -56,8 +62,101 @@ export function screenPointOf(node, { dx = 0, dy = 0 } = {}) {
|
|
|
56
62
|
};
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Why a node has no rect to aim at, worded as what to do about it. Only one
|
|
67
|
+
* answer is a matter of waiting, and its advice, `await act()`, is wrong for
|
|
68
|
+
* every other: no frame gives a size to a node that layout has already
|
|
69
|
+
* placed at 0×0. Asked in this order:
|
|
70
|
+
*
|
|
71
|
+
* - it has unmounted;
|
|
72
|
+
* - it is hidden: by its own `display: 'none'` or an ancestor's, by React,
|
|
73
|
+
* or in a window kept unmapped by `hidden` or by React. That comes before
|
|
74
|
+
* asking whether layout reached it, because a `<box>` with
|
|
75
|
+
* `display: 'none'` never places its children at all.
|
|
76
|
+
*
|
|
77
|
+
* These first two are asked whatever the node's rect: `screenPointOf`
|
|
78
|
+
* comes here for them before it looks, since the rect such a node keeps
|
|
79
|
+
* is where it was, not where anything is;
|
|
80
|
+
* - it has no box of its own: a span, an SVG shape;
|
|
81
|
+
* - layout has not placed it yet (`_placed`), or the window owes a pass
|
|
82
|
+
* (`needsLayout`) that may still give it a size. A pending frame is no
|
|
83
|
+
* witness: a paced window holds one with nothing scheduled;
|
|
84
|
+
* - layout placed it, at 0×0.
|
|
85
|
+
*/
|
|
86
|
+
function noRectMessage(node, win) {
|
|
87
|
+
const name = describeNode(node);
|
|
88
|
+
if (node.destroyed) {
|
|
89
|
+
return (
|
|
90
|
+
`react-x11/test: ${name} has unmounted, so it is nowhere on screen to ` +
|
|
91
|
+
'point at. If something replaced it, query for that instead.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const hider = hiddenBy(node);
|
|
95
|
+
if (hider) {
|
|
96
|
+
const self = hider === node;
|
|
97
|
+
const has = self ? 'has' : 'which has';
|
|
98
|
+
const how =
|
|
99
|
+
hider.isWindow && hider.props.hidden
|
|
100
|
+
? `${has} \`hidden\``
|
|
101
|
+
: hider.style?.display === 'none'
|
|
102
|
+
? `${has} \`display: 'none'\``
|
|
103
|
+
: `${self ? 'is hidden by React' : 'which React has hidden'}, in ` +
|
|
104
|
+
'an `<Activity mode="hidden">` or a `<Suspense>` showing its ' +
|
|
105
|
+
'fallback';
|
|
106
|
+
return (
|
|
107
|
+
`react-x11/test: ${name} ` +
|
|
108
|
+
(self ? how : `is inside ${describeNode(hider)}, ${how}`) +
|
|
109
|
+
', so there is nothing on screen to point at, and `await act()` will ' +
|
|
110
|
+
'not change that. ' +
|
|
111
|
+
(self
|
|
112
|
+
? 'Show it before pointing at it.'
|
|
113
|
+
: 'Show that before pointing at anything inside it.')
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (!node.yoga) {
|
|
117
|
+
let owner = node.parent;
|
|
118
|
+
while (owner && !owner.yoga) owner = owner.parent;
|
|
119
|
+
return (
|
|
120
|
+
`react-x11/test: ${name} has no box of its own: it is laid out as part ` +
|
|
121
|
+
`of ${owner ? describeNode(owner) : 'its parent'}, so there is no ` +
|
|
122
|
+
'rect to point at. Point at that instead, with `{ dx, dy }` to reach ' +
|
|
123
|
+
'a spot inside it.'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!node._placed || win.needsLayout) {
|
|
127
|
+
return (
|
|
128
|
+
`react-x11/test: <${node.kind}> has no laid-out rect yet. Layout runs ` +
|
|
129
|
+
'on the frame clock, so `await act()` before pointing at it.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
`react-x11/test: ${name} was laid out at 0×0, so there is nothing on ` +
|
|
134
|
+
'screen to point at. Layout has run since it last changed, so ' +
|
|
135
|
+
'`await act()` will not give it a size: give it one, with `width` and ' +
|
|
136
|
+
'`height`, `flexGrow` or content to size it, or point at an ancestor ' +
|
|
137
|
+
'or a child that has one. If its size comes from content still on its ' +
|
|
138
|
+
'way, such as an image that is loading, wait for that with `waitFor`.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The node that takes `node` off the screen — itself or the nearest
|
|
143
|
+
* ancestor with `display: 'none'` or hidden by React, up to and including
|
|
144
|
+
* its window, which a `hidden` prop or React keeps unmapped — or null when
|
|
145
|
+
* nothing does. The same walk as the renderer's `_hiddenInTree`. */
|
|
146
|
+
function hiddenBy(node) {
|
|
147
|
+
for (let n = node; n; n = n.parent) {
|
|
148
|
+
if (n.hidden || n.style?.display === 'none') return n;
|
|
149
|
+
if (n.isWindow) break;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
59
154
|
function serverOf(node) {
|
|
60
|
-
|
|
155
|
+
// what a target that is no node at all — a `queryBy*` that found nothing —
|
|
156
|
+
// is told by `screenPointOf`, for the calls that never ask it
|
|
157
|
+
const win = rootWindow(node);
|
|
158
|
+
if (!win) throw new Error(NOT_IN_A_WINDOW);
|
|
159
|
+
const server = win.app?._reactX11TestServer;
|
|
61
160
|
if (!server) {
|
|
62
161
|
throw new Error(
|
|
63
162
|
'react-x11/test: fireEvent needs the in-process X server, so it needs a ' +
|
|
@@ -142,10 +241,13 @@ export const fireEvent = {
|
|
|
142
241
|
fireEvent.mouseMove(node, options);
|
|
143
242
|
},
|
|
144
243
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Move the pointer off the node, to the screen's origin, which is what
|
|
246
|
+
* produces a leave. Nothing is aimed at, so the node may since have been
|
|
247
|
+
* hidden or unmounted: it only names the connection.
|
|
248
|
+
*/
|
|
249
|
+
mouseLeave(node) {
|
|
250
|
+
serverOf(node).injectPointerMove(0, 0);
|
|
149
251
|
},
|
|
150
252
|
|
|
151
253
|
mouseDown(node, options = {}) {
|
|
@@ -157,9 +259,13 @@ export const fireEvent = {
|
|
|
157
259
|
for (const kc of modifiers.reverse()) server.injectKey(kc, false);
|
|
158
260
|
},
|
|
159
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Release the button wherever the pointer is — a `mouseMove` first puts
|
|
264
|
+
* it somewhere else. The node only names the connection, so it may be one
|
|
265
|
+
* the press hid or unmounted: the gesture can still end.
|
|
266
|
+
*/
|
|
160
267
|
mouseUp(node, options = {}) {
|
|
161
|
-
|
|
162
|
-
server.injectButton(options.button ?? 1, false);
|
|
268
|
+
serverOf(node).injectButton(options.button ?? 1, false);
|
|
163
269
|
},
|
|
164
270
|
|
|
165
271
|
/** Press and release — the renderer synthesises the click from the pair. */
|
package/src/testing/harness.js
CHANGED
|
@@ -56,23 +56,84 @@ const DEFAULT_SCREEN = { width: 1280, height: 800 };
|
|
|
56
56
|
* cheapest request with a reply, and its reply cannot arrive before every
|
|
57
57
|
* request queued ahead of it has been processed — which is what makes it a
|
|
58
58
|
* barrier rather than a sleep.
|
|
59
|
+
*
|
|
60
|
+
* **On a connection that is going, it resolves at once.** Nothing there will
|
|
61
|
+
* be answered, and a test that ends its connection still has `act()`,
|
|
62
|
+
* `waitFor` and `cleanup()` to get through — the last from an `afterEach`.
|
|
63
|
+
* node-x11 reports the two ways a connection goes very differently:
|
|
64
|
+
*
|
|
65
|
+
* - **The app closed it.** From the moment `close()` begins, every request
|
|
66
|
+
* throws `client is in closing state` — synchronously, which inside the
|
|
67
|
+
* promise below is a rejection.
|
|
68
|
+
* - **The server ended it** — it exited, or killed the client. All node-x11
|
|
69
|
+
* says is an `end` event: it goes on taking requests and never answers
|
|
70
|
+
* them, or anything that was in flight, so the round trip waits forever.
|
|
71
|
+
* That is the route a test of `onDisconnect` has to take: over the
|
|
72
|
+
* in-process stream pair, a client that closed its own connection never
|
|
73
|
+
* hears it end.
|
|
59
74
|
*/
|
|
60
75
|
export function settle(app, roundTrips = 2) {
|
|
61
76
|
const X = app?.X;
|
|
62
77
|
if (typeof X?.GetInputFocus !== 'function') return Promise.resolve();
|
|
78
|
+
// renderX11 settles as it mounts, so its connection is watched before a
|
|
79
|
+
// test can end it
|
|
80
|
+
const watch = watchEnd(X);
|
|
63
81
|
let chain = Promise.resolve();
|
|
64
82
|
for (let i = 0; i < roundTrips; i++) {
|
|
65
83
|
chain = chain.then(
|
|
66
84
|
() =>
|
|
67
85
|
new Promise((resolve) => {
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
if (connectionGone(X)) return resolve();
|
|
87
|
+
const done = () => {
|
|
88
|
+
watch.waiting.delete(done);
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
watch.waiting.add(done);
|
|
92
|
+
try {
|
|
93
|
+
X.GetInputFocus(done);
|
|
94
|
+
} catch {
|
|
95
|
+
// refused by a rule the check above does not know: whatever the
|
|
96
|
+
// reason, a request that never went out has nothing to drain
|
|
97
|
+
done();
|
|
98
|
+
}
|
|
70
99
|
}),
|
|
71
100
|
);
|
|
72
101
|
}
|
|
73
102
|
return chain;
|
|
74
103
|
}
|
|
75
104
|
|
|
105
|
+
// Each connection settle() has met: whether the server has ended it, and the
|
|
106
|
+
// round trips waiting on it, which the end has to answer since no reply will.
|
|
107
|
+
const watched = new WeakMap();
|
|
108
|
+
|
|
109
|
+
function watchEnd(X) {
|
|
110
|
+
let watch = watched.get(X);
|
|
111
|
+
if (!watch) {
|
|
112
|
+
watch = { ended: false, waiting: new Set() };
|
|
113
|
+
watched.set(X, watch);
|
|
114
|
+
X.once?.('end', () => {
|
|
115
|
+
watch.ended = true;
|
|
116
|
+
for (const done of watch.waiting) done();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return watch;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a request on this connection would go unanswered: the app began
|
|
124
|
+
* closing it, or its stream has ended — the test ntk makes before releasing
|
|
125
|
+
* anything (`connectionGone`, ntk's lib/cleanup.js) — or the server ended
|
|
126
|
+
* it, which only the `end` event says.
|
|
127
|
+
*/
|
|
128
|
+
function connectionGone(X) {
|
|
129
|
+
return Boolean(
|
|
130
|
+
X?._closing ||
|
|
131
|
+
X?.stream?.destroyed ||
|
|
132
|
+
X?.stream?.writableEnded ||
|
|
133
|
+
watched.get(X)?.ended,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
76
137
|
async function createXServerApp({ screen, fonts }) {
|
|
77
138
|
const [{ default: xserver }, ntk] = await Promise.all([
|
|
78
139
|
optional('x11/lib/xserver/index.js', 'is the in-process X server'),
|
|
@@ -340,7 +401,15 @@ export async function act(fn, only = null) {
|
|
|
340
401
|
for (const entry of entries) await settle(entry.app);
|
|
341
402
|
}
|
|
342
403
|
|
|
343
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* Run the frame the scheduler would have run, for every window in a root.
|
|
406
|
+
*
|
|
407
|
+
* Not skipped on a connection that has gone. The harm a frame can do there
|
|
408
|
+
* is to throw, which it would only once the app is closing, and the frame
|
|
409
|
+
* declines on such a connection before it sends a thing (`_flushFrame`); on
|
|
410
|
+
* one the server ended, its requests are taken and go nowhere. A check here
|
|
411
|
+
* would be a second copy of the frame's own.
|
|
412
|
+
*/
|
|
344
413
|
function flushFrames(entry) {
|
|
345
414
|
for (const node of windowNodesOf(entry.windowNode)) {
|
|
346
415
|
if (!node.window) continue;
|
|
@@ -359,12 +428,17 @@ async function unmountEntry(entry) {
|
|
|
359
428
|
// before the unmount, so the teardown's own hook traffic is not recorded
|
|
360
429
|
entry.at?.uninstall();
|
|
361
430
|
try {
|
|
362
|
-
|
|
431
|
+
// awaited, or the catch is never reached: unmount is async
|
|
432
|
+
await entry.root.unmount?.();
|
|
363
433
|
} catch {
|
|
364
434
|
// a root whose connection already died: nothing left to unmount
|
|
365
435
|
}
|
|
366
436
|
await settle(entry.app);
|
|
367
|
-
|
|
437
|
+
// A connection that is already going is not closed again. close() begins
|
|
438
|
+
// with a round trip, which one the test closed refuses by throwing, and
|
|
439
|
+
// which one whose stream has ended — the server's doing, or a dispose —
|
|
440
|
+
// never answers, hanging the teardown
|
|
441
|
+
if (entry.ownsApp && !connectionGone(entry.app.X)) {
|
|
368
442
|
try {
|
|
369
443
|
await entry.app.close?.();
|
|
370
444
|
} catch {
|
package/src/testing/index.d.ts
CHANGED
|
@@ -343,7 +343,11 @@ export function cleanup(): Promise<void>;
|
|
|
343
343
|
*/
|
|
344
344
|
export function setAppearance(values: PinnedAppearance): Promise<void>;
|
|
345
345
|
|
|
346
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* Drain in-flight requests on a connection. On one that has gone — closed by
|
|
348
|
+
* the app, or ended by the server — it resolves at once, since nothing there
|
|
349
|
+
* will be answered.
|
|
350
|
+
*/
|
|
347
351
|
export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
|
|
348
352
|
|
|
349
353
|
/**
|
|
@@ -461,8 +465,14 @@ export interface KeyOptions {
|
|
|
461
465
|
export const fireEvent: {
|
|
462
466
|
mouseMove(node: DrawnNode, options?: PointerOptions): void;
|
|
463
467
|
mouseEnter(node: DrawnNode, options?: PointerOptions): void;
|
|
468
|
+
/** Move the pointer to the screen's origin, which is a leave. Aims at
|
|
469
|
+
* nothing: `node` only names the connection, and may since have been
|
|
470
|
+
* hidden or unmounted. */
|
|
464
471
|
mouseLeave(node: DrawnNode, options?: PointerOptions): void;
|
|
465
472
|
mouseDown(node: DrawnNode, options?: PointerOptions): void;
|
|
473
|
+
/** Release `button` wherever the pointer is — move it first to release
|
|
474
|
+
* somewhere else. Aims at nothing: `node` only names the connection, and
|
|
475
|
+
* may be one the press hid or unmounted. */
|
|
466
476
|
mouseUp(node: DrawnNode, options?: PointerOptions): void;
|
|
467
477
|
click(node: DrawnNode, options?: PointerOptions): void;
|
|
468
478
|
doubleClick(node: DrawnNode, options?: PointerOptions): void;
|
|
@@ -491,6 +501,7 @@ export const userEvent: {
|
|
|
491
501
|
click(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
492
502
|
doubleClick(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
493
503
|
hover(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
504
|
+
/** `fireEvent.mouseLeave`, flushed: aims at nothing. */
|
|
494
505
|
unhover(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
495
506
|
/** A scroll in notches; `smooth` takes fractions of one, as a touchpad
|
|
496
507
|
* measures them. */
|
|
@@ -517,7 +528,12 @@ export const userEvent: {
|
|
|
517
528
|
}): Promise<void>;
|
|
518
529
|
};
|
|
519
530
|
|
|
520
|
-
/**
|
|
531
|
+
/**
|
|
532
|
+
* A node's centre in screen coordinates. Throws, saying why, for a node
|
|
533
|
+
* with no rect: not laid out yet, laid out at 0×0, or laid out as part of
|
|
534
|
+
* another node — and for one that is hidden or has unmounted, whatever rect
|
|
535
|
+
* it kept, which is where it was and not where anything is.
|
|
536
|
+
*/
|
|
521
537
|
export function screenPointOf(
|
|
522
538
|
node: DrawnNode,
|
|
523
539
|
options?: { dx?: number; dy?: number },
|
package/src/testing/queries.js
CHANGED
|
@@ -66,11 +66,24 @@ export function roleOf(node) {
|
|
|
66
66
|
return roleNameOf(node) ?? node.kind;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
/** A node the way an error names it: its kind, its `data-testname` and the
|
|
70
|
+
* start of its text — what a test author can find it by. A `<popup>` is a
|
|
71
|
+
* window to the renderer, and is named as the element it was written as.
|
|
72
|
+
* The text is read whether or not the node is still mounted, unlike
|
|
73
|
+
* `textOf`'s: one that has unmounted presents nothing now, and is named by
|
|
74
|
+
* what it said. A mounted node holds no unmounted child, so for one of those
|
|
75
|
+
* the two read the same. */
|
|
76
|
+
export function describeNode(node) {
|
|
77
|
+
let text = '';
|
|
78
|
+
(function read(n) {
|
|
79
|
+
if (typeof n.text === 'string') text += n.text;
|
|
80
|
+
for (const child of n.children ?? []) read(child);
|
|
81
|
+
})(node);
|
|
82
|
+
text = text.trim();
|
|
71
83
|
const name = node.props?.['data-testname'];
|
|
84
|
+
const tag = node.isPopup ? 'popup' : node.kind;
|
|
72
85
|
return (
|
|
73
|
-
`<${
|
|
86
|
+
`<${tag}${name ? ` data-testname="${name}"` : ''}>` +
|
|
74
87
|
(text ? ` ${JSON.stringify(text.slice(0, 40))}` : '')
|
|
75
88
|
);
|
|
76
89
|
}
|
|
@@ -79,7 +92,7 @@ function inventory(root, limit = 12) {
|
|
|
79
92
|
const lines = [];
|
|
80
93
|
walk(root, (n) => {
|
|
81
94
|
if (n.kind === 'textchunk' || n.kind === 'textspan') return;
|
|
82
|
-
if (lines.length < limit) lines.push(` ${
|
|
95
|
+
if (lines.length < limit) lines.push(` ${describeNode(n)}`);
|
|
83
96
|
});
|
|
84
97
|
return lines.join('\n');
|
|
85
98
|
}
|
|
@@ -93,7 +106,7 @@ function one(root, found, what, printTree = inventory) {
|
|
|
93
106
|
}
|
|
94
107
|
throw new Error(
|
|
95
108
|
`react-x11/test: found ${found.length} elements for ${what}, expected one:\n` +
|
|
96
|
-
found.map((n) => ` ${
|
|
109
|
+
found.map((n) => ` ${describeNode(n)}`).join('\n'),
|
|
97
110
|
);
|
|
98
111
|
}
|
|
99
112
|
|