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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.45.0",
3
+ "version": "2.45.2",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -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: false,
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
@@ -488,18 +488,21 @@ export class ForeignNode extends Node {
488
488
  wnd.setBackgroundPixel(pixel);
489
489
  }
490
490
 
491
- setHidden(hidden) {
492
- super.setHidden(hidden);
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 `hidden`; the client follows the container,
497
- * because an inferior of an unmapped window is not viewable however
498
- * mapped it is itself. */
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.hidden) wnd.unmap?.();
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
- setHidden(hidden) {
729
- super.setHidden(hidden);
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
@@ -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
- if (!win?.window) {
29
- throw new Error(
30
- 'react-x11/test: the target is not inside a realized window — did you ' +
31
- 'await renderX11(), or has it unmounted?',
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
- const server = rootWindow(node)?.app?._reactX11TestServer;
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
- /** Move the pointer off the node, which is what produces a leave. */
146
- mouseLeave(node, options = {}) {
147
- const { server } = screenPointOf(node, options);
148
- server.injectPointerMove(0, 0);
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
- const { server } = screenPointOf(node, options);
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. */
@@ -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
- // resolve either way: a closing connection must not hang a test
69
- X.GetInputFocus(() => resolve());
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
- /** Run the frame the scheduler would have run, for every window in a root. */
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
- entry.root.unmount?.();
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
- if (entry.ownsApp) {
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 attempt is
567
- * preceded by an `act()`, so waiting also *advances* — this is not a sleep
568
- * with a condition on it.
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
- return await fn();
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
- await new Promise((resolve) => setTimeout(resolve, interval));
679
+ return found;
588
680
  }
589
681
  }
590
682
 
@@ -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
- /** Drain in-flight requests on a connection. */
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
- /** Retry until it stops throwing, `act`-ing between attempts. */
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
- /** A node's centre in screen coordinates. */
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 },
@@ -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*, retried until it appears or the timeout expires
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
- function describe(node) {
70
- const text = textOf(node).trim();
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
- `<${node.kind}${name ? ` data-testname="${name}"` : ''}>` +
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(` ${describe(n)}`);
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) => ` ${describe(n)}`).join('\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 retry(() => q[`get${suffix}`](...args), options);
206
+ return waitFor(() => q[`get${suffix}`](...args), options);
205
207
  };
206
208
  }
207
209
  return q;