@symbiote-native/test-utils 0.1.6 → 0.3.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 +36 -5
- package/build/fake-fabric.d.ts +10 -1
- package/build/fake-fabric.js +77 -26
- package/build/index.d.ts +2 -0
- package/build/index.js +2 -0
- package/build/lowering-equivalence.d.ts +135 -0
- package/build/lowering-equivalence.js +224 -0
- package/build/wait-for.d.ts +11 -0
- package/build/wait-for.js +95 -0
- package/package.json +11 -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
|
|
|
@@ -64,6 +69,32 @@ stays `null`, so a test can tell "explicitly reset" apart from "never set"), and
|
|
|
64
69
|
throws on an illegal family reparent. A persistence bug in the fake is fixed once, here, for every
|
|
65
70
|
test that depends on it.
|
|
66
71
|
|
|
72
|
+
## Waiting for async settling
|
|
73
|
+
|
|
74
|
+
`waitUntil(condition, label, timeoutMs?)` polls once per macrotask until `condition()` holds and
|
|
75
|
+
throws (naming `label`) on timeout — the honest replacement for a fixed `setTimeout` tick count,
|
|
76
|
+
which is only a proxy for "the framework has settled" and breaks under a loaded test run.
|
|
77
|
+
`waitForQuiet(sample, label, stableTicks?, timeoutMs?)` waits until `sample()` returns the same
|
|
78
|
+
value across `stableTicks` consecutive macrotasks and returns that settled value — the shape for
|
|
79
|
+
"work has stopped arriving" (a batched commit, a zoneless change-detection pass, a press-timing
|
|
80
|
+
timer). `advanceTicks(count)` is kept for the genuine "let the queue drain N times" case. Do not
|
|
81
|
+
raise a tick count to fix a flaky test — that trades a fast failure for a slow one and keeps the
|
|
82
|
+
race; reach for `waitUntil`/`waitForQuiet` instead.
|
|
83
|
+
|
|
84
|
+
## The host-primitive lowering oracle
|
|
85
|
+
|
|
86
|
+
`normalizeCommitted(nodes)` strips per-mount identity (`tag`, `instanceHandle`,
|
|
87
|
+
`parentFamilyTag`) from a committed Fabric tree, and `compareLoweringEquivalence` /
|
|
88
|
+
`expectCommittedProps` compare two such trees — used by React, Vue, Svelte, and Solid to mount a
|
|
89
|
+
primitive (`Pressable`, `TextInput`, …) as a framework COMPONENT and as a lowered intrinsic tag
|
|
90
|
+
with the same props, and assert the two committed payloads agree. It exists because a lowered
|
|
91
|
+
element inherits nothing its wrapper component did — prop defaults, alias renames, and bag folds
|
|
92
|
+
all live in the wrapper — so a naive lowering silently drops them (a lost `ellipsizeMode`, a
|
|
93
|
+
never-applied `id -> nativeID`) with every other test green. Write BOTH assertions on every case:
|
|
94
|
+
`compareLoweringEquivalence` catches a fold one path lost, `expectCommittedProps` catches a fold
|
|
95
|
+
BOTH paths lost (an equivalence check alone can't tell "both arms agree" from "both arms are
|
|
96
|
+
broken the same way").
|
|
97
|
+
|
|
67
98
|
## What it does NOT do
|
|
68
99
|
|
|
69
100
|
- It is not a mocking framework — there's nothing to configure beyond calling `installFabric()`;
|
|
@@ -78,5 +109,5 @@ test that depends on it.
|
|
|
78
109
|
|
|
79
110
|
## Test it
|
|
80
111
|
|
|
81
|
-
This package has no tests of its own — it
|
|
112
|
+
This package has no tests of its own — it _is_ the test double every other package's `vitest` suite
|
|
82
113
|
imports.
|
package/build/fake-fabric.d.ts
CHANGED
|
@@ -19,10 +19,19 @@ export interface IFabricRecorder {
|
|
|
19
19
|
commandName: string;
|
|
20
20
|
args: readonly unknown[];
|
|
21
21
|
}>;
|
|
22
|
-
/**
|
|
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
|
+
*/
|
|
23
30
|
counts: {
|
|
24
31
|
createNode: number;
|
|
25
32
|
completeRoot: number;
|
|
33
|
+
appendChild: number;
|
|
34
|
+
clone: number;
|
|
26
35
|
};
|
|
27
36
|
/**
|
|
28
37
|
* RN wraps every commit in a synthetic `box-none` AppContainer root.
|
package/build/fake-fabric.js
CHANGED
|
@@ -1,45 +1,89 @@
|
|
|
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
|
}
|
|
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
|
+
}
|
|
19
37
|
export function installFabric() {
|
|
20
38
|
let committed = [];
|
|
21
39
|
const created = [];
|
|
22
40
|
const commands = [];
|
|
23
|
-
const counts = { createNode: 0, completeRoot: 0 };
|
|
41
|
+
const counts = { createNode: 0, completeRoot: 0, appendChild: 0, clone: 0 };
|
|
24
42
|
let eventHandler;
|
|
25
43
|
const slot = {
|
|
26
44
|
createNode(tag, viewName, _rootTag, props, instanceHandle) {
|
|
27
45
|
counts.createNode += 1;
|
|
28
|
-
const node = {
|
|
46
|
+
const node = {
|
|
47
|
+
tag,
|
|
48
|
+
viewName,
|
|
49
|
+
props,
|
|
50
|
+
children: [],
|
|
51
|
+
instanceHandle,
|
|
52
|
+
};
|
|
29
53
|
created.push(node);
|
|
30
54
|
return node;
|
|
31
55
|
},
|
|
32
|
-
cloneNodeWithNewProps: (node, newProps) =>
|
|
33
|
-
|
|
34
|
-
props: mergeFabricProps(node.props, newProps)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
},
|
|
38
83
|
createChildSet: () => [],
|
|
39
84
|
appendChild(parent, child) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
85
|
+
counts.appendChild += 1;
|
|
86
|
+
assertSameFamily(parent, child);
|
|
43
87
|
child.parentFamilyTag = parent.tag;
|
|
44
88
|
parent.children.push(child);
|
|
45
89
|
return parent;
|
|
@@ -61,7 +105,9 @@ export function installFabric() {
|
|
|
61
105
|
Object.assign(globalThis, { nativeFabricUIManager: slot });
|
|
62
106
|
const serializeNode = (node) => {
|
|
63
107
|
const text = node.viewName === 'RCTRawText' ? ` "${String(node.props.text)}"` : '';
|
|
64
|
-
const kids = node.children.length
|
|
108
|
+
const kids = node.children.length
|
|
109
|
+
? `(${node.children.map(serializeNode).join('')})`
|
|
110
|
+
: '';
|
|
65
111
|
return `${node.viewName}${text}${kids}`;
|
|
66
112
|
};
|
|
67
113
|
return {
|
|
@@ -93,8 +139,13 @@ export function installFabric() {
|
|
|
93
139
|
committed = [];
|
|
94
140
|
created.length = 0;
|
|
95
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.
|
|
96
145
|
counts.createNode = 0;
|
|
97
146
|
counts.completeRoot = 0;
|
|
147
|
+
counts.appendChild = 0;
|
|
148
|
+
counts.clone = 0;
|
|
98
149
|
},
|
|
99
150
|
};
|
|
100
151
|
}
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { IFakeNode } from './fake-fabric';
|
|
2
|
+
/**
|
|
3
|
+
* A committed node stripped of per-mount identity. `tag`, `instanceHandle` and `parentFamilyTag`
|
|
4
|
+
* differ between two mounts of the same tree by construction, so comparing them would fail every
|
|
5
|
+
* correct adapter.
|
|
6
|
+
*/
|
|
7
|
+
export interface ICommittedShape {
|
|
8
|
+
viewName: string;
|
|
9
|
+
props: Record<string, unknown>;
|
|
10
|
+
children: ICommittedShape[];
|
|
11
|
+
}
|
|
12
|
+
export declare function normalizeCommitted(nodes: readonly IFakeNode[]): ICommittedShape[];
|
|
13
|
+
/**
|
|
14
|
+
* The result of one comparison. Differences are returned rather than thrown so a caller can assert
|
|
15
|
+
* on an EMPTY ARRAY and have its runner print the whole list at once — a thrown error reports the
|
|
16
|
+
* first mismatch and hides the rest, and the interesting failures here are plural (six missing keys
|
|
17
|
+
* across three nodes was the real one).
|
|
18
|
+
*/
|
|
19
|
+
export interface IEquivalenceResult {
|
|
20
|
+
equal: boolean;
|
|
21
|
+
differences: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The assertion. Compare two committed trees taken from two separate mounts of the same props.
|
|
25
|
+
*
|
|
26
|
+
* The committed tree is the right subject and the RETAINED tree is not: anchors live only in the
|
|
27
|
+
* retained tree and are exactly what lowering removes, so the two retained trees legitimately
|
|
28
|
+
* differ. A committed tree holds no anchors, so it must match exactly, `RCTRawText` children
|
|
29
|
+
* included.
|
|
30
|
+
*/
|
|
31
|
+
export declare function compareLoweringEquivalence(componentTree: readonly IFakeNode[], loweredTree: readonly IFakeNode[]): IEquivalenceResult;
|
|
32
|
+
/**
|
|
33
|
+
* THE SECOND ASSERTION EVERY CASE OWES — an ABSOLUTE expectation, not a comparison.
|
|
34
|
+
*
|
|
35
|
+
* Find the committed node carrying `testID` and require `expected`'s keys to be present with those
|
|
36
|
+
* values. Extra keys are allowed: this asserts that a fold RAN, and pinning the whole payload would
|
|
37
|
+
* turn every unrelated prop addition into a failure here instead of in the test that owns it.
|
|
38
|
+
*
|
|
39
|
+
* Pass the value a fold PRODUCES, never the one the author wrote — `{ nativeID: 'x' }` for an
|
|
40
|
+
* authored `id="x"`, `{ ellipsizeMode: 'tail' }` for a `<Text>` that set none. An expectation
|
|
41
|
+
* restating the input passes with the fold deleted and is the same false green one level down.
|
|
42
|
+
*/
|
|
43
|
+
export declare function expectCommittedProps(tree: readonly IFakeNode[], testID: string, expected: Record<string, unknown>): IEquivalenceResult;
|
|
44
|
+
/**
|
|
45
|
+
* THE GUARD AGAINST THE MOST LIKELY FALSE GREEN: both arms taking the SAME path.
|
|
46
|
+
*
|
|
47
|
+
* Angular is the live instance — its primitives carry a dual selector (`'symbiote-view, View'`) and
|
|
48
|
+
* directive matching is resolved per TEMPLATE, so writing the intrinsic inside a template that
|
|
49
|
+
* imports the component resolves straight back to the component, silently. The general form is any
|
|
50
|
+
* adapter where the "lowered" snippet is hand-written intrinsic markup the renderer happens to route
|
|
51
|
+
* through the wrapper anyway. Two identical arms agree perfectly and prove nothing.
|
|
52
|
+
*
|
|
53
|
+
* The discriminator here is the RETAINED tree: a component form allocates a wrapper the lowered
|
|
54
|
+
* form does not, so the counts cannot be equal.
|
|
55
|
+
*
|
|
56
|
+
* !! THAT PREMISE IS PER-ADAPTER AND IT IS FALSE ON AT LEAST TWO OF THE FIVE. Corrected 2026-09-01,
|
|
57
|
+
* after the Solid and React arms hit it independently within the hour. A component only allocates a
|
|
58
|
+
* retained node if the FRAMEWORK gives it one:
|
|
59
|
+
*
|
|
60
|
+
* svelte HALF TRUE, and corrected by the svelte arm the same day: it holds only for a
|
|
61
|
+
* CHILDREN-BEARING wrapper, because the anchors are what cost the node. `Switch` and
|
|
62
|
+
* `TextInput` render one childless element with `p={descriptor.props}` and BOTH arms
|
|
63
|
+
* retain exactly one node — so this control fired on two correct arms before it ever
|
|
64
|
+
* reached the two real divergences in that file
|
|
65
|
+
* angular a per-component host element counts differ -> this control holds
|
|
66
|
+
* solid a component is a plain function returning the same host node — MEASURED
|
|
67
|
+
* byte-identical: {nodes:1, anchors:0, renderable:1} on both arms
|
|
68
|
+
* react the wrappers render the intrinsic themselves, so both arms build one node
|
|
69
|
+
* vue `hostComponent()` is a FunctionalComponent, so expected to match — UNMEASURED
|
|
70
|
+
*
|
|
71
|
+
* On an adapter where the counts legitimately agree this control fires on a CORRECT arm, which is
|
|
72
|
+
* worse than not having it: a control that cries wolf gets deleted, and the real guard goes with it.
|
|
73
|
+
*
|
|
74
|
+
* So each adapter owes a discriminator whose premise holds FOR IT, stated where it is used. THREE
|
|
75
|
+
* SHAPES EXIST, one per adapter family, and none is a fallback for another:
|
|
76
|
+
*
|
|
77
|
+
* node count this function. Holds where a component allocates a retained node —
|
|
78
|
+
* Svelte's anchors, Angular's per-component host. On Svelte it holds only
|
|
79
|
+
* for a CHILDREN-BEARING wrapper: `Switch` and `TextInput` render a single
|
|
80
|
+
* childless element and both arms retain one node. On ANGULAR it holds only
|
|
81
|
+
* for a COMPOSED component: `View`/`Text` are `SymbiotePrimitiveHost`s whose
|
|
82
|
+
* component IS the node, so both arms census 1.
|
|
83
|
+
* template text Angular's, and it exists because of the trap below. Both arms are built
|
|
84
|
+
* from template STRINGS, so the arm can assert that only the lowered one
|
|
85
|
+
* spells the intrinsic. Weaker than reading compiled output — it proves the
|
|
86
|
+
* author wrote two different tags, not that Angular resolved them
|
|
87
|
+
* differently — and it is the only thing left when no counter separates them.
|
|
88
|
+
* source spelling only the lowered arm names the BARE intrinsic, since a wrapper renders the
|
|
89
|
+
* `-managed` spelling. The strongest of the three — no runtime coincidence
|
|
90
|
+
* satisfies it — and available only where the arm can read its own source.
|
|
91
|
+
* compiled output `_$createComponent(View, …)` vs `_$createElement("symbiote-view")`. Solid's,
|
|
92
|
+
* because its arms are JSX compiled by the runner: there is no source to read
|
|
93
|
+
* at assertion time, and this is the same claim reachable from where it stands.
|
|
94
|
+
*
|
|
95
|
+
* A DISABLED discriminator can be disabled exactly where its trap lives, and only a break-test
|
|
96
|
+
* says so. Measured on Angular 2026-09-02: the node-count control was switched off for `View` and
|
|
97
|
+
* `Text` because their arms legitimately census equal — and those two are the ONLY primitives
|
|
98
|
+
* carrying the dual selector `'symbiote-view, View'`, i.e. the one construct that makes an
|
|
99
|
+
* intrinsic resolve back to its component. Handing the lowered arm its imports, which should have
|
|
100
|
+
* reproduced the trap, left all nine rows GREEN. The five rows where the control WAS active have
|
|
101
|
+
* single-name selectors and could never have shown it.
|
|
102
|
+
*
|
|
103
|
+
* So: when a control is scoped off for some members, ask whether the hazard it guards is
|
|
104
|
+
* concentrated in the members you excluded. Here it was entirely there.
|
|
105
|
+
*
|
|
106
|
+
* Do not generalise any of them into this file. Which artifact distinguishes the two paths is a fact
|
|
107
|
+
* about a framework, and this repo has a standing rule against absorbing those into shared code —
|
|
108
|
+
* a shared second control would force one answer onto five adapters, which is the mistake this
|
|
109
|
+
* correction exists to undo.
|
|
110
|
+
*
|
|
111
|
+
* THE SHAPE THAT TRANSFERS, offered as a pattern and deliberately NOT as a function here, for the
|
|
112
|
+
* reason the paragraph above gives. The one thing lowering must change on every adapter is the TAG:
|
|
113
|
+
* a wrapper renders the `-managed` spelling or a component boundary, and only the lowered path can
|
|
114
|
+
* name the bare intrinsic the spec declares. So the question each arm can ask in its own idiom is
|
|
115
|
+
* "does ONLY the lowered artifact name `HOST_PRIMITIVES[name].intrinsic`" — Svelte asks it of its
|
|
116
|
+
* two source strings, Solid of its compiled output, and an adapter with no compile step has to find
|
|
117
|
+
* its own answer. Two properties are worth keeping when you translate it: assert on the ARTIFACT
|
|
118
|
+
* rather than on runtime state, so no coincidence at mount can satisfy it, and assert the component
|
|
119
|
+
* arm does NOT name the intrinsic, or the check passes on two lowered arms.
|
|
120
|
+
*
|
|
121
|
+
* Match the tag with a boundary. `symbiote-text` is a prefix of `symbiote-text-input` and
|
|
122
|
+
* `symbiote-switch` of `symbiote-switch-managed`, so a bare substring test reads a wrapper's
|
|
123
|
+
* `-managed` output as the bare intrinsic and certifies two arms that are the same arm.
|
|
124
|
+
*
|
|
125
|
+
* Counts are passed IN rather than computed here: `censusRetainedTree` lives in the engine, and
|
|
126
|
+
* this package must not depend on it — test-utils is imported by the engine's own suite, so the
|
|
127
|
+
* edge would be a cycle. Each caller reads the census from the engine it already imports.
|
|
128
|
+
*/
|
|
129
|
+
export declare function assertArmsAreDistinct(componentRetainedNodes: number, loweredRetainedNodes: number): IEquivalenceResult;
|
|
130
|
+
/**
|
|
131
|
+
* THE SECOND FALSE GREEN: both arms empty. `committed` is `[]` until `completeRoot` runs, and two
|
|
132
|
+
* empty trees compare equal, so a wrong flush count silently compares nothing at all. Every arm must
|
|
133
|
+
* prove it committed something before its diff is believed.
|
|
134
|
+
*/
|
|
135
|
+
export declare function assertCommittedSomething(tree: readonly IFakeNode[], armName: string): IEquivalenceResult;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
export function normalizeCommitted(nodes) {
|
|
2
|
+
return nodes.map(node => ({
|
|
3
|
+
viewName: node.viewName,
|
|
4
|
+
props: { ...node.props },
|
|
5
|
+
children: normalizeCommitted(node.children),
|
|
6
|
+
}));
|
|
7
|
+
}
|
|
8
|
+
function describePath(path) {
|
|
9
|
+
return path.length === 0 ? '<root>' : path.join(' > ');
|
|
10
|
+
}
|
|
11
|
+
// Props are compared by KEY NAME and value, never by count. Counting is what cost a day: two arms
|
|
12
|
+
// agreeing on a total while disagreeing on which keys make it up is a coincidence, not equivalence.
|
|
13
|
+
//
|
|
14
|
+
// `undefined` needs no special case, and adding one would HIDE a defect. `setProp` deletes a key
|
|
15
|
+
// written as `undefined` and no-ops when it was never there, and `setEventListener` derives its
|
|
16
|
+
// gate flag from `typeof value === 'function'` — so an absent key and an `undefined` key commit
|
|
17
|
+
// identically. An adapter whose diff fails to route a disappeared key is exactly what must fail.
|
|
18
|
+
function diffProps(path, component, lowered, differences) {
|
|
19
|
+
const keys = new Set([...Object.keys(component), ...Object.keys(lowered)]);
|
|
20
|
+
for (const key of [...keys].sort()) {
|
|
21
|
+
const inComponent = Object.hasOwn(component, key);
|
|
22
|
+
const inLowered = Object.hasOwn(lowered, key);
|
|
23
|
+
if (!inLowered) {
|
|
24
|
+
differences.push(`${describePath(path)}: lowered is MISSING "${key}" (component has ${JSON.stringify(component[key])}) — a fold the wrapper applied and the lowered path does not`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!inComponent) {
|
|
28
|
+
differences.push(`${describePath(path)}: lowered has EXTRA "${key}" = ${JSON.stringify(lowered[key])}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const left = JSON.stringify(component[key]);
|
|
32
|
+
const right = JSON.stringify(lowered[key]);
|
|
33
|
+
if (left !== right) {
|
|
34
|
+
differences.push(`${describePath(path)}: "${key}" differs — component ${left}, lowered ${right}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function diffShapes(path, component, lowered, differences) {
|
|
39
|
+
if (component.length !== lowered.length) {
|
|
40
|
+
differences.push(`${describePath(path)}: child count differs — component ${component.length}, lowered ${lowered.length}`);
|
|
41
|
+
}
|
|
42
|
+
const count = Math.max(component.length, lowered.length);
|
|
43
|
+
for (let index = 0; index < count; index += 1) {
|
|
44
|
+
const left = component[index];
|
|
45
|
+
const right = lowered[index];
|
|
46
|
+
if (left === undefined || right === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
const here = [...path, `${left.viewName}[${index}]`];
|
|
49
|
+
if (left.viewName !== right.viewName) {
|
|
50
|
+
differences.push(`${describePath(path)}: child ${index} is ${left.viewName} on the component path and ${right.viewName} on the lowered one`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
diffProps(here, left.props, right.props, differences);
|
|
54
|
+
diffShapes(here, left.children, right.children, differences);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The assertion. Compare two committed trees taken from two separate mounts of the same props.
|
|
59
|
+
*
|
|
60
|
+
* The committed tree is the right subject and the RETAINED tree is not: anchors live only in the
|
|
61
|
+
* retained tree and are exactly what lowering removes, so the two retained trees legitimately
|
|
62
|
+
* differ. A committed tree holds no anchors, so it must match exactly, `RCTRawText` children
|
|
63
|
+
* included.
|
|
64
|
+
*/
|
|
65
|
+
export function compareLoweringEquivalence(componentTree, loweredTree) {
|
|
66
|
+
const differences = [];
|
|
67
|
+
diffShapes([], normalizeCommitted(componentTree), normalizeCommitted(loweredTree), differences);
|
|
68
|
+
return { equal: differences.length === 0, differences };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* THE SECOND ASSERTION EVERY CASE OWES — an ABSOLUTE expectation, not a comparison.
|
|
72
|
+
*
|
|
73
|
+
* Find the committed node carrying `testID` and require `expected`'s keys to be present with those
|
|
74
|
+
* values. Extra keys are allowed: this asserts that a fold RAN, and pinning the whole payload would
|
|
75
|
+
* turn every unrelated prop addition into a failure here instead of in the test that owns it.
|
|
76
|
+
*
|
|
77
|
+
* Pass the value a fold PRODUCES, never the one the author wrote — `{ nativeID: 'x' }` for an
|
|
78
|
+
* authored `id="x"`, `{ ellipsizeMode: 'tail' }` for a `<Text>` that set none. An expectation
|
|
79
|
+
* restating the input passes with the fold deleted and is the same false green one level down.
|
|
80
|
+
*/
|
|
81
|
+
export function expectCommittedProps(tree, testID, expected) {
|
|
82
|
+
const differences = [];
|
|
83
|
+
const found = findByTestID(tree, testID);
|
|
84
|
+
if (found === undefined) {
|
|
85
|
+
return {
|
|
86
|
+
equal: false,
|
|
87
|
+
differences: [
|
|
88
|
+
`no committed node carries testID "${testID}" — the mount did not flush, or the prop never reached the payload`,
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
for (const key of Object.keys(expected).sort()) {
|
|
93
|
+
const actual = JSON.stringify(found.props[key]);
|
|
94
|
+
const wanted = JSON.stringify(expected[key]);
|
|
95
|
+
if (actual !== wanted) {
|
|
96
|
+
differences.push(`${testID}: "${key}" is ${actual}, expected ${wanted} — the fold that produces it did not run on this path`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { equal: differences.length === 0, differences };
|
|
100
|
+
}
|
|
101
|
+
function findByTestID(nodes, testID) {
|
|
102
|
+
for (const node of nodes) {
|
|
103
|
+
if (node.props.testID === testID)
|
|
104
|
+
return node;
|
|
105
|
+
const found = findByTestID(node.children, testID);
|
|
106
|
+
if (found !== undefined)
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* THE GUARD AGAINST THE MOST LIKELY FALSE GREEN: both arms taking the SAME path.
|
|
113
|
+
*
|
|
114
|
+
* Angular is the live instance — its primitives carry a dual selector (`'symbiote-view, View'`) and
|
|
115
|
+
* directive matching is resolved per TEMPLATE, so writing the intrinsic inside a template that
|
|
116
|
+
* imports the component resolves straight back to the component, silently. The general form is any
|
|
117
|
+
* adapter where the "lowered" snippet is hand-written intrinsic markup the renderer happens to route
|
|
118
|
+
* through the wrapper anyway. Two identical arms agree perfectly and prove nothing.
|
|
119
|
+
*
|
|
120
|
+
* The discriminator here is the RETAINED tree: a component form allocates a wrapper the lowered
|
|
121
|
+
* form does not, so the counts cannot be equal.
|
|
122
|
+
*
|
|
123
|
+
* !! THAT PREMISE IS PER-ADAPTER AND IT IS FALSE ON AT LEAST TWO OF THE FIVE. Corrected 2026-09-01,
|
|
124
|
+
* after the Solid and React arms hit it independently within the hour. A component only allocates a
|
|
125
|
+
* retained node if the FRAMEWORK gives it one:
|
|
126
|
+
*
|
|
127
|
+
* svelte HALF TRUE, and corrected by the svelte arm the same day: it holds only for a
|
|
128
|
+
* CHILDREN-BEARING wrapper, because the anchors are what cost the node. `Switch` and
|
|
129
|
+
* `TextInput` render one childless element with `p={descriptor.props}` and BOTH arms
|
|
130
|
+
* retain exactly one node — so this control fired on two correct arms before it ever
|
|
131
|
+
* reached the two real divergences in that file
|
|
132
|
+
* angular a per-component host element counts differ -> this control holds
|
|
133
|
+
* solid a component is a plain function returning the same host node — MEASURED
|
|
134
|
+
* byte-identical: {nodes:1, anchors:0, renderable:1} on both arms
|
|
135
|
+
* react the wrappers render the intrinsic themselves, so both arms build one node
|
|
136
|
+
* vue `hostComponent()` is a FunctionalComponent, so expected to match — UNMEASURED
|
|
137
|
+
*
|
|
138
|
+
* On an adapter where the counts legitimately agree this control fires on a CORRECT arm, which is
|
|
139
|
+
* worse than not having it: a control that cries wolf gets deleted, and the real guard goes with it.
|
|
140
|
+
*
|
|
141
|
+
* So each adapter owes a discriminator whose premise holds FOR IT, stated where it is used. THREE
|
|
142
|
+
* SHAPES EXIST, one per adapter family, and none is a fallback for another:
|
|
143
|
+
*
|
|
144
|
+
* node count this function. Holds where a component allocates a retained node —
|
|
145
|
+
* Svelte's anchors, Angular's per-component host. On Svelte it holds only
|
|
146
|
+
* for a CHILDREN-BEARING wrapper: `Switch` and `TextInput` render a single
|
|
147
|
+
* childless element and both arms retain one node. On ANGULAR it holds only
|
|
148
|
+
* for a COMPOSED component: `View`/`Text` are `SymbiotePrimitiveHost`s whose
|
|
149
|
+
* component IS the node, so both arms census 1.
|
|
150
|
+
* template text Angular's, and it exists because of the trap below. Both arms are built
|
|
151
|
+
* from template STRINGS, so the arm can assert that only the lowered one
|
|
152
|
+
* spells the intrinsic. Weaker than reading compiled output — it proves the
|
|
153
|
+
* author wrote two different tags, not that Angular resolved them
|
|
154
|
+
* differently — and it is the only thing left when no counter separates them.
|
|
155
|
+
* source spelling only the lowered arm names the BARE intrinsic, since a wrapper renders the
|
|
156
|
+
* `-managed` spelling. The strongest of the three — no runtime coincidence
|
|
157
|
+
* satisfies it — and available only where the arm can read its own source.
|
|
158
|
+
* compiled output `_$createComponent(View, …)` vs `_$createElement("symbiote-view")`. Solid's,
|
|
159
|
+
* because its arms are JSX compiled by the runner: there is no source to read
|
|
160
|
+
* at assertion time, and this is the same claim reachable from where it stands.
|
|
161
|
+
*
|
|
162
|
+
* A DISABLED discriminator can be disabled exactly where its trap lives, and only a break-test
|
|
163
|
+
* says so. Measured on Angular 2026-09-02: the node-count control was switched off for `View` and
|
|
164
|
+
* `Text` because their arms legitimately census equal — and those two are the ONLY primitives
|
|
165
|
+
* carrying the dual selector `'symbiote-view, View'`, i.e. the one construct that makes an
|
|
166
|
+
* intrinsic resolve back to its component. Handing the lowered arm its imports, which should have
|
|
167
|
+
* reproduced the trap, left all nine rows GREEN. The five rows where the control WAS active have
|
|
168
|
+
* single-name selectors and could never have shown it.
|
|
169
|
+
*
|
|
170
|
+
* So: when a control is scoped off for some members, ask whether the hazard it guards is
|
|
171
|
+
* concentrated in the members you excluded. Here it was entirely there.
|
|
172
|
+
*
|
|
173
|
+
* Do not generalise any of them into this file. Which artifact distinguishes the two paths is a fact
|
|
174
|
+
* about a framework, and this repo has a standing rule against absorbing those into shared code —
|
|
175
|
+
* a shared second control would force one answer onto five adapters, which is the mistake this
|
|
176
|
+
* correction exists to undo.
|
|
177
|
+
*
|
|
178
|
+
* THE SHAPE THAT TRANSFERS, offered as a pattern and deliberately NOT as a function here, for the
|
|
179
|
+
* reason the paragraph above gives. The one thing lowering must change on every adapter is the TAG:
|
|
180
|
+
* a wrapper renders the `-managed` spelling or a component boundary, and only the lowered path can
|
|
181
|
+
* name the bare intrinsic the spec declares. So the question each arm can ask in its own idiom is
|
|
182
|
+
* "does ONLY the lowered artifact name `HOST_PRIMITIVES[name].intrinsic`" — Svelte asks it of its
|
|
183
|
+
* two source strings, Solid of its compiled output, and an adapter with no compile step has to find
|
|
184
|
+
* its own answer. Two properties are worth keeping when you translate it: assert on the ARTIFACT
|
|
185
|
+
* rather than on runtime state, so no coincidence at mount can satisfy it, and assert the component
|
|
186
|
+
* arm does NOT name the intrinsic, or the check passes on two lowered arms.
|
|
187
|
+
*
|
|
188
|
+
* Match the tag with a boundary. `symbiote-text` is a prefix of `symbiote-text-input` and
|
|
189
|
+
* `symbiote-switch` of `symbiote-switch-managed`, so a bare substring test reads a wrapper's
|
|
190
|
+
* `-managed` output as the bare intrinsic and certifies two arms that are the same arm.
|
|
191
|
+
*
|
|
192
|
+
* Counts are passed IN rather than computed here: `censusRetainedTree` lives in the engine, and
|
|
193
|
+
* this package must not depend on it — test-utils is imported by the engine's own suite, so the
|
|
194
|
+
* edge would be a cycle. Each caller reads the census from the engine it already imports.
|
|
195
|
+
*/
|
|
196
|
+
export function assertArmsAreDistinct(componentRetainedNodes, loweredRetainedNodes) {
|
|
197
|
+
if (componentRetainedNodes === loweredRetainedNodes) {
|
|
198
|
+
return {
|
|
199
|
+
equal: false,
|
|
200
|
+
differences: [
|
|
201
|
+
`both arms retained ${componentRetainedNodes} nodes — the lowered arm did not lower. ` +
|
|
202
|
+
`Check that the intrinsic spelling is not resolving back to the component (a dual ` +
|
|
203
|
+
`selector, or a template that still imports the wrapper).`,
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return { equal: true, differences: [] };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* THE SECOND FALSE GREEN: both arms empty. `committed` is `[]` until `completeRoot` runs, and two
|
|
211
|
+
* empty trees compare equal, so a wrong flush count silently compares nothing at all. Every arm must
|
|
212
|
+
* prove it committed something before its diff is believed.
|
|
213
|
+
*/
|
|
214
|
+
export function assertCommittedSomething(tree, armName) {
|
|
215
|
+
const count = normalizeCommitted(tree).length;
|
|
216
|
+
return count > 0
|
|
217
|
+
? { equal: true, differences: [] }
|
|
218
|
+
: {
|
|
219
|
+
equal: false,
|
|
220
|
+
differences: [
|
|
221
|
+
`the ${armName} arm committed nothing — the mount did not flush, so the diff below compares two empty trees`,
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function waitUntil(condition: () => boolean, label: string, timeoutMs?: number): Promise<void>;
|
|
2
|
+
export interface IQuietOptions {
|
|
3
|
+
/** Consecutive macrotasks the sample must hold. */
|
|
4
|
+
readonly stableTicks?: number;
|
|
5
|
+
/** Wall time the sample must hold, so the settle does not measure machine speed. */
|
|
6
|
+
readonly quietMs?: number;
|
|
7
|
+
readonly timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function waitForQuiet(sample: () => number, label: string, options?: IQuietOptions): Promise<number>;
|
|
10
|
+
export declare function advanceTicks(count: number): Promise<void>;
|
|
11
|
+
export declare function advanceMs(durationMs?: number): Promise<void>;
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
// A tick count cannot express "no work arrived", only "the queue drained N times" — and how much
|
|
20
|
+
// wall time that spans is a property of the machine. Measured 2026-09-02 on the FlatList window:
|
|
21
|
+
// exactly one deferred commit lands 30-60 ticks after a 3-tick settle, so an idle machine declared
|
|
22
|
+
// quiet before the list's own batch and a loaded one caught it. The producer is RN's VirtualizedList
|
|
23
|
+
// batching period (50ms), the longest deferred one in this repo; quiet has to outlast it.
|
|
24
|
+
const DEFAULT_QUIET_MS = 75;
|
|
25
|
+
// Read off the host rather than imported, the same reason the engine's animations/raf.ts does:
|
|
26
|
+
// this package's tsconfig carries no DOM and no Node lib, so the ambient names do not exist.
|
|
27
|
+
function tick() {
|
|
28
|
+
const set = Reflect.get(globalThis, 'setTimeout');
|
|
29
|
+
if (typeof set !== 'function') {
|
|
30
|
+
throw new Error('setTimeout is not available on the host');
|
|
31
|
+
}
|
|
32
|
+
return new Promise(resolve => {
|
|
33
|
+
Reflect.apply(set, globalThis, [resolve, TICK_MS]);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function now() {
|
|
37
|
+
return Date.now();
|
|
38
|
+
}
|
|
39
|
+
// Polls `condition` once per macrotask until it holds, then resolves. Throws on timeout with
|
|
40
|
+
// `label` in the message, so a genuine product failure still fails the test — loudly, and naming
|
|
41
|
+
// what never happened.
|
|
42
|
+
export async function waitUntil(condition, label, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
43
|
+
const deadline = now() + timeoutMs;
|
|
44
|
+
while (!condition()) {
|
|
45
|
+
if (now() > deadline) {
|
|
46
|
+
throw new Error(`waitUntil timed out after ${timeoutMs}ms: ${label}`);
|
|
47
|
+
}
|
|
48
|
+
await tick();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Waits until `sample` stops changing — for both `stableTicks` consecutive macrotasks and
|
|
52
|
+
// `quietMs` of wall time. Returns the settled value so a caller can assert against what actually
|
|
53
|
+
// happened rather than re-reading it.
|
|
54
|
+
//
|
|
55
|
+
// This is the honest way to write a "does not free-run" test: settle FIRST on a real condition,
|
|
56
|
+
// then observe. Asserting no-growth over a fixed window without settling first only tests whether
|
|
57
|
+
// the machine was fast enough — and a tick-only settle has the same defect one layer in, which is
|
|
58
|
+
// why quiet is a duration here and not a count.
|
|
59
|
+
export async function waitForQuiet(sample, label, options = {}) {
|
|
60
|
+
const { stableTicks = 3, quietMs = DEFAULT_QUIET_MS, timeoutMs = DEFAULT_TIMEOUT_MS, } = options;
|
|
61
|
+
const deadline = now() + timeoutMs;
|
|
62
|
+
let last = sample();
|
|
63
|
+
let stable = 0;
|
|
64
|
+
let quietSince = now();
|
|
65
|
+
while (stable < stableTicks || now() - quietSince < quietMs) {
|
|
66
|
+
await tick();
|
|
67
|
+
const current = sample();
|
|
68
|
+
if (current === last) {
|
|
69
|
+
stable += 1;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
stable = 0;
|
|
73
|
+
quietSince = now();
|
|
74
|
+
}
|
|
75
|
+
last = current;
|
|
76
|
+
if (now() > deadline) {
|
|
77
|
+
throw new Error(`waitForQuiet timed out after ${timeoutMs}ms, still changing: ${label}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return last;
|
|
81
|
+
}
|
|
82
|
+
// Advances a fixed number of macrotasks. Kept for the cases where the wait genuinely is "let the
|
|
83
|
+
// queue drain once" — a commit that is known to be one microtask away — rather than a settle.
|
|
84
|
+
export async function advanceTicks(count) {
|
|
85
|
+
for (let index = 0; index < count; index += 1)
|
|
86
|
+
await tick();
|
|
87
|
+
}
|
|
88
|
+
// Observes for a DURATION rather than a tick count. The counterpart to `waitForQuiet` for the
|
|
89
|
+
// second half of a "does not free-run" test: the window in which a late producer would show up is
|
|
90
|
+
// wall time, so the observation has to be too.
|
|
91
|
+
export async function advanceMs(durationMs = DEFAULT_QUIET_MS) {
|
|
92
|
+
const until = now() + durationMs;
|
|
93
|
+
while (now() < until)
|
|
94
|
+
await tick();
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/test-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
"main": "./build/index.js",
|
|
18
18
|
"module": "./build/index.js",
|
|
19
19
|
"types": "./build/index.d.ts",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"react-native",
|
|
22
|
+
"symbiote-native",
|
|
23
|
+
"testing",
|
|
24
|
+
"test-utils",
|
|
25
|
+
"fabric",
|
|
26
|
+
"vitest",
|
|
27
|
+
"test-harness",
|
|
28
|
+
"mock"
|
|
29
|
+
],
|
|
20
30
|
"exports": {
|
|
21
31
|
".": {
|
|
22
32
|
"types": "./build/index.d.ts",
|