react-x11 2.45.0 → 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/cocoa/glarea.js +18 -2
- package/src/foreignnodes.js +9 -6
- package/src/glnodes.js +12 -2
- package/src/nodes/node.js +19 -0
- package/src/testing/events.js +122 -16
- package/src/testing/harness.js +104 -12
- package/src/testing/index.d.ts +35 -3
- package/src/testing/queries.js +25 -23
package/package.json
CHANGED
package/src/cocoa/glarea.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// What GlAreaNode needs from us is the child-"window" contract it already
|
|
12
12
|
// speaks (src/glnodes.js): `createWindow({ parent, … })` answering an
|
|
13
13
|
// object with `getContext('opengl', config)`, `setState(rect)`, `map()`,
|
|
14
|
-
// `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
|
|
14
|
+
// `unmap()`, `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
|
|
15
15
|
// window stacked above the parent's drawing; here it is a sublayer of the
|
|
16
16
|
// window's root layer with a high zPosition — the same "GL sits above the
|
|
17
17
|
// 2D" semantics, by the same mechanism the platform gives us. The layer
|
|
@@ -153,6 +153,9 @@ export class CocoaGLArea {
|
|
|
153
153
|
return layer;
|
|
154
154
|
});
|
|
155
155
|
this.rect = null;
|
|
156
|
+
// Ours to keep, because every `setState` writes the layer's whole prop
|
|
157
|
+
// set: a geometry change on a hidden surface must not show it again.
|
|
158
|
+
this._hidden = false;
|
|
156
159
|
this.setState({
|
|
157
160
|
x: options.x ?? 0,
|
|
158
161
|
y: options.y ?? 0,
|
|
@@ -178,7 +181,7 @@ export class CocoaGLArea {
|
|
|
178
181
|
// mirroring the layer is the flip, applied where the compositor is
|
|
179
182
|
// already transforming instead of in anyone's shader.
|
|
180
183
|
transform: { scaleY: -1 },
|
|
181
|
-
hidden:
|
|
184
|
+
hidden: this._hidden,
|
|
182
185
|
});
|
|
183
186
|
this._context?._resized(rect.width, rect.height);
|
|
184
187
|
}
|
|
@@ -200,9 +203,22 @@ export class CocoaGLArea {
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
map() {
|
|
206
|
+
this._hidden = false;
|
|
203
207
|
if (!this.destroyed) this._setLayerProps({ hidden: false });
|
|
204
208
|
}
|
|
205
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The other half of `map()`, which this surface went without: a layer is
|
|
212
|
+
* composited by the WindowServer rather than painted by the window, so a
|
|
213
|
+
* subtree going off the screen leaves it on screen over what replaced it.
|
|
214
|
+
* X11 and Wayland unmap the surface's window for this; here the layer's
|
|
215
|
+
* own `hidden` carries it.
|
|
216
|
+
*/
|
|
217
|
+
unmap() {
|
|
218
|
+
this._hidden = true;
|
|
219
|
+
if (!this.destroyed) this._setLayerProps({ hidden: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
/**
|
|
207
223
|
* Everything this layer is handed goes out with Core Animation's implicit
|
|
208
224
|
* actions off. The layer is ours, not a presenter's, so no frame's
|
package/src/foreignnodes.js
CHANGED
|
@@ -488,18 +488,21 @@ export class ForeignNode extends Node {
|
|
|
488
488
|
wnd.setBackgroundPixel(pixel);
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
-
|
|
492
|
-
|
|
491
|
+
/** The client's window is a window of the display system over this one,
|
|
492
|
+
* so it follows the whole subtree's visibility — see
|
|
493
|
+
* `GlAreaNode._syncSurfaceVisibility`, which answers the same call. */
|
|
494
|
+
_syncSurfaceVisibility() {
|
|
493
495
|
this._syncMapped();
|
|
494
496
|
}
|
|
495
497
|
|
|
496
|
-
/** The container follows
|
|
497
|
-
*
|
|
498
|
-
*
|
|
498
|
+
/** The container follows whether this subtree is on screen — this node's
|
|
499
|
+
* own `hidden` flag, or anything above it that took the subtree away; the
|
|
500
|
+
* client follows the container, because an inferior of an unmapped window
|
|
501
|
+
* is not viewable however mapped it is itself. */
|
|
499
502
|
_syncMapped() {
|
|
500
503
|
const wnd = this.socket?.window;
|
|
501
504
|
if (!wnd) return;
|
|
502
|
-
if (this.
|
|
505
|
+
if (this._hiddenInTree()) wnd.unmap?.();
|
|
503
506
|
else wnd.map?.();
|
|
504
507
|
}
|
|
505
508
|
|
package/src/glnodes.js
CHANGED
|
@@ -324,6 +324,9 @@ export class GlAreaNode extends Node {
|
|
|
324
324
|
// same zPosition. The window's hit test reads the list in that order
|
|
325
325
|
// (`EventManager._surfaceAt`).
|
|
326
326
|
this._joinSurfaces();
|
|
327
|
+
// A surface created inside a subtree that is already hidden — a tab
|
|
328
|
+
// mounted behind the one on screen — must not come up over it.
|
|
329
|
+
this._syncSurfaceVisibility();
|
|
327
330
|
this.gl = wnd.getContext('opengl', config);
|
|
328
331
|
// A buffer freed by the display is a frame that can be drawn again — for
|
|
329
332
|
// a frame that was waiting for one. The Cocoa surface and the CGL
|
|
@@ -725,8 +728,15 @@ export class GlAreaNode extends Node {
|
|
|
725
728
|
this.requestFrame();
|
|
726
729
|
}
|
|
727
730
|
|
|
728
|
-
|
|
729
|
-
|
|
731
|
+
/**
|
|
732
|
+
* The surface is a window of the display system, so it goes when the
|
|
733
|
+
* subtree does — `display: 'none'` anywhere above it, or React's own
|
|
734
|
+
* `hidden` for `<Activity>`/`<Suspense>`, on this node or on an ancestor.
|
|
735
|
+
* `Node._syncSurfaces` calls this for every route; the panes the children
|
|
736
|
+
* are drawn on follow the same answer.
|
|
737
|
+
*/
|
|
738
|
+
_syncSurfaceVisibility() {
|
|
739
|
+
const hidden = this._hiddenInTree();
|
|
730
740
|
if (hidden) this.window?.unmap?.();
|
|
731
741
|
else this.window?.map?.();
|
|
732
742
|
this._overlay?.setHidden(hidden);
|
package/src/nodes/node.js
CHANGED
|
@@ -860,12 +860,31 @@ export class Node {
|
|
|
860
860
|
// window rather than for this subtree — the set is the handful of nodes
|
|
861
861
|
// that declare an `animation`, and each one answers for itself.
|
|
862
862
|
if (this.root?._loopNodes?.size) this.root._refreshLoops();
|
|
863
|
+
// Same funnel, same reason, one layer down: a surface — a `<glarea>`'s GL
|
|
864
|
+
// child, an embedded client's window — is a real window of the display
|
|
865
|
+
// system stacked above everything this window paints, not pixels in its
|
|
866
|
+
// 2d context. Hiding a subtree takes away its layout and its paint, and
|
|
867
|
+
// reaches a surface inside it through neither: it would stay on screen,
|
|
868
|
+
// over whatever is shown in its place, which is what a tab that keeps its
|
|
869
|
+
// screens mounted does every time it switches.
|
|
870
|
+
this._syncSurfaces();
|
|
863
871
|
const events = this._focusManager();
|
|
864
872
|
if (!events) return;
|
|
865
873
|
if (visible) events.subtreeRevealed(this);
|
|
866
874
|
else events.subtreeHidden(this);
|
|
867
875
|
}
|
|
868
876
|
|
|
877
|
+
/**
|
|
878
|
+
* Ask every surface in this subtree — this node included — whether it is
|
|
879
|
+
* still on screen, and map or unmap it to match. Walked rather than kept
|
|
880
|
+
* in a registry because the subtree whose visibility just changed is
|
|
881
|
+
* exactly the scope, and all but two elements answer nothing.
|
|
882
|
+
*/
|
|
883
|
+
_syncSurfaces() {
|
|
884
|
+
this._syncSurfaceVisibility?.();
|
|
885
|
+
for (const child of this.children) child._syncSurfaces?.();
|
|
886
|
+
}
|
|
887
|
+
|
|
869
888
|
/**
|
|
870
889
|
* Focus this node, as clicking it would: the owning window's focus moves
|
|
871
890
|
* here, `onBlur` fires on whatever had it, `onFocus` here. Also pulls the
|
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 {
|
|
@@ -559,32 +633,50 @@ function restoreFrameClock() {
|
|
|
559
633
|
}
|
|
560
634
|
|
|
561
635
|
/**
|
|
562
|
-
* Retry `fn` until it stops throwing, flushing between attempts
|
|
636
|
+
* Retry `fn` until it stops throwing, flushing between attempts, and resolve
|
|
637
|
+
* with what it returned once that is on screen.
|
|
563
638
|
*
|
|
564
639
|
* The tool for anything whose arrival is not a single deterministic step:
|
|
565
640
|
* an async rich-content reflow, an image decode, a timer, or a repaint whose
|
|
566
|
-
* cause travelled through the X connection and back. Each
|
|
567
|
-
*
|
|
568
|
-
*
|
|
641
|
+
* cause travelled through the X connection and back. Each retry is preceded
|
|
642
|
+
* by an `act()`, so waiting also *advances* — this is not a sleep with a
|
|
643
|
+
* condition on it.
|
|
569
644
|
*
|
|
570
645
|
* ```js
|
|
571
646
|
* await waitFor(async () => {
|
|
572
647
|
* assert.notDeepStrictEqual(await pixelAt(ctx, x, y), before);
|
|
573
648
|
* });
|
|
649
|
+
* const row = await waitFor(() => screen.getByText('notes.txt'));
|
|
650
|
+
* await userEvent.click(row);
|
|
574
651
|
* ```
|
|
652
|
+
*
|
|
653
|
+
* **The attempt that passes is followed by an `act()` too.** What it found
|
|
654
|
+
* can have arrived in the sleep before it — an fs read, a timer — and that
|
|
655
|
+
* commits outside act, on React's own scheduler, while the frame that lays
|
|
656
|
+
* it out runs on the window's clock, which need not have ticked since.
|
|
657
|
+
* Without it, a node it returned could have no rect yet, and the click after
|
|
658
|
+
* it would throw `has no laid-out rect yet` (#904). It follows whichever
|
|
659
|
+
* attempt passed, the first included, since the commit can land before
|
|
660
|
+
* `waitFor` is called. And it has to come after the attempt: an `act()`
|
|
661
|
+
* before each one leaves the same gap in its own closing round trip, after
|
|
662
|
+
* the frame it ran.
|
|
575
663
|
*/
|
|
576
664
|
export async function waitFor(fn, { timeout = 2000, interval = 10 } = {}) {
|
|
577
665
|
const deadline = Date.now() + timeout;
|
|
578
666
|
let last;
|
|
579
667
|
for (;;) {
|
|
668
|
+
let found;
|
|
580
669
|
try {
|
|
581
|
-
|
|
670
|
+
found = await fn();
|
|
582
671
|
} catch (err) {
|
|
583
672
|
last = err;
|
|
673
|
+
if (Date.now() >= deadline) throw last;
|
|
674
|
+
await act();
|
|
675
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
676
|
+
continue;
|
|
584
677
|
}
|
|
585
|
-
if (Date.now() >= deadline) throw last;
|
|
586
678
|
await act();
|
|
587
|
-
|
|
679
|
+
return found;
|
|
588
680
|
}
|
|
589
681
|
}
|
|
590
682
|
|
package/src/testing/index.d.ts
CHANGED
|
@@ -174,6 +174,13 @@ export interface A11ySpy {
|
|
|
174
174
|
uninstall(): void;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Testing Library's four families over the retained node tree: `getBy*`
|
|
179
|
+
* (exactly one, or throw), `queryBy*` (one or null), `getAllBy*` (one or
|
|
180
|
+
* more), and `findBy*` — `getBy*` under {@link waitFor}, with its options and
|
|
181
|
+
* defaults, so it retries until the node appears and resolves once a frame
|
|
182
|
+
* has laid it out.
|
|
183
|
+
*/
|
|
177
184
|
export interface Queries {
|
|
178
185
|
getByText(text: string | RegExp, options?: TextMatchOptions): DrawnNode;
|
|
179
186
|
getAllByText(text: string | RegExp, options?: TextMatchOptions): DrawnNode[];
|
|
@@ -336,10 +343,23 @@ export function cleanup(): Promise<void>;
|
|
|
336
343
|
*/
|
|
337
344
|
export function setAppearance(values: PinnedAppearance): Promise<void>;
|
|
338
345
|
|
|
339
|
-
/**
|
|
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
|
+
*/
|
|
340
351
|
export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
|
|
341
352
|
|
|
342
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* Retry until it stops throwing, `act`-ing between attempts — and once more
|
|
355
|
+
* after the attempt that passed, so what it resolves with is on screen: a
|
|
356
|
+
* node it found has been laid out, and a click on it lands.
|
|
357
|
+
*
|
|
358
|
+
* ```ts
|
|
359
|
+
* const row = await waitFor(() => screen.getByText('notes.txt'));
|
|
360
|
+
* await userEvent.click(row);
|
|
361
|
+
* ```
|
|
362
|
+
*/
|
|
343
363
|
export function waitFor<T>(
|
|
344
364
|
fn: () => T | Promise<T>,
|
|
345
365
|
options?: WaitOptions,
|
|
@@ -445,8 +465,14 @@ export interface KeyOptions {
|
|
|
445
465
|
export const fireEvent: {
|
|
446
466
|
mouseMove(node: DrawnNode, options?: PointerOptions): void;
|
|
447
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. */
|
|
448
471
|
mouseLeave(node: DrawnNode, options?: PointerOptions): void;
|
|
449
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. */
|
|
450
476
|
mouseUp(node: DrawnNode, options?: PointerOptions): void;
|
|
451
477
|
click(node: DrawnNode, options?: PointerOptions): void;
|
|
452
478
|
doubleClick(node: DrawnNode, options?: PointerOptions): void;
|
|
@@ -475,6 +501,7 @@ export const userEvent: {
|
|
|
475
501
|
click(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
476
502
|
doubleClick(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
477
503
|
hover(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
504
|
+
/** `fireEvent.mouseLeave`, flushed: aims at nothing. */
|
|
478
505
|
unhover(node: DrawnNode, options?: PointerOptions): Promise<void>;
|
|
479
506
|
/** A scroll in notches; `smooth` takes fractions of one, as a touchpad
|
|
480
507
|
* measures them. */
|
|
@@ -501,7 +528,12 @@ export const userEvent: {
|
|
|
501
528
|
}): Promise<void>;
|
|
502
529
|
};
|
|
503
530
|
|
|
504
|
-
/**
|
|
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
|
+
*/
|
|
505
537
|
export function screenPointOf(
|
|
506
538
|
node: DrawnNode,
|
|
507
539
|
options?: { dx?: number; dy?: number },
|
package/src/testing/queries.js
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
// getBy* exactly one, or throw
|
|
13
13
|
// queryBy* one or null, never throws — the way to assert absence
|
|
14
14
|
// getAllBy* one or more, or throw
|
|
15
|
-
// findBy* getBy
|
|
15
|
+
// findBy* getBy* under waitFor: retried until it appears or the timeout
|
|
16
|
+
// expires, and laid out by the time it resolves
|
|
16
17
|
|
|
17
18
|
import { queryAllByComponent, componentInventory } from './components.js';
|
|
19
|
+
import { waitFor } from './harness.js';
|
|
18
20
|
import { roleNameOf } from '../a11y.js';
|
|
19
21
|
|
|
20
|
-
const DEFAULT_TIMEOUT = 1000;
|
|
21
|
-
|
|
22
22
|
/** Depth-first, in paint order, including `<popup>` subtrees. */
|
|
23
23
|
function walk(node, visit) {
|
|
24
24
|
if (!node || node.destroyed) return;
|
|
@@ -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,24 +106,10 @@ 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
|
|
|
100
|
-
async function retry(fn, { timeout = DEFAULT_TIMEOUT, interval = 20 } = {}) {
|
|
101
|
-
const deadline = Date.now() + timeout;
|
|
102
|
-
let last;
|
|
103
|
-
for (;;) {
|
|
104
|
-
try {
|
|
105
|
-
return fn();
|
|
106
|
-
} catch (err) {
|
|
107
|
-
last = err;
|
|
108
|
-
if (Date.now() >= deadline) throw last;
|
|
109
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
113
|
/**
|
|
115
114
|
* The queries, bound to a subtree. `within(node)` is this with a different
|
|
116
115
|
* root; `screen` is this bound to whatever `renderX11` mounted last.
|
|
@@ -199,9 +198,12 @@ export function within(root) {
|
|
|
199
198
|
if (found.length > 1) return one(root, found, label(args[0]));
|
|
200
199
|
return found[0] ?? null;
|
|
201
200
|
};
|
|
201
|
+
// Through waitFor rather than a loop of its own: what this resolves with
|
|
202
|
+
// has to be laid out, or a click on it throws, and waitFor is what runs
|
|
203
|
+
// the frame before resolving
|
|
202
204
|
q[`find${suffix}`] = (...args) => {
|
|
203
205
|
const options = args.length > 1 ? args[args.length - 1] : undefined;
|
|
204
|
-
return
|
|
206
|
+
return waitFor(() => q[`get${suffix}`](...args), options);
|
|
205
207
|
};
|
|
206
208
|
}
|
|
207
209
|
return q;
|