@solidrt/test 0.0.0-stage → 0.0.65
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 +72 -0
- package/package.json +20 -4
- package/src/index.ts +550 -0
- package/README.md +0 -3
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 srt 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.65",
|
|
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.65"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@solidrt/core": "0.0.65"
|
|
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 "srt: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
|
+
* `srt: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