@solidrt/test 0.0.0-stage → 0.0.66

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/AGENTS.md ADDED
@@ -0,0 +1,72 @@
1
+ # @solidrt/test - agent notes
2
+
3
+ Tests for a SolidRT app: `test` registers one and hands it the app under
4
+ test, `expect` asserts. A devDependency of every app and a peer of
5
+ @solidrt/core. This file is the API; the method (what to test at which
6
+ layer, when to `settle`, how to read a failure) is @solidrt/cli
7
+ agents/testing.md.
8
+
9
+ - A test file is `tests/<name>.test.tsx`, run with `bun run sol test`. It
10
+ imports `test` and `expect` from `@solidrt/test`; every test gets
11
+ the app under test and an engine of its own:
12
+
13
+ ```tsx
14
+ import { test, expect } from "@solidrt/test"
15
+
16
+ test("a tap increments", async app => {
17
+ let counter = await app.mount(() => <Counter />)
18
+ await app.tap(counter.find({ text: "Increment" }))
19
+ expect(counter.find({ text: "1" }).visible).toBe(true)
20
+ })
21
+ ```
22
+
23
+ - `app.mount(ui)` takes what `render` takes (content is put in a window of
24
+ its own) and returns a locator for the window; `app.load(() =>
25
+ import("../src/index.tsx"))` starts a whole entry instead. Once per test.
26
+ - Name a node by what the user sees, `find({ text: "Save" })`; by its
27
+ `label` prop where it shows no text, `find({ label: "sidebar" })`
28
+ (`label` is on every element, a stable name and nothing else); or hold a
29
+ ref, `let box = app.ref()` with `<view ref={box} />`. A string matches
30
+ exactly, a RegExp a part; `kind` narrows. A locator is resolved each time
31
+ it is used and names exactly one node: reading through one that matches
32
+ none or several throws (the message lists the texts and labels that are
33
+ there), `findAll` returns them all, `.exists` never throws.
34
+ - Read `.text`, `.box` (painted, window coordinates), `.props` (off-default,
35
+ JSX names), `.visible` (painted: not clipped away by a scrolling or
36
+ hidden-overflow ancestor or the window, not faded to 0, mounted) and
37
+ `.record` (the `/tree` record).
38
+ - Input is the real pipeline: `app.tap(locator)`, `app.drag(from, to, {
39
+ durationMs })`, `app.key("Enter")`, `app.type("text")` (to the focused
40
+ node), `app.input([...])` for raw events in the `send_input` shape. A tap
41
+ on a node that something covers at its center throws and names the
42
+ cover; a point (`{ x, y }`) lands on whatever is there. Each verb returns
43
+ after the frame its last event landed in, so the tree is current.
44
+ - There is no wall clock: time passes only by `app.frame(n)` and
45
+ `app.advance(ms)`, timers fire with the frame their time falls in,
46
+ `performance.now()` is 0 and the date starts at 2000-01-01 UTC and moves
47
+ with the frames. Nothing waits and nothing is retried. `{ fps: 1000 }`
48
+ as the third argument of `test` makes a frame one millisecond.
49
+ - `await app.settle()` runs the app until it is at rest: what it started
50
+ (a fetch, a file read, a query) has landed, no timer is due and no frame
51
+ is demanded. Use it after an action that loads or animates, in place of
52
+ a guessed `frame(n)`: a transition is played to its end. A timer due
53
+ later is not waited for (`advance` reaches it). An app that never rests
54
+ (an `onFrame` loop, a looping animation) fails it after 5000 ms of app
55
+ time (`{ maxMs }` for another cap), naming what still wanted frames.
56
+ - `await app.link(link)` delivers a link as the OS does (throws when the
57
+ app has no `onLink`); `await app.debug(name, args)` calls a command the
58
+ app registered with `registerDebug` and returns its value: the way to
59
+ put a loaded app into a state its UI reaches slowly. A `gamepad` event
60
+ in `app.input` seats and drives a synthetic pad, as `send_input` does.
61
+ - Past a node's record: `locator.outline()` is the subtree as an outline
62
+ (kind, label, text, box per line, no ids) to pin a layout or print it;
63
+ `locator.pixel(x, y)` is `[r, g, b, a]` at a point of the node and
64
+ `locator.pixels()` the whole image, drawn now with no time passing (the
65
+ last resort, for what only the picture shows); `app.gpu({ label })` is
66
+ the GPU inventory `/gpu` reports, where "does it draw" is a count.
67
+ - The data folder and the fetch cache are empty at the start of every
68
+ test, and no gamepad is seated.
69
+ - A failure prints the app time and frame, what still demanded frames,
70
+ the whole outline and a snapshot's path under `dist/test/` beside the
71
+ error: read them before changing the test. The method (what to test at
72
+ which layer, when to `settle`) is @solidrt/cli agents/testing.md.
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "name": "@solidrt/test",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.66",
4
+ "license": "MIT",
5
+ "funding": "https://github.com/sponsors/wellawaretech",
6
+ "author": "Antoine van Wel",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "files": [
13
+ "src/",
14
+ "AGENTS.md"
15
+ ],
16
+ "devDependencies": {
17
+ "@solidrt/flux-types": "0.0.66"
18
+ },
19
+ "peerDependencies": {
20
+ "@solidrt/core": "0.0.66"
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,550 @@
1
+ // Tests for a SolidRT app: `test` registers one and hands it the app under
2
+ // test, `expect` asserts. The base is flux:test (flat tests, every test in
3
+ // an engine of its own, a seeded Math.random); what this adds is the app
4
+ // layer, on the dev client's test mode (okf/done/test-harness.md).
5
+ //
6
+ // An app test has no wall clock. Time is the frames the test asks for:
7
+ // nothing runs between two frames, timers fire with the frame their time
8
+ // falls in, performance.now() reads 0 and the calendar starts at a fixed
9
+ // epoch and moves with the frames. So a test does the same on every run
10
+ // and never waits.
11
+ //
12
+ // Nodes are named the way a user or an agent names them: by the text they
13
+ // show, by a `label`, or by a ref the test holds. Input goes through the
14
+ // real pipeline (hit test, recognizers, focus) in the control API's event
15
+ // shape, so what an agent did by hand over MCP transcribes into a test.
16
+
17
+ import { test as register, expect } from "flux:test"
18
+ import * as gui from "flux:test/gui"
19
+ import { capture, debug, frame as stepFrame, frameRate, inputPlan, inputStep, link, setFrameRate, settle, time, windowReady } from "sol:test"
20
+ import { createElement, insert, render } from "@solidrt/core"
21
+
22
+ export { expect }
23
+
24
+ // How far below a whole frame count a time still rounds down to it:
25
+ // ms * fps / 1000 of an exact multiple can land a rounding error above.
26
+ const FRAME_COUNT_EPSILON = 1e-9
27
+ // How many nodes one query returns at most. Far more than a test means to
28
+ // name at once; a query that reaches it is reported as such.
29
+ const FIND_LIMIT = 1000
30
+ // How many of the texts and labels in scope a failed `find` lists, so the
31
+ // message says what was there instead without printing a whole screen.
32
+ const FOUND_INSTEAD_MAX = 12
33
+ // How much app time `settle` gives an app to come to rest unless the call
34
+ // says otherwise: far past any transition an app runs, short enough that a
35
+ // frame callback that never stops fails promptly.
36
+ const SETTLE_MAX_MS = 5000
37
+ // Bytes per pixel of a capture.
38
+ const RGBA = 4
39
+
40
+ /** A window point, in logical pixels. */
41
+ export interface Point {
42
+ x: number
43
+ y: number
44
+ }
45
+
46
+ /**
47
+ * What a node is named by. Every field given has to hold. A string matches
48
+ * exactly; a RegExp matches a part.
49
+ */
50
+ export interface Query {
51
+ /** The text a text node shows. */
52
+ text?: string | RegExp
53
+ /** The node's `label` prop. */
54
+ label?: string | RegExp
55
+ /** The element kind: "view", "text", "rect", ... */
56
+ kind?: string
57
+ }
58
+
59
+ /**
60
+ * A node as the control API's `/tree` reports it (the fields are documented
61
+ * with it, in the debugging guide of `@solidrt/cli`): the painted box in
62
+ * window coordinates, the text and label where there are any, and the
63
+ * off-default props under their JSX names.
64
+ */
65
+ export interface NodeRecord {
66
+ id: number
67
+ kind: string
68
+ x: number
69
+ y: number
70
+ width: number
71
+ height: number
72
+ text?: string
73
+ label?: string
74
+ props?: Record<string, unknown>
75
+ children?: NodeRecord[]
76
+ [field: string]: unknown
77
+ }
78
+
79
+ /**
80
+ * A name for a node, resolved each time it is used, so it stays valid while
81
+ * the tree changes. Reading or acting through a locator that names no node,
82
+ * or more than one, throws: `findAll` is for the plural case.
83
+ */
84
+ export interface Locator {
85
+ /** The node this names within this one's subtree. */
86
+ find(query: Query): Locator
87
+ /** Every node the query matches within this one's subtree, as it is now. */
88
+ findAll(query: Query): Locator[]
89
+ /** Whether this names exactly one node right now. Never throws. */
90
+ readonly exists: boolean
91
+ /**
92
+ * Whether the node is painted where a user could see it: mounted, its
93
+ * painted box not cut away by a clipping ancestor or the window, and not
94
+ * faded out. False when the node is not there. It says nothing of what
95
+ * is painted over it; a tap does (it fails on a covered node).
96
+ */
97
+ readonly visible: boolean
98
+ /** The text the node shows; undefined for a node that is no text. */
99
+ readonly text: string | undefined
100
+ /** The painted box, in window coordinates. */
101
+ readonly box: { x: number; y: number; width: number; height: number }
102
+ /** The node's off-default props, under their JSX names. */
103
+ readonly props: Record<string, unknown>
104
+ /** The node's whole record. */
105
+ readonly record: NodeRecord
106
+ /**
107
+ * The subtree as an outline: one line per node, indented by depth, with
108
+ * its kind, label, text and painted box (`view [save] 20,50 120x40`). No
109
+ * ids, so it reads the same on every run and diffs cleanly: compare it
110
+ * to a string the test holds to pin a whole layout, or print it to see
111
+ * what is there. `{ props: true }` adds each node's off-default props. A
112
+ * failed test prints the whole app this way.
113
+ */
114
+ outline(options?: { props?: boolean }): string
115
+ /**
116
+ * The pixels the node paints, its subtree included. Drawn now, from the
117
+ * tree as it is: no frame runs and no app time passes, so a transition
118
+ * is read where it stands. The last resort of a test, for what only the
119
+ * picture shows (a shader, a gradient, a blend): a node's text, box and
120
+ * props say the rest, cheaper and on any GPU.
121
+ */
122
+ pixels(): Pixels
123
+ /**
124
+ * The color at a point of the node, in its own coordinates (0, 0 is the
125
+ * top left of its painted box): `[r, g, b, a]`, 0 to 255. See `pixels`.
126
+ *
127
+ * @example
128
+ * expect(swatch.pixel(10, 10)).toEqual([255, 0, 0, 255])
129
+ */
130
+ pixel(x: number, y: number): [r: number, g: number, b: number, a: number]
131
+ }
132
+
133
+ /**
134
+ * Pixels read back from the GPU: RGBA8, rows top to bottom, alpha
135
+ * premultiplied (a half-transparent red reads `[128, 0, 0, 128]`), at the
136
+ * display scale. The shape `captureSnapshot` and `readTexture` return.
137
+ */
138
+ export interface Pixels {
139
+ width: number
140
+ height: number
141
+ data: Uint8Array
142
+ }
143
+
144
+ /** A locator that is also the `ref` of the node it names: `<view ref={it} />`. */
145
+ export type RefLocator = Locator & ((node: { id: number }) => void)
146
+
147
+ /** One event in the control API's `/input` shape. */
148
+ export type InputEvent = Record<string, unknown> & { type: "pointer" | "key" | "wheel" | "text" | "gamepad" }
149
+
150
+ export interface PointerOptions {
151
+ /** "mouse" (the default) or "touch". */
152
+ pointerType?: "mouse" | "touch"
153
+ /** The mouse button, 0 (left, the default) to 4. */
154
+ button?: number
155
+ shift?: boolean
156
+ ctrl?: boolean
157
+ alt?: boolean
158
+ meta?: boolean
159
+ }
160
+
161
+ /** The app under test, as a test's function receives it. */
162
+ export interface TestApp {
163
+ /**
164
+ * Mounts `ui` and returns a locator for the window it is in. `ui` is what
165
+ * an app hands `render`: its `<window>`, or any content, which is then
166
+ * put in a window of its own. Once per test (a test is one app). The
167
+ * first frame is built when this returns; no app time has passed.
168
+ * `ui` runs as a component body, the owner of what it creates: a
169
+ * pointer feed, an input map, a GPU buffer or a scene is created inside
170
+ * it, never at the top of the test.
171
+ */
172
+ mount(ui: () => unknown): Promise<Locator>
173
+ /**
174
+ * Starts the app an entry module is, the way the runtime starts it: pass
175
+ * a function that imports the entry, whose top level calls `render`.
176
+ * Returns a locator for its window, the first frame built. In place of
177
+ * `mount`, once per test; every test gets the entry evaluated afresh.
178
+ *
179
+ * @example
180
+ * let root = await app.load(() => import("../src/index.tsx"))
181
+ */
182
+ load(entry: () => Promise<unknown>): Promise<Locator>
183
+ /**
184
+ * Delivers a link to the app the way the OS delivers one (the raw string,
185
+ * on `onLink`) and runs the frame it lands in. Throws when the app does
186
+ * not listen for links. What the link opens may load or animate:
187
+ * `settle()` after it.
188
+ */
189
+ link(link: string): Promise<void>
190
+ /**
191
+ * Calls a debug command the app registered (`registerDebug` of
192
+ * `sol:dev`), runs a frame, and returns what the command returned. The
193
+ * way to put a loaded app into a state its UI reaches slowly or not at
194
+ * all: the same commands an agent calls over MCP. Throws on an unknown
195
+ * name, listing the registered ones.
196
+ */
197
+ debug(name: string, args?: unknown): Promise<unknown>
198
+ /**
199
+ * The GPU resource inventory, as the control API's `/gpu` reports it:
200
+ * textures, buffers, pipelines and every draw target's entries with
201
+ * their counts and uniforms. `label` keeps the resources created with
202
+ * that label; `draw` names the draw entry reported in full. "Does it
203
+ * draw, and with what" is read here, not off pixels.
204
+ */
205
+ gpu(options?: { label?: string; draw?: number }): Record<string, unknown>
206
+ /** The node the query names, anywhere in the app. */
207
+ find(query: Query): Locator
208
+ /** Every node the query matches, as the tree is now. */
209
+ findAll(query: Query): Locator[]
210
+ /**
211
+ * A locator for a node the test's own JSX creates, which is also its ref:
212
+ * `let box = app.ref()`, then `<view ref={box} />` and `box.box.width`.
213
+ */
214
+ ref(): RefLocator
215
+ /**
216
+ * Runs `count` frames (default 1), one after another. A frame fires the
217
+ * timers due by its time, runs the frame callbacks and the flush, and
218
+ * draws if anything demanded a draw.
219
+ */
220
+ frame(count?: number): Promise<void>
221
+ /**
222
+ * Runs frames until `ms` of app time have passed: as many as it takes to
223
+ * cover it, so at 60 fps `advance(100)` is 6 frames. The frame is the
224
+ * time resolution of a test: a timer due at 50 ms fires with the frame at
225
+ * 50 ms, one due at 55 ms with the frame at 66.7 ms.
226
+ */
227
+ advance(ms: number): Promise<void>
228
+ /** App time as of the last frame, in milliseconds; 0 when the test starts. */
229
+ readonly time: number
230
+ /**
231
+ * Runs the app until it is at rest: nothing it started is still in flight
232
+ * (a fetch, a file read, a query), no timer is due, and no frame is
233
+ * demanded. Work in flight is waited for with no app time passing; a
234
+ * demanded frame and a timer that is due are run, so a running transition
235
+ * is played to its end and the test reads the end state. A timer due
236
+ * later is not waited for: `advance` reaches it. What stands (a server,
237
+ * an open socket) is not waited for either.
238
+ *
239
+ * Throws once `maxMs` of app time (default 5000) have passed without
240
+ * rest, naming what is left: a frame callback that never stops, a looping
241
+ * animation, a playing video.
242
+ *
243
+ * @example
244
+ * await app.tap(app.find({ text: "Load" }))
245
+ * await app.settle()
246
+ * expect(app.find({ label: "rows" }).findAll({ kind: "text" }).length).toBe(20)
247
+ */
248
+ settle(options?: { maxMs?: number }): Promise<void>
249
+ /**
250
+ * A tap at the node's painted center, or at a point: down, a frame (and
251
+ * `holdMs`, if given), up, and one frame more, so the tree is current
252
+ * when this returns. A mouse is moved there a frame ahead. Naming a node
253
+ * that something else covers at that point throws, saying what is there;
254
+ * a point lands on whatever is there.
255
+ */
256
+ tap(target: Locator | Point, options?: PointerOptions & { holdMs?: number }): Promise<void>
257
+ /**
258
+ * A drag from one node's center (or point) to another's: down, one move
259
+ * per frame along the line over `durationMs` (default 300), up.
260
+ */
261
+ drag(from: Locator | Point, to: Locator | Point, options?: PointerOptions & { durationMs?: number }): Promise<void>
262
+ /** A key pressed and released a frame (and `holdMs`) later, by its W3C key name ("Enter", "a"). */
263
+ key(key: string, options?: { holdMs?: number; shift?: boolean; ctrl?: boolean; alt?: boolean; meta?: boolean }): Promise<void>
264
+ /** Text as an input method commits it, to the focused node. */
265
+ type(text: string): Promise<void>
266
+ /**
267
+ * Events in the control API's `/input` shape, as `send_input` takes them.
268
+ * `delayMs` and `holdMs` are app time: frames run to cover them. One
269
+ * frame runs after the last event.
270
+ */
271
+ input(events: InputEvent[]): Promise<void>
272
+ }
273
+
274
+ export interface TestOptions {
275
+ /**
276
+ * The frame rate the test steps at, a positive integer (default 60). A
277
+ * test that asserts a threshold to the millisecond sets 1000.
278
+ */
279
+ fps?: number
280
+ }
281
+
282
+ // -- Locators --
283
+
284
+ function describeQuery(query: Query): string {
285
+ let parts = Object.entries(query).map(([name, value]) => `${name}: ${value instanceof RegExp ? value : JSON.stringify(value)}`)
286
+ return `{ ${parts.join(", ")} }`
287
+ }
288
+
289
+ function describeNode(record: NodeRecord): string {
290
+ if (record.text !== undefined) return `${record.kind} ${JSON.stringify(record.text)}`
291
+ if (record.label !== undefined) return `${record.kind} labelled ${JSON.stringify(record.label)}`
292
+ return `${record.kind} #${record.id}`
293
+ }
294
+
295
+ function field(value: string | RegExp | undefined): string | true | undefined {
296
+ return value instanceof RegExp ? true : value
297
+ }
298
+
299
+ function holds(value: string | RegExp | undefined, actual: string | undefined): boolean {
300
+ return !(value instanceof RegExp) || (actual !== undefined && value.test(actual))
301
+ }
302
+
303
+ // The records the query matches under `root` (null: the whole app).
304
+ function search(root: number | null, query: Query): NodeRecord[] {
305
+ if (query === null || typeof query !== "object") throw new TypeError("find: the query must be an object, like { text: \"Save\" }")
306
+ let names = Object.keys(query)
307
+ let unknown = names.find(name => name !== "text" && name !== "label" && name !== "kind")
308
+ if (unknown !== undefined) throw new TypeError(`find: unknown query field "${unknown}"; a node is named by text, label or kind`)
309
+ if (names.length === 0) throw new TypeError("find: the query names nothing; give it a text, a label or a kind")
310
+ let found = gui.find(root, { kind: query.kind, text: field(query.text), label: field(query.label) }, FIND_LIMIT)
311
+ if (found === null) return []
312
+ let records = JSON.parse(found) as NodeRecord[]
313
+ if (records.length === FIND_LIMIT) throw new Error(`find: ${describeQuery(query)} matches ${FIND_LIMIT} nodes or more; name fewer`)
314
+ return records.filter(record => holds(query.text, record.text) && holds(query.label, record.label))
315
+ }
316
+
317
+ // What a failed find says was there instead: the texts and labels in scope.
318
+ function foundInstead(root: number | null): string {
319
+ let texts = gui.find(root, { text: true }, FOUND_INSTEAD_MAX)
320
+ let labels = gui.find(root, { label: true }, FOUND_INSTEAD_MAX)
321
+ let parts: string[] = []
322
+ let list = (json: string | null, pick: (record: NodeRecord) => string | undefined) =>
323
+ (json === null ? [] : (JSON.parse(json) as NodeRecord[])).map(record => JSON.stringify(pick(record))).join(", ")
324
+ let shown = list(texts, record => record.text)
325
+ let named = list(labels, record => record.label)
326
+ if (shown !== "") parts.push(`texts there: ${shown}`)
327
+ if (named !== "") parts.push(`labels there: ${named}`)
328
+ return parts.length === 0 ? "nothing with a text or a label is there" : parts.join("; ")
329
+ }
330
+
331
+ // A locator over `resolve`, which answers the ids it names right now.
332
+ // `what` says how it names them, for messages; `scope` is where a failed
333
+ // find looks for what was there instead.
334
+ function locatorFields(resolve: () => number[], what: string, scope: () => number | null): PropertyDescriptorMap {
335
+ let one = (): number => {
336
+ let ids = resolve()
337
+ if (ids.length === 1) return ids[0]!
338
+ if (ids.length === 0) throw new Error(`No node matches ${what}; ${foundInstead(scope())}`)
339
+ throw new Error(`${ids.length} nodes match ${what}; a locator names one node (findAll returns them all)`)
340
+ }
341
+ let record = (): NodeRecord => {
342
+ let id = one()
343
+ let json = gui.node(id, 0)
344
+ if (json === null) throw new Error(`The node ${what} named is gone`)
345
+ return JSON.parse(json) as NodeRecord
346
+ }
347
+ return {
348
+ find: { value: (query: Query) => findIn(() => one(), query) },
349
+ findAll: { value: (query: Query) => search(one(), query).map(found => fixed(found.id, `${describeQuery(query)} (${describeNode(found)})`)) },
350
+ exists: { get: () => resolve().length === 1 },
351
+ visible: {
352
+ get: () => {
353
+ let ids = resolve()
354
+ if (ids.length > 1) one()
355
+ return ids.length === 1 && gui.visible(ids[0]!)
356
+ },
357
+ },
358
+ text: { get: () => record().text },
359
+ box: {
360
+ get: () => {
361
+ let { x, y, width, height } = record()
362
+ return { x, y, width, height }
363
+ },
364
+ },
365
+ props: { get: () => record().props ?? {} },
366
+ record: { get: record },
367
+ outline: {
368
+ value: (options: { props?: boolean } = {}) => {
369
+ let text = gui.outline(one(), options.props === true)
370
+ if (text === null) throw new Error(`The node ${what} named is gone`)
371
+ return text
372
+ },
373
+ },
374
+ pixels: { value: () => capture(one()) },
375
+ pixel: {
376
+ value: (x: number, y: number) => {
377
+ let { width, height } = record()
378
+ if (!(x >= 0 && x < width && y >= 0 && y < height)) throw new RangeError(`pixel: ${x}, ${y} is outside ${what}, which is ${width} x ${height}`)
379
+ let image = capture(one())
380
+ let column = Math.min(image.width - 1, Math.floor((x * image.width) / width))
381
+ let row = Math.min(image.height - 1, Math.floor((y * image.height) / height))
382
+ let at = (row * image.width + column) * RGBA
383
+ return Array.from(image.data.subarray(at, at + RGBA))
384
+ },
385
+ },
386
+ }
387
+ }
388
+
389
+ function locator(resolve: () => number[], what: string, scope: () => number | null): Locator {
390
+ return Object.defineProperties({}, locatorFields(resolve, what, scope)) as Locator
391
+ }
392
+
393
+ // A locator for one node by id, which stays that node: gone once it is.
394
+ function fixed(id: number, what: string): Locator {
395
+ return locator(() => (gui.node(id, 0) === null ? [] : [id]), what, () => null)
396
+ }
397
+
398
+ // A locator for what `query` names under `root` (null: the whole app).
399
+ function findIn(root: () => number | null, query: Query): Locator {
400
+ // Checked now, so a malformed query fails where it is written.
401
+ search(null, query)
402
+ return locator(() => search(root(), query).map(found => found.id), describeQuery(query), root)
403
+ }
404
+
405
+ // -- Input --
406
+
407
+ function isLocator(target: Locator | Point): target is Locator {
408
+ return "record" in target
409
+ }
410
+
411
+ // Where a gesture on `target` lands, and that it lands on it: the center of
412
+ // a node's painted box, which must reach the node when it is hit tested.
413
+ function pointOf(target: Locator | Point, verb: string): Point {
414
+ if (!isLocator(target)) return target
415
+ let record = target.record
416
+ let at = { x: record.x + record.width / 2, y: record.y + record.height / 2 }
417
+ if (!gui.visible(record.id)) throw new Error(`${verb}: ${describeNode(record)} is not visible (its painted box is ${record.width} x ${record.height} at ${record.x}, ${record.y})`)
418
+ let hit = gui.hit(at.x, at.y)
419
+ let landed = hit[hit.length - 1]
420
+ let own = gui.path(record.id) ?? []
421
+ // Reaching the node, something inside it, or the container it sits in
422
+ // (a node that takes no pointer events itself) is reaching it.
423
+ if (landed === undefined) throw new Error(`${verb}: nothing takes a pointer at ${at.x}, ${at.y}, the center of ${describeNode(record)}`)
424
+ if (!hit.includes(record.id) && !own.includes(landed)) {
425
+ let cover = gui.node(landed, 0)
426
+ let there = cover === null ? `node #${landed}` : describeNode(JSON.parse(cover) as NodeRecord)
427
+ throw new Error(`${verb}: ${describeNode(record)} is covered at its center (${at.x}, ${at.y}) by ${there}`)
428
+ }
429
+ return at
430
+ }
431
+
432
+ function framesFor(ms: number): number {
433
+ return Math.ceil((ms * frameRate()) / 1000 - FRAME_COUNT_EPSILON)
434
+ }
435
+
436
+ // -- The app --
437
+
438
+ let mounted = false
439
+
440
+ let app: TestApp = {
441
+ async mount(ui) {
442
+ if (typeof ui !== "function") throw new TypeError("mount: pass a function that returns the UI, like () => <Counter />")
443
+ if (mounted) throw new Error("mount: this test already mounted its app; a test is one app")
444
+ mounted = true
445
+ let root = 0
446
+ render(() => {
447
+ let content = ui() as { id: number; elementType?: string } | undefined
448
+ if (content && content.elementType === "window") {
449
+ root = content.id
450
+ return content
451
+ }
452
+ let win = createElement("window")
453
+ insert(win, () => content)
454
+ root = win.id
455
+ return win
456
+ })
457
+ // The window builds its first frame on its size, which the engine may
458
+ // not have been told yet.
459
+ await windowReady()
460
+ return fixed(root, "the mounted window")
461
+ },
462
+ async load(entry) {
463
+ if (typeof entry !== "function") throw new TypeError("load: pass a function that imports the entry, like () => import(\"../src/index.tsx\")")
464
+ if (mounted) throw new Error("load: this test already mounted its app; a test is one app")
465
+ mounted = true
466
+ await entry()
467
+ await windowReady()
468
+ let windows = search(null, { kind: "window" })
469
+ if (windows.length === 0) throw new Error("load: the entry rendered no window; its top level has to call render()")
470
+ return fixed(windows[0]!.id, "the app's window")
471
+ },
472
+ async link(target) {
473
+ if (typeof target !== "string" || target === "") throw new TypeError("link: pass the link as a non-empty string")
474
+ if (!link(target)) throw new Error(`link: the app does not listen for links (no onLink handler), so ${JSON.stringify(target)} went nowhere`)
475
+ await app.frame()
476
+ },
477
+ async debug(name, args) {
478
+ let result = debug(name, args === undefined ? null : JSON.stringify(args))
479
+ await app.frame()
480
+ return JSON.parse(result)
481
+ },
482
+ gpu: (options = {}) => JSON.parse(gui.gpu(options.label ?? null, options.draw ?? null)),
483
+ find: query => findIn(() => null, query),
484
+ findAll: query => search(null, query).map(found => fixed(found.id, `${describeQuery(query)} (${describeNode(found)})`)),
485
+ ref() {
486
+ let id: number | null = null
487
+ let ref = (node: { id: number }) => {
488
+ id = node.id
489
+ }
490
+ let resolve = () => {
491
+ if (id === null) throw new Error("This ref names no node yet: put it on an element (ref={it}) and mount that")
492
+ return gui.node(id, 0) === null ? [] : [id]
493
+ }
494
+ return Object.defineProperties(ref, locatorFields(resolve, "the ref", () => null)) as RefLocator
495
+ },
496
+ async frame(count = 1) {
497
+ if (!Number.isInteger(count) || count < 0) throw new TypeError(`frame: the count must be a non-negative integer, got ${count}`)
498
+ for (let i = 0; i < count; i++) await stepFrame()
499
+ },
500
+ advance(ms) {
501
+ if (typeof ms !== "number" || !(ms >= 0) || !Number.isFinite(ms)) throw new TypeError(`advance: the time must be a non-negative number of milliseconds, got ${ms}`)
502
+ return app.frame(framesFor(ms))
503
+ },
504
+ get time() {
505
+ return time()
506
+ },
507
+ settle(options = {}) {
508
+ let maxMs = options.maxMs ?? SETTLE_MAX_MS
509
+ if (typeof maxMs !== "number" || !(maxMs >= 0) || !Number.isFinite(maxMs)) throw new TypeError(`settle: maxMs must be a non-negative number of milliseconds, got ${maxMs}`)
510
+ return settle(maxMs)
511
+ },
512
+ tap(target, options = {}) {
513
+ let at = pointOf(target, "tap")
514
+ return app.input([{ type: "pointer", action: "tap", ...at, ...options }])
515
+ },
516
+ drag(from, to, options = {}) {
517
+ let start = pointOf(from, "drag")
518
+ let end = isLocator(to) ? pointOf(to, "drag") : to
519
+ return app.input([{ type: "pointer", action: "drag", ...start, to: end, ...options }])
520
+ },
521
+ key(key, options = {}) {
522
+ return app.input([{ type: "key", action: "tap", key, ...options }])
523
+ },
524
+ type(text) {
525
+ return app.input([{ type: "text", text }])
526
+ },
527
+ async input(events) {
528
+ let waits = inputPlan(JSON.stringify(events))
529
+ for (let i = 0; i < waits.length; i++) {
530
+ let [ms, frames] = waits[i]!
531
+ await app.frame(Math.max(frames, framesFor(ms)))
532
+ inputStep(i)
533
+ }
534
+ // The frame the last event lands in: the tree is current after it.
535
+ await app.frame()
536
+ },
537
+ }
538
+
539
+ /**
540
+ * Registers an app test. `fn` receives the app under test and passes when
541
+ * it returns, or when the promise it returns fulfills. Flat like the base:
542
+ * no `describe`, no hooks; every test runs in an engine of its own, on the
543
+ * file evaluated afresh, with app time at 0.
544
+ */
545
+ export function test(name: string, fn: (app: TestApp) => void | Promise<void>, options: TestOptions = {}): void {
546
+ register(name, () => {
547
+ if (options.fps !== undefined) setFrameRate(options.fps)
548
+ return fn(app)
549
+ })
550
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.