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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.45.1",
3
+ "version": "2.45.2",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -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 {
@@ -343,7 +343,11 @@ export function cleanup(): Promise<void>;
343
343
  */
344
344
  export function setAppearance(values: PinnedAppearance): Promise<void>;
345
345
 
346
- /** 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
+ */
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
- /** 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
+ */
521
537
  export function screenPointOf(
522
538
  node: DrawnNode,
523
539
  options?: { dx?: number; dy?: number },
@@ -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,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) => ` ${describe(n)}`).join('\n'),
109
+ found.map((n) => ` ${describeNode(n)}`).join('\n'),
97
110
  );
98
111
  }
99
112