@symbiote-native/test-utils 0.3.0 → 0.4.0
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/README.md +15 -13
- package/build/host-crossings.d.ts +21 -0
- package/build/host-crossings.js +61 -0
- package/build/index.d.ts +3 -2
- package/build/index.js +14 -5
- package/build/live-tree.d.ts +114 -0
- package/build/live-tree.js +135 -0
- package/build/recording-host.d.ts +144 -0
- package/build/recording-host.js +409 -0
- package/package.json +4 -1
- package/build/fake-fabric.d.ts +0 -51
- package/build/fake-fabric.js +0 -151
- package/build/lowering-equivalence.d.ts +0 -135
- package/build/lowering-equivalence.js +0 -224
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tree host that RECORDS and derives nothing — for the 141 test files that never read a tree.
|
|
3
|
+
*
|
|
4
|
+
* Those files call `installFabric()` only because that is how a test gets a host at all: they
|
|
5
|
+
* assert on state machines, prop resolution, listeners, styles. Attaching them to a second
|
|
6
|
+
* implementation of Fabric's tree rules buys them nothing and costs the project a mirror.
|
|
7
|
+
*
|
|
8
|
+
* **Why this is not one.** It keeps the AUTHORED tree — who was appended to whom, what props were
|
|
9
|
+
* set — because that is what the ops literally say and what an adapter's own seam asks back
|
|
10
|
+
* (Solid's `getParentNode`, Vue's `nextSibling`, Svelte's `firstChild`). It does not decide what
|
|
11
|
+
* COMMITS: no flattening, no stacking contexts, no virtual nodes, no clone protocol, no view-name
|
|
12
|
+
* rewriting. There is no derived answer here to be wrong, which is the whole difference between
|
|
13
|
+
* recording a statement and re-deriving a conclusion.
|
|
14
|
+
*
|
|
15
|
+
* A test that needs the committed tree belongs in `core/engine/cpp/tests/js`, where React Native
|
|
16
|
+
* answers. Asking one of those questions here gets `undefined` rather than a plausible lie.
|
|
17
|
+
*/
|
|
18
|
+
import { NO_VALUE, OP_APPEND_CHILD, OP_COMMIT, OP_CREATE_ANCHOR, OP_CREATE_ELEMENT, OP_CREATE_RAW_TEXT, OP_INSERT_BEFORE, OP_REMOVE_CHILD, OP_SET_COMPONENT, OP_SET_OWNED_LISTENER, OP_SET_TAG, OP_SET_UNDERLAY_SHOWN, OP_SET_PROP, OP_SET_TEXT, OP_STRIDE, } from '@symbiote-native/engine/mutation-buffer';
|
|
19
|
+
import { fabricProps, isSymbioteNode, propsOf, setTreeHost, } from '@symbiote-native/engine';
|
|
20
|
+
/**
|
|
21
|
+
* Install the recording host, with the minimum slot the engine insists on.
|
|
22
|
+
*
|
|
23
|
+
* `createSurface` calls `installEventHandler`, which reaches `globalThis.nativeFabricUIManager` and
|
|
24
|
+
* throws if it is absent — so a host alone is not enough to get a surface open. The slot below
|
|
25
|
+
* exists to be PRESENT: every method answers nothing except `registerEventHandler`, which hands the
|
|
26
|
+
* engine's handler to the host so `fireEvent` can play it back.
|
|
27
|
+
*
|
|
28
|
+
* What that does NOT buy is a real gesture. Deciding which node a touch lands on is hit-testing,
|
|
29
|
+
* and that is the renderer's answer; a test that needs it belongs in `core/engine/cpp/tests/js`.
|
|
30
|
+
*/
|
|
31
|
+
export function installRecordingFabric() {
|
|
32
|
+
const host = createRecordingHost();
|
|
33
|
+
Object.assign(globalThis, {
|
|
34
|
+
nativeFabricUIManager: {
|
|
35
|
+
registerEventHandler(handler) {
|
|
36
|
+
host.registerEventHandler(handler);
|
|
37
|
+
},
|
|
38
|
+
dispatchCommand() { },
|
|
39
|
+
setIsJSResponder() { },
|
|
40
|
+
sendAccessibilityEvent() { },
|
|
41
|
+
measure() { },
|
|
42
|
+
measureInWindow() { },
|
|
43
|
+
measureLayout() { },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
setTreeHost(host);
|
|
47
|
+
return host;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The Fabric payload for a node — what the engine WOULD hand the renderer for it, built by the
|
|
51
|
+
* engine's own builder rather than read off anything.
|
|
52
|
+
*
|
|
53
|
+
* This exists because the two are easy to confuse and the difference bites: a node's PROPS are the
|
|
54
|
+
* author's bag (`style` is still an object), while the PAYLOAD is what `fabricProps` makes of it —
|
|
55
|
+
* style flattened into top-level keys, the aria fold run, the ten RN processors applied. A test
|
|
56
|
+
* asking about `padding` or `accessibilityRole` or a parsed `backgroundSize` means the payload, and
|
|
57
|
+
* reading the bag instead comes back `undefined` with nothing to explain why.
|
|
58
|
+
*/
|
|
59
|
+
export function payloadOf(node) {
|
|
60
|
+
return fabricProps(node, propsOf(node));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Where a tag would be. Not a Fabric tag and not pretending to be one — this host never speaks to
|
|
64
|
+
* Fabric, so any number here would be an invention.
|
|
65
|
+
*/
|
|
66
|
+
const NO_TAG = -1;
|
|
67
|
+
export function createRecordingHost() {
|
|
68
|
+
let recorded = new WeakMap();
|
|
69
|
+
let eventHandler;
|
|
70
|
+
// The WeakMap above cannot be enumerated, and `find` has to start somewhere. Strong references,
|
|
71
|
+
// so `forget()` is what a long file calls to stop this growing — the same deal `installFabric`'s
|
|
72
|
+
// `created` array made.
|
|
73
|
+
let created = [];
|
|
74
|
+
const nodeOf = (handle, what) => {
|
|
75
|
+
const node = recorded.get(handle);
|
|
76
|
+
if (node === undefined) {
|
|
77
|
+
throw new Error(`${what}: handle names no node in this tree`);
|
|
78
|
+
}
|
|
79
|
+
return node;
|
|
80
|
+
};
|
|
81
|
+
const detach = (node) => {
|
|
82
|
+
const { parent } = node;
|
|
83
|
+
if (parent === undefined)
|
|
84
|
+
return;
|
|
85
|
+
const at = parent.children.indexOf(node);
|
|
86
|
+
if (at >= 0)
|
|
87
|
+
parent.children.splice(at, 1);
|
|
88
|
+
node.parent = undefined;
|
|
89
|
+
};
|
|
90
|
+
// Lands a rootTag onto a whole subtree at OP_COMMIT, the same moment the real engine hands every
|
|
91
|
+
// node in the walk its own `ShadowNode`. `committedRootTag` never gets cleared afterwards, so a
|
|
92
|
+
// node removed after this still answers `committedRecordOf` — see that field's own comment.
|
|
93
|
+
const markCommitted = (node, rootTag) => {
|
|
94
|
+
node.committedRootTag = rootTag;
|
|
95
|
+
for (const child of node.children)
|
|
96
|
+
markCommitted(child, rootTag);
|
|
97
|
+
};
|
|
98
|
+
const host = {
|
|
99
|
+
commits: 0,
|
|
100
|
+
commands: [],
|
|
101
|
+
responderHandovers: [],
|
|
102
|
+
accessibilityEvents: [],
|
|
103
|
+
applyOps(batch) {
|
|
104
|
+
const { ops, strings, values, handles, instanceHandles } = batch;
|
|
105
|
+
const handleAt = (slot) => {
|
|
106
|
+
const handle = handles[slot];
|
|
107
|
+
if (handle === undefined) {
|
|
108
|
+
throw new Error(`applyOps: slot ${slot} is outside this batch's handles`);
|
|
109
|
+
}
|
|
110
|
+
return handle;
|
|
111
|
+
};
|
|
112
|
+
const at = (slot) => nodeOf(handleAt(slot), 'applyOps');
|
|
113
|
+
const create = (slot, viewName, props, instanceHandle) => {
|
|
114
|
+
const handle = handleAt(slot);
|
|
115
|
+
// Checked rather than assumed: the batch types handles as `object`, and the whole value of
|
|
116
|
+
// holding the node is being able to hand it back to the engine's own API.
|
|
117
|
+
if (!isSymbioteNode(handle)) {
|
|
118
|
+
throw new Error('applyOps: a handle in this batch is not an engine node');
|
|
119
|
+
}
|
|
120
|
+
const node = {
|
|
121
|
+
handle,
|
|
122
|
+
instanceHandle,
|
|
123
|
+
viewName,
|
|
124
|
+
tagName: '',
|
|
125
|
+
ownedListeners: {},
|
|
126
|
+
underlayShown: false,
|
|
127
|
+
props,
|
|
128
|
+
parent: undefined,
|
|
129
|
+
children: [],
|
|
130
|
+
committedRootTag: undefined,
|
|
131
|
+
};
|
|
132
|
+
recorded.set(handle, node);
|
|
133
|
+
created.push(node);
|
|
134
|
+
};
|
|
135
|
+
for (let cursor = 0; cursor + OP_STRIDE <= ops.length; cursor += OP_STRIDE) {
|
|
136
|
+
const code = ops[cursor];
|
|
137
|
+
const a = ops[cursor + 1];
|
|
138
|
+
const b = ops[cursor + 2];
|
|
139
|
+
const c = ops[cursor + 3];
|
|
140
|
+
const d = ops[cursor + 4];
|
|
141
|
+
switch (code) {
|
|
142
|
+
case OP_CREATE_ELEMENT:
|
|
143
|
+
// Slot 4 is the index into `instanceHandles` — the object Fabric would hand back with
|
|
144
|
+
// an event. Recorded because it is what the op SAYS, and because a test firing an
|
|
145
|
+
// event has to name the same object the engine registered.
|
|
146
|
+
create(a, strings[b], {}, instanceHandles[d]);
|
|
147
|
+
break;
|
|
148
|
+
case OP_CREATE_RAW_TEXT:
|
|
149
|
+
create(a, 'RCTRawText', { text: strings[b] });
|
|
150
|
+
break;
|
|
151
|
+
case OP_CREATE_ANCHOR:
|
|
152
|
+
create(a, '', {});
|
|
153
|
+
break;
|
|
154
|
+
case OP_APPEND_CHILD: {
|
|
155
|
+
const child = at(b);
|
|
156
|
+
detach(child);
|
|
157
|
+
child.parent = at(a);
|
|
158
|
+
at(a).children.push(child);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case OP_INSERT_BEFORE: {
|
|
162
|
+
const parent = at(a);
|
|
163
|
+
const child = at(b);
|
|
164
|
+
const before = at(c);
|
|
165
|
+
detach(child);
|
|
166
|
+
child.parent = parent;
|
|
167
|
+
const index = parent.children.indexOf(before);
|
|
168
|
+
parent.children.splice(index < 0 ? parent.children.length : index, 0, child);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case OP_REMOVE_CHILD:
|
|
172
|
+
detach(at(b));
|
|
173
|
+
break;
|
|
174
|
+
case OP_SET_PROP: {
|
|
175
|
+
const node = at(a);
|
|
176
|
+
if (c === NO_VALUE)
|
|
177
|
+
delete node.props[strings[b]];
|
|
178
|
+
else
|
|
179
|
+
node.props[strings[b]] = values[c];
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case OP_SET_TEXT:
|
|
183
|
+
at(a).props.text = strings[b];
|
|
184
|
+
break;
|
|
185
|
+
case OP_SET_COMPONENT:
|
|
186
|
+
at(a).viewName = strings[b];
|
|
187
|
+
break;
|
|
188
|
+
// RECORDED AND NOT ACTED ON, deliberately. The real host resolves a tag's platform props
|
|
189
|
+
// off this (`foldPressableProps`); this host builds its payload through the TypeScript
|
|
190
|
+
// `fabricProps`, which carries no copy of those rules and must not grow one — a second
|
|
191
|
+
// implementation is how a test goes green over a rule that no longer runs. A test that
|
|
192
|
+
// needs to read what a tag actually sends belongs in `core/engine/cpp/tests/js`.
|
|
193
|
+
case OP_SET_TAG:
|
|
194
|
+
at(a).tagName = strings[b];
|
|
195
|
+
break;
|
|
196
|
+
// RECORDED, NOT APPLIED, exactly like the tag above. The real host resolves `focusable`'s
|
|
197
|
+
// three-leg touchable form off this bit; this host builds its payload through the
|
|
198
|
+
// TypeScript `fabricProps`, which carries no copy of the tag rules and must not grow one.
|
|
199
|
+
// So a test that wants the resolved key reads the committed payload in an itest, and what
|
|
200
|
+
// this records is the fact a test can ASK about.
|
|
201
|
+
case OP_SET_OWNED_LISTENER:
|
|
202
|
+
at(a).ownedListeners[strings[b]] = c !== 0;
|
|
203
|
+
break;
|
|
204
|
+
// Same treatment, and for the same reason: the real host paints TouchableHighlight's
|
|
205
|
+
// underlay from this bit (`foldTouchableHighlightUnderlay`), and this one must not grow a
|
|
206
|
+
// copy of that rule. Recorded so a test can ask whether the MACHINE flipped it — which is
|
|
207
|
+
// the half that is still JS — while what the flip LOOKS like is asserted on a committed
|
|
208
|
+
// payload in `core/engine/cpp/tests/js/touchable-highlight-underlay.itest.ts`.
|
|
209
|
+
case OP_SET_UNDERLAY_SHOWN:
|
|
210
|
+
at(a).underlayShown = b !== 0;
|
|
211
|
+
break;
|
|
212
|
+
case OP_COMMIT: {
|
|
213
|
+
host.commits += 1;
|
|
214
|
+
markCommitted(at(b), a);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
throw new Error(`applyOps: unknown opcode ${String(code)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
propOf(handle, key) {
|
|
223
|
+
return nodeOf(handle, 'propOf').props[key];
|
|
224
|
+
},
|
|
225
|
+
propsOf(handle) {
|
|
226
|
+
return nodeOf(handle, 'propsOf').props;
|
|
227
|
+
},
|
|
228
|
+
// Nothing is memoized here, so there is nothing to invalidate.
|
|
229
|
+
markPropsDirty() { },
|
|
230
|
+
/**
|
|
231
|
+
* A record for a node standing under a surface the ops COMMITTED, and nothing more.
|
|
232
|
+
*
|
|
233
|
+
* The engine gates every imperative call on this, so answering `undefined` always would make
|
|
234
|
+
* `dispatchCommand` unreachable and quietly turn four command tests green-by-absence. What is
|
|
235
|
+
* answered here is read straight off the op stream — there WAS a commit, and this node is under
|
|
236
|
+
* that surface — with no claim about what the renderer did with it: no tag Fabric minted, no
|
|
237
|
+
* view that survived flattening, no guarantee it is mounted. A test that needs any of those
|
|
238
|
+
* belongs in `core/engine/cpp/tests/js`.
|
|
239
|
+
*
|
|
240
|
+
* `handle` is the authored node and `tag` is `NO_TAG`, so a test reading either gets something
|
|
241
|
+
* obviously not-from-Fabric rather than a plausible number. `rootTag` is real, not invented —
|
|
242
|
+
* see `IRecorded.committedRootTag`'s own comment for why the JS-level surface identifier is a
|
|
243
|
+
* different unknown from the native Fabric tag.
|
|
244
|
+
*
|
|
245
|
+
* Read off `committedRootTag` (set once per node at the commit that landed it, never cleared),
|
|
246
|
+
* not a live walk to a committed root — `removeChild` cuts the parent link before a behavior's
|
|
247
|
+
* teardown sweep runs in the SAME commit, so a walk answers `undefined` for exactly the node a
|
|
248
|
+
* parting write (`TextInput`'s unmount blur) needs to still reach. `node->committed` in the real
|
|
249
|
+
* engine works the same way, and for the same reason.
|
|
250
|
+
*/
|
|
251
|
+
committedRecordOf(handle) {
|
|
252
|
+
const node = recorded.get(handle);
|
|
253
|
+
if (node === undefined || node.committedRootTag === undefined) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
handle,
|
|
258
|
+
tag: NO_TAG,
|
|
259
|
+
rootTag: node.committedRootTag,
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
/**
|
|
263
|
+
* REFUSED, loudly, and that is the whole point of implementing it here.
|
|
264
|
+
*
|
|
265
|
+
* This host records the OPS it was handed; it never builds a Fabric payload, because building
|
|
266
|
+
* one is `SymbioteFabricProps.cpp`'s job and that code does not exist in a vitest process. An
|
|
267
|
+
* answer synthesised from the JS twin would be a test asserting against the wrong
|
|
268
|
+
* implementation — the exact drift `ITreeHost.committedPayloadOf` was added to close.
|
|
269
|
+
*
|
|
270
|
+
* So a test asking this question belongs in `core/engine/cpp/tests/js/` (`pnpm run test:itest`),
|
|
271
|
+
* where the real builder runs. `.props` on this host stays the read for "what did the adapter
|
|
272
|
+
* SAY", which is a different and still-useful question.
|
|
273
|
+
*/
|
|
274
|
+
committedPayloadOf() {
|
|
275
|
+
throw new Error('recording host: committedPayloadOf needs the real payload builder — move this to an itest');
|
|
276
|
+
},
|
|
277
|
+
parentOf(handle) {
|
|
278
|
+
return nodeOf(handle, 'parentOf').parent?.handle;
|
|
279
|
+
},
|
|
280
|
+
childrenOf(handle) {
|
|
281
|
+
return nodeOf(handle, 'childrenOf').children.map(child => child.handle);
|
|
282
|
+
},
|
|
283
|
+
firstChildOf(handle) {
|
|
284
|
+
return nodeOf(handle, 'firstChildOf').children[0]?.handle;
|
|
285
|
+
},
|
|
286
|
+
nextSiblingOf(handle) {
|
|
287
|
+
const node = nodeOf(handle, 'nextSiblingOf');
|
|
288
|
+
const siblings = node.parent?.children;
|
|
289
|
+
if (siblings === undefined)
|
|
290
|
+
return undefined;
|
|
291
|
+
return siblings[siblings.indexOf(node) + 1]?.handle;
|
|
292
|
+
},
|
|
293
|
+
parentsOf(handles) {
|
|
294
|
+
return handles.map(handle => nodeOf(handle, 'parentsOf').parent?.handle);
|
|
295
|
+
},
|
|
296
|
+
subtreesOf(roots) {
|
|
297
|
+
const out = [];
|
|
298
|
+
const walk = (node) => {
|
|
299
|
+
out.push(node.handle);
|
|
300
|
+
for (const child of node.children)
|
|
301
|
+
walk(child);
|
|
302
|
+
};
|
|
303
|
+
for (const root of roots)
|
|
304
|
+
walk(nodeOf(root, 'subtreesOf'));
|
|
305
|
+
return out;
|
|
306
|
+
},
|
|
307
|
+
// The twin of the engine's narrowed walk, and it MUST narrow here too: the vitest suite is where
|
|
308
|
+
// the sweep's behaviour is asserted, so a recording host that handed back everything would make
|
|
309
|
+
// every one of those cases pass whatever the engine does.
|
|
310
|
+
teardownSubtreesOf(roots) {
|
|
311
|
+
const out = [];
|
|
312
|
+
const walk = (node, isRoot) => {
|
|
313
|
+
const reserved = out.length;
|
|
314
|
+
out.push(node.handle);
|
|
315
|
+
let isWanted = isRoot || node.tagName !== '';
|
|
316
|
+
for (const child of node.children) {
|
|
317
|
+
if (walk(child, false))
|
|
318
|
+
isWanted = true;
|
|
319
|
+
}
|
|
320
|
+
if (!isWanted)
|
|
321
|
+
out.length = reserved;
|
|
322
|
+
return isWanted;
|
|
323
|
+
};
|
|
324
|
+
for (const root of roots)
|
|
325
|
+
walk(nodeOf(root, 'teardownSubtreesOf'), true);
|
|
326
|
+
return out;
|
|
327
|
+
},
|
|
328
|
+
ancestorsOf(handle) {
|
|
329
|
+
const chain = [];
|
|
330
|
+
for (let node = nodeOf(handle, 'ancestorsOf'); node !== undefined; node = node.parent) {
|
|
331
|
+
chain.push(node.handle);
|
|
332
|
+
}
|
|
333
|
+
return chain;
|
|
334
|
+
},
|
|
335
|
+
census(roots) {
|
|
336
|
+
let nodes = 0;
|
|
337
|
+
const walk = (node) => {
|
|
338
|
+
nodes += 1;
|
|
339
|
+
for (const child of node.children)
|
|
340
|
+
walk(child);
|
|
341
|
+
};
|
|
342
|
+
for (const root of roots)
|
|
343
|
+
walk(nodeOf(root, 'census'));
|
|
344
|
+
// The skip counts belong to the commit rules, which this host does not have. Zero is the
|
|
345
|
+
// honest answer: nothing here was skipped, because nothing here was decided.
|
|
346
|
+
return {
|
|
347
|
+
nodes,
|
|
348
|
+
anchors: 0,
|
|
349
|
+
emptyRawTexts: 0,
|
|
350
|
+
renderable: nodes,
|
|
351
|
+
flattenWidths: [],
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
// The imperative six ask the PLATFORM — a frame, a gesture, an announcement. Three of them
|
|
355
|
+
// carry a request the engine MADE, so they are recorded; the measuring three want an answer
|
|
356
|
+
// only a platform has, and there is none here.
|
|
357
|
+
dispatchCommand(handle, commandName, args) {
|
|
358
|
+
host.commands.push({
|
|
359
|
+
handle,
|
|
360
|
+
viewName: nodeOf(handle, 'dispatchCommand').viewName,
|
|
361
|
+
commandName,
|
|
362
|
+
args,
|
|
363
|
+
});
|
|
364
|
+
},
|
|
365
|
+
sendAccessibilityEvent(handle, eventType) {
|
|
366
|
+
host.accessibilityEvents.push({ handle, eventType });
|
|
367
|
+
},
|
|
368
|
+
measure(_handle, _callback) { },
|
|
369
|
+
measureInWindow(_handle, _callback) { },
|
|
370
|
+
measureLayout(_handle, _relativeTo, onFail, _onSuccess) {
|
|
371
|
+
onFail();
|
|
372
|
+
},
|
|
373
|
+
setIsJSResponder(handle, isResponder, blockNativeResponder) {
|
|
374
|
+
host.responderHandovers.push({
|
|
375
|
+
handle,
|
|
376
|
+
isResponder,
|
|
377
|
+
blockNativeResponder,
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
registerEventHandler(handler) {
|
|
381
|
+
eventHandler = handler;
|
|
382
|
+
},
|
|
383
|
+
find(predicate) {
|
|
384
|
+
return created.find(predicate);
|
|
385
|
+
},
|
|
386
|
+
findAll(predicate) {
|
|
387
|
+
return created.filter(predicate);
|
|
388
|
+
},
|
|
389
|
+
fireEvent(handle, topLevelType, nativeEvent = {}) {
|
|
390
|
+
if (eventHandler === undefined) {
|
|
391
|
+
throw new Error('no event handler registered by the renderer');
|
|
392
|
+
}
|
|
393
|
+
eventHandler(handle, topLevelType, nativeEvent);
|
|
394
|
+
},
|
|
395
|
+
reset() {
|
|
396
|
+
created = [];
|
|
397
|
+
host.commits = 0;
|
|
398
|
+
host.commands.length = 0;
|
|
399
|
+
host.responderHandovers.length = 0;
|
|
400
|
+
host.accessibilityEvents.length = 0;
|
|
401
|
+
},
|
|
402
|
+
forget() {
|
|
403
|
+
recorded = new WeakMap();
|
|
404
|
+
created = [];
|
|
405
|
+
host.reset();
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
return host;
|
|
409
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/test-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared, framework-agnostic fake-Fabric test harness for SymbioteNative — the fake nativeFabricUIManager recorder used by the co-located test suites across the engine, every adapter, and the example apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@symbiote-native/engine": "1.1.0"
|
|
44
|
+
},
|
|
42
45
|
"scripts": {
|
|
43
46
|
"typecheck": "tsc --build",
|
|
44
47
|
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
package/build/fake-fabric.d.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
export interface IFakeNode {
|
|
2
|
-
tag: number;
|
|
3
|
-
viewName: string;
|
|
4
|
-
props: Record<string, unknown>;
|
|
5
|
-
children: IFakeNode[];
|
|
6
|
-
instanceHandle: unknown;
|
|
7
|
-
/** Fabric family parent. Clones keep the same tag/family, so reparenting a family is illegal. */
|
|
8
|
-
parentFamilyTag?: number;
|
|
9
|
-
}
|
|
10
|
-
export type IEventHandler = (instanceHandle: unknown, topLevelType: string, nativeEvent: Record<string, unknown>) => void;
|
|
11
|
-
export interface IFabricRecorder {
|
|
12
|
-
/** The child set handed to the most recent `completeRoot`. */
|
|
13
|
-
committed: IFakeNode[];
|
|
14
|
-
/** Every node ever `createNode`'d this run (clones excluded). */
|
|
15
|
-
created: IFakeNode[];
|
|
16
|
-
/** Every imperative command dispatched at a committed Fabric node. */
|
|
17
|
-
commands: Array<{
|
|
18
|
-
node: IFakeNode;
|
|
19
|
-
commandName: string;
|
|
20
|
-
args: readonly unknown[];
|
|
21
|
-
}>;
|
|
22
|
-
/**
|
|
23
|
-
* Call counters, for tests that assert "exactly N native nodes were created" — and for pricing
|
|
24
|
-
* a commit's PROTOCOL half against its walk half. `appendChild` and `clone` are the two Fabric
|
|
25
|
-
* makes unavoidable: a parent whose child set changed is cloned empty and re-appends every child
|
|
26
|
-
* handle, so those counts are what a real JSI boundary would charge no matter how cheap the JS
|
|
27
|
-
* walk above them gets. Counting them here is the only way a headless run can say which half a
|
|
28
|
-
* proposed optimisation is even aimed at.
|
|
29
|
-
*/
|
|
30
|
-
counts: {
|
|
31
|
-
createNode: number;
|
|
32
|
-
completeRoot: number;
|
|
33
|
-
appendChild: number;
|
|
34
|
-
clone: number;
|
|
35
|
-
};
|
|
36
|
-
/**
|
|
37
|
-
* RN wraps every commit in a synthetic `box-none` AppContainer root.
|
|
38
|
-
* Returns it, asserting it is the single expected root, so each test unwraps the
|
|
39
|
-
* AppContainer the same way instead of re-checking the invariant by hand.
|
|
40
|
-
*/
|
|
41
|
-
appRoot(): IFakeNode;
|
|
42
|
-
/** Find the first `createNode`'d node matching a predicate (e.g. the app's own View). */
|
|
43
|
-
find(predicate: (node: IFakeNode) => boolean): IFakeNode | undefined;
|
|
44
|
-
/** Deliver a native event to the renderer's registered handler. */
|
|
45
|
-
fireEvent(handle: unknown, topLevelType: string, nativeEvent?: Record<string, unknown>): void;
|
|
46
|
-
/** Serialize a node list to `RCTView(RCTText(RCTRawText "text"))` shorthand. */
|
|
47
|
-
serialize(nodes: IFakeNode[]): string;
|
|
48
|
-
/** Zero the counters and clear `committed` / `created` (the event handler survives). */
|
|
49
|
-
reset(): void;
|
|
50
|
-
}
|
|
51
|
-
export declare function installFabric(): IFabricRecorder;
|
package/build/fake-fabric.js
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
// One shared fake `nativeFabricUIManager` for the unit suite. `installFabric()` puts a
|
|
2
|
-
// fresh recording slot on `globalThis` and returns a handle to inspect what was committed.
|
|
3
|
-
//
|
|
4
|
-
// Mirrors real Fabric's clone-on-write semantics: every clone gets a NEW identity;
|
|
5
|
-
// `*NewProps` MERGES the diff onto previous props (the engine always sends a minimal diff —
|
|
6
|
-
// see `diffProps` in commit.ts). A removed key arrives as literal `null` and stays `null`,
|
|
7
|
-
// not deleted, so a test can tell "explicitly reset" apart from "never set". `*Children`
|
|
8
|
-
// variants reset children (the engine re-appends).
|
|
9
|
-
// See the header comment above for the merge/null-removal semantics this mirrors.
|
|
10
|
-
function mergeFabricProps(previous, diff) {
|
|
11
|
-
return { ...previous, ...diff };
|
|
12
|
-
}
|
|
13
|
-
// Fabric clones keep the node's FAMILY, so a handle that already belongs to one parent can never
|
|
14
|
-
// be appended under another. Enforced on both routes into a parent's child list — the append loop
|
|
15
|
-
// and the batched clone — so switching between them cannot quietly drop the check.
|
|
16
|
-
function assertSameFamily(parent, child) {
|
|
17
|
-
if (child.parentFamilyTag !== undefined &&
|
|
18
|
-
child.parentFamilyTag !== parent.tag) {
|
|
19
|
-
throw new Error(`Fabric family reparent: child ${child.viewName}#${child.tag} already belongs to parent #${child.parentFamilyTag}, cannot append to ${parent.viewName}#${parent.tag}`);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
// `Array.isArray` narrows to `any[]`, which leaves the OTHER branch of the union un-narrowed;
|
|
23
|
-
// an explicit predicate keeps both sides typed without a cast.
|
|
24
|
-
function isFakeNodeList(value) {
|
|
25
|
-
return Array.isArray(value);
|
|
26
|
-
}
|
|
27
|
-
// A clone with no child list comes back EMPTY, exactly as the real binding does.
|
|
28
|
-
function adoptChildren(parent, children) {
|
|
29
|
-
if (children === undefined)
|
|
30
|
-
return [];
|
|
31
|
-
for (const child of children) {
|
|
32
|
-
assertSameFamily(parent, child);
|
|
33
|
-
child.parentFamilyTag = parent.tag;
|
|
34
|
-
}
|
|
35
|
-
return [...children];
|
|
36
|
-
}
|
|
37
|
-
export function installFabric() {
|
|
38
|
-
let committed = [];
|
|
39
|
-
const created = [];
|
|
40
|
-
const commands = [];
|
|
41
|
-
const counts = { createNode: 0, completeRoot: 0, appendChild: 0, clone: 0 };
|
|
42
|
-
let eventHandler;
|
|
43
|
-
const slot = {
|
|
44
|
-
createNode(tag, viewName, _rootTag, props, instanceHandle) {
|
|
45
|
-
counts.createNode += 1;
|
|
46
|
-
const node = {
|
|
47
|
-
tag,
|
|
48
|
-
viewName,
|
|
49
|
-
props,
|
|
50
|
-
children: [],
|
|
51
|
-
instanceHandle,
|
|
52
|
-
};
|
|
53
|
-
created.push(node);
|
|
54
|
-
return node;
|
|
55
|
-
},
|
|
56
|
-
cloneNodeWithNewProps: (node, newProps) => {
|
|
57
|
-
counts.clone += 1;
|
|
58
|
-
return { ...node, props: mergeFabricProps(node.props, newProps) };
|
|
59
|
-
},
|
|
60
|
-
// Both clone-with-children forms take the child list as an OPTIONAL trailing/second argument,
|
|
61
|
-
// mirroring UIManagerBinding.cpp: `cloneNodeWithNewChildren(node, children?)` and the 3-arg
|
|
62
|
-
// `cloneNodeWithNewChildrenAndProps(node, children, props)`. The engine probes support by
|
|
63
|
-
// ARITY, so these must keep 2 and 3 declared parameters — a default value on any of them
|
|
64
|
-
// would drop `.length` below the threshold and silently send every test down the append loop.
|
|
65
|
-
cloneNodeWithNewChildren: (node, children) => {
|
|
66
|
-
counts.clone += 1;
|
|
67
|
-
return { ...node, children: adoptChildren(node, children) };
|
|
68
|
-
},
|
|
69
|
-
cloneNodeWithNewChildrenAndProps: (node, childrenOrProps, maybeProps) => {
|
|
70
|
-
counts.clone += 1;
|
|
71
|
-
const children = isFakeNodeList(childrenOrProps)
|
|
72
|
-
? childrenOrProps
|
|
73
|
-
: undefined;
|
|
74
|
-
const newProps = isFakeNodeList(childrenOrProps)
|
|
75
|
-
? (maybeProps ?? {})
|
|
76
|
-
: childrenOrProps;
|
|
77
|
-
return {
|
|
78
|
-
...node,
|
|
79
|
-
props: mergeFabricProps(node.props, newProps),
|
|
80
|
-
children: adoptChildren(node, children),
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
createChildSet: () => [],
|
|
84
|
-
appendChild(parent, child) {
|
|
85
|
-
counts.appendChild += 1;
|
|
86
|
-
assertSameFamily(parent, child);
|
|
87
|
-
child.parentFamilyTag = parent.tag;
|
|
88
|
-
parent.children.push(child);
|
|
89
|
-
return parent;
|
|
90
|
-
},
|
|
91
|
-
appendChildToSet(childSet, child) {
|
|
92
|
-
childSet.push(child);
|
|
93
|
-
},
|
|
94
|
-
completeRoot(_rootTag, childSet) {
|
|
95
|
-
counts.completeRoot += 1;
|
|
96
|
-
committed = childSet;
|
|
97
|
-
},
|
|
98
|
-
registerEventHandler(handler) {
|
|
99
|
-
eventHandler = handler;
|
|
100
|
-
},
|
|
101
|
-
dispatchCommand(node, commandName, args) {
|
|
102
|
-
commands.push({ node, commandName, args });
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
Object.assign(globalThis, { nativeFabricUIManager: slot });
|
|
106
|
-
const serializeNode = (node) => {
|
|
107
|
-
const text = node.viewName === 'RCTRawText' ? ` "${String(node.props.text)}"` : '';
|
|
108
|
-
const kids = node.children.length
|
|
109
|
-
? `(${node.children.map(serializeNode).join('')})`
|
|
110
|
-
: '';
|
|
111
|
-
return `${node.viewName}${text}${kids}`;
|
|
112
|
-
};
|
|
113
|
-
return {
|
|
114
|
-
get committed() {
|
|
115
|
-
return committed;
|
|
116
|
-
},
|
|
117
|
-
created,
|
|
118
|
-
commands,
|
|
119
|
-
counts,
|
|
120
|
-
appRoot() {
|
|
121
|
-
const root = committed[0];
|
|
122
|
-
if (committed.length !== 1 || root?.props.pointerEvents !== 'box-none') {
|
|
123
|
-
throw new Error(`expected a single box-none AppContainer root, got ${committed.length} node(s)`);
|
|
124
|
-
}
|
|
125
|
-
return root;
|
|
126
|
-
},
|
|
127
|
-
find(predicate) {
|
|
128
|
-
return created.find(predicate);
|
|
129
|
-
},
|
|
130
|
-
fireEvent(handle, topLevelType, nativeEvent = {}) {
|
|
131
|
-
if (!eventHandler)
|
|
132
|
-
throw new Error('no event handler registered by the renderer');
|
|
133
|
-
eventHandler(handle, topLevelType, nativeEvent);
|
|
134
|
-
},
|
|
135
|
-
serialize(nodes) {
|
|
136
|
-
return nodes.map(serializeNode).join('');
|
|
137
|
-
},
|
|
138
|
-
reset() {
|
|
139
|
-
committed = [];
|
|
140
|
-
created.length = 0;
|
|
141
|
-
commands.length = 0;
|
|
142
|
-
// Every counter, not a subset: `appendChild` and `clone` were left out, so any assertion
|
|
143
|
-
// on them across a reset read the PREVIOUS phase's total and could not fail. Both are now
|
|
144
|
-
// the metric that prices the clone protocol, so a stale one is a silent wrong answer.
|
|
145
|
-
counts.createNode = 0;
|
|
146
|
-
counts.completeRoot = 0;
|
|
147
|
-
counts.appendChild = 0;
|
|
148
|
-
counts.clone = 0;
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|