@symbiote-native/test-utils 0.1.5 → 0.2.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 +10 -5
- package/build/fake-fabric.js +29 -19
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/build/wait-for.d.ts +3 -0
- package/build/wait-for.js +72 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -30,16 +30,21 @@ test('tap increments the counter', () => {
|
|
|
30
30
|
const fabric = installFabric();
|
|
31
31
|
mount(1, createElement(App));
|
|
32
32
|
|
|
33
|
-
const button = fabric.find(
|
|
33
|
+
const button = fabric.find(
|
|
34
|
+
n => n.viewName === 'RCTView' && n.props.testID === 'tap-target',
|
|
35
|
+
);
|
|
34
36
|
fabric.fireEvent(button!.instanceHandle, 'topClick', {});
|
|
35
37
|
|
|
36
38
|
expect(fabric.serialize(fabric.committed)).toContain('Taps: 1');
|
|
37
39
|
});
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
Call `installFabric()` ONCE per test file, at module scope, and `.reset()` the handle between
|
|
43
|
+
tests. Do not re-install per test: the engine's `getSlot()` (`core/engine/src/fabric.ts`) reads the
|
|
44
|
+
slot's methods once and caches them for the process lifetime, so a second `installFabric()` after
|
|
45
|
+
anything has committed swaps the global while the engine keeps writing through the handle it
|
|
46
|
+
already bound — the new recorder just stays empty, with nothing to signal why. File-level isolation
|
|
47
|
+
comes from the test runner; `reset()` is what separates tests within a file.
|
|
43
48
|
|
|
44
49
|
## What `installFabric()` gives you
|
|
45
50
|
|
|
@@ -78,5 +83,5 @@ test that depends on it.
|
|
|
78
83
|
|
|
79
84
|
## Test it
|
|
80
85
|
|
|
81
|
-
This package has no tests of its own — it
|
|
86
|
+
This package has no tests of its own — it _is_ the test double every other package's `vitest` suite
|
|
82
87
|
imports.
|
package/build/fake-fabric.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
// One shared fake `nativeFabricUIManager` for the unit suite. `installFabric()`
|
|
2
|
-
//
|
|
3
|
-
// renderer committed. It replaces the per-file slot the smokes each copy-pasted (×65).
|
|
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.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (the engine re-appends). A persistence bug in the fake is now fixed once, here, for every
|
|
12
|
-
// test, matching how the real engine's clone-on-write commit path behaves.
|
|
13
|
-
// Mirrors real Fabric's clone*WithNewProps merge: `diff` is a minimal payload (only changed
|
|
14
|
-
// keys, plus a removed key sent as literal `null` — kept as `null` here, not deleted, so a
|
|
15
|
-
// test can tell "explicitly reset to default" apart from "never set").
|
|
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.
|
|
16
10
|
function mergeFabricProps(previous, diff) {
|
|
17
11
|
return { ...previous, ...diff };
|
|
18
12
|
}
|
|
@@ -25,7 +19,13 @@ export function installFabric() {
|
|
|
25
19
|
const slot = {
|
|
26
20
|
createNode(tag, viewName, _rootTag, props, instanceHandle) {
|
|
27
21
|
counts.createNode += 1;
|
|
28
|
-
const node = {
|
|
22
|
+
const node = {
|
|
23
|
+
tag,
|
|
24
|
+
viewName,
|
|
25
|
+
props,
|
|
26
|
+
children: [],
|
|
27
|
+
instanceHandle,
|
|
28
|
+
};
|
|
29
29
|
created.push(node);
|
|
30
30
|
return node;
|
|
31
31
|
},
|
|
@@ -33,11 +33,19 @@ export function installFabric() {
|
|
|
33
33
|
...node,
|
|
34
34
|
props: mergeFabricProps(node.props, newProps),
|
|
35
35
|
}),
|
|
36
|
-
cloneNodeWithNewChildren: (node) => ({
|
|
37
|
-
|
|
36
|
+
cloneNodeWithNewChildren: (node) => ({
|
|
37
|
+
...node,
|
|
38
|
+
children: [],
|
|
39
|
+
}),
|
|
40
|
+
cloneNodeWithNewChildrenAndProps: (node, newProps) => ({
|
|
41
|
+
...node,
|
|
42
|
+
props: mergeFabricProps(node.props, newProps),
|
|
43
|
+
children: [],
|
|
44
|
+
}),
|
|
38
45
|
createChildSet: () => [],
|
|
39
46
|
appendChild(parent, child) {
|
|
40
|
-
if (child.parentFamilyTag !== undefined &&
|
|
47
|
+
if (child.parentFamilyTag !== undefined &&
|
|
48
|
+
child.parentFamilyTag !== parent.tag) {
|
|
41
49
|
throw new Error(`Fabric family reparent: child ${child.viewName}#${child.tag} already belongs to parent #${child.parentFamilyTag}, cannot append to ${parent.viewName}#${parent.tag}`);
|
|
42
50
|
}
|
|
43
51
|
child.parentFamilyTag = parent.tag;
|
|
@@ -61,7 +69,9 @@ export function installFabric() {
|
|
|
61
69
|
Object.assign(globalThis, { nativeFabricUIManager: slot });
|
|
62
70
|
const serializeNode = (node) => {
|
|
63
71
|
const text = node.viewName === 'RCTRawText' ? ` "${String(node.props.text)}"` : '';
|
|
64
|
-
const kids = node.children.length
|
|
72
|
+
const kids = node.children.length
|
|
73
|
+
? `(${node.children.map(serializeNode).join('')})`
|
|
74
|
+
: '';
|
|
65
75
|
return `${node.viewName}${text}${kids}`;
|
|
66
76
|
};
|
|
67
77
|
return {
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function waitUntil(condition: () => boolean, label: string, timeoutMs?: number): Promise<void>;
|
|
2
|
+
export declare function waitForQuiet(sample: () => number, label: string, stableTicks?: number, timeoutMs?: number): Promise<number>;
|
|
3
|
+
export declare function advanceTicks(count: number): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Condition-based waiting, for tests that need "the framework has settled" rather than "N
|
|
2
|
+
// macrotasks have elapsed".
|
|
3
|
+
//
|
|
4
|
+
// Nearly every suite here spells the wait as `await new Promise(r => setTimeout(r, 0))`, repeated
|
|
5
|
+
// two or ten times. That is a PROXY for settling, and it holds only while the machine is idle: a
|
|
6
|
+
// macrotask boundary guarantees the queue drained once, not that a batched commit, a zoneless
|
|
7
|
+
// change-detection pass, or a press-timing timer has finished. Under a loaded full-suite run the
|
|
8
|
+
// proxy breaks, and the failure reads as a product bug — a press that produced no event, change
|
|
9
|
+
// detection that never stopped — rather than as the harness giving up early.
|
|
10
|
+
//
|
|
11
|
+
// Measured 2026-08-19: three tests across two adapters failed only inside a loaded `vitest run`
|
|
12
|
+
// and passed in isolation, every one of them on a fixed tick count.
|
|
13
|
+
//
|
|
14
|
+
// Use `waitUntil` when a condition names what you are waiting for, and `waitForQuiet` when the
|
|
15
|
+
// thing to wait for is the ABSENCE of further work. Do not raise a tick count to fix a flake —
|
|
16
|
+
// that trades a fast failure for a slow one and keeps the race.
|
|
17
|
+
const TICK_MS = 0;
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 2_000;
|
|
19
|
+
// Read off the host rather than imported, the same reason the engine's animations/raf.ts does:
|
|
20
|
+
// this package's tsconfig carries no DOM and no Node lib, so the ambient names do not exist.
|
|
21
|
+
function tick() {
|
|
22
|
+
const set = Reflect.get(globalThis, 'setTimeout');
|
|
23
|
+
if (typeof set !== 'function') {
|
|
24
|
+
throw new Error('setTimeout is not available on the host');
|
|
25
|
+
}
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
Reflect.apply(set, globalThis, [resolve, TICK_MS]);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function now() {
|
|
31
|
+
return Date.now();
|
|
32
|
+
}
|
|
33
|
+
// Polls `condition` once per macrotask until it holds, then resolves. Throws on timeout with
|
|
34
|
+
// `label` in the message, so a genuine product failure still fails the test — loudly, and naming
|
|
35
|
+
// what never happened.
|
|
36
|
+
export async function waitUntil(condition, label, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
37
|
+
const deadline = now() + timeoutMs;
|
|
38
|
+
while (!condition()) {
|
|
39
|
+
if (now() > deadline) {
|
|
40
|
+
throw new Error(`waitUntil timed out after ${timeoutMs}ms: ${label}`);
|
|
41
|
+
}
|
|
42
|
+
await tick();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Waits until `sample` returns the same value across `stableTicks` consecutive macrotasks — the
|
|
46
|
+
// shape for "work has stopped arriving". Returns the settled value so a caller can assert against
|
|
47
|
+
// what actually happened rather than re-reading it.
|
|
48
|
+
//
|
|
49
|
+
// This is the honest way to write a "does not free-run" test: settle FIRST on a real condition,
|
|
50
|
+
// then observe. Asserting no-growth over a fixed window without settling first only tests whether
|
|
51
|
+
// the machine was fast enough.
|
|
52
|
+
export async function waitForQuiet(sample, label, stableTicks = 3, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
53
|
+
const deadline = now() + timeoutMs;
|
|
54
|
+
let last = sample();
|
|
55
|
+
let stable = 0;
|
|
56
|
+
while (stable < stableTicks) {
|
|
57
|
+
await tick();
|
|
58
|
+
const current = sample();
|
|
59
|
+
stable = current === last ? stable + 1 : 0;
|
|
60
|
+
last = current;
|
|
61
|
+
if (now() > deadline) {
|
|
62
|
+
throw new Error(`waitForQuiet timed out after ${timeoutMs}ms, still changing: ${label}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return last;
|
|
66
|
+
}
|
|
67
|
+
// Advances a fixed number of macrotasks. Kept for the cases where the wait genuinely is "let the
|
|
68
|
+
// queue drain once" — a commit that is known to be one microtask away — rather than a settle.
|
|
69
|
+
export async function advanceTicks(count) {
|
|
70
|
+
for (let index = 0; index < count; index += 1)
|
|
71
|
+
await tick();
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/test-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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
|
+
"license": "MIT",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "git+https://github.com/OneEyed1366/symbiote-native.git",
|