@symbiote-native/test-utils 0.2.0 → 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 CHANGED
@@ -69,6 +69,32 @@ stays `null`, so a test can tell "explicitly reset" apart from "never set"), and
69
69
  throws on an illegal family reparent. A persistence bug in the fake is fixed once, here, for every
70
70
  test that depends on it.
71
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
+
72
98
  ## What it does NOT do
73
99
 
74
100
  - It is not a mocking framework — there's nothing to configure beyond calling `installFabric()`;
@@ -19,10 +19,19 @@ export interface IFabricRecorder {
19
19
  commandName: string;
20
20
  args: readonly unknown[];
21
21
  }>;
22
- /** Call counters, for tests that assert "exactly N native nodes were created". */
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.
@@ -10,11 +10,35 @@
10
10
  function mergeFabricProps(previous, diff) {
11
11
  return { ...previous, ...diff };
12
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
+ }
13
37
  export function installFabric() {
14
38
  let committed = [];
15
39
  const created = [];
16
40
  const commands = [];
17
- const counts = { createNode: 0, completeRoot: 0 };
41
+ const counts = { createNode: 0, completeRoot: 0, appendChild: 0, clone: 0 };
18
42
  let eventHandler;
19
43
  const slot = {
20
44
  createNode(tag, viewName, _rootTag, props, instanceHandle) {
@@ -29,25 +53,37 @@ export function installFabric() {
29
53
  created.push(node);
30
54
  return node;
31
55
  },
32
- cloneNodeWithNewProps: (node, newProps) => ({
33
- ...node,
34
- props: mergeFabricProps(node.props, newProps),
35
- }),
36
- cloneNodeWithNewChildren: (node) => ({
37
- ...node,
38
- children: [],
39
- }),
40
- cloneNodeWithNewChildrenAndProps: (node, newProps) => ({
41
- ...node,
42
- props: mergeFabricProps(node.props, newProps),
43
- children: [],
44
- }),
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
+ },
45
83
  createChildSet: () => [],
46
84
  appendChild(parent, child) {
47
- if (child.parentFamilyTag !== undefined &&
48
- child.parentFamilyTag !== parent.tag) {
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}`);
50
- }
85
+ counts.appendChild += 1;
86
+ assertSameFamily(parent, child);
51
87
  child.parentFamilyTag = parent.tag;
52
88
  parent.children.push(child);
53
89
  return parent;
@@ -103,8 +139,13 @@ export function installFabric() {
103
139
  committed = [];
104
140
  created.length = 0;
105
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.
106
145
  counts.createNode = 0;
107
146
  counts.completeRoot = 0;
147
+ counts.appendChild = 0;
148
+ counts.clone = 0;
108
149
  },
109
150
  };
110
151
  }
package/build/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './fake-fabric';
2
+ export * from './lowering-equivalence';
2
3
  export * from './wait-for';
package/build/index.js CHANGED
@@ -2,4 +2,5 @@
2
2
  // the co-located tests across engine, adapters, and the example apps so the fake-Fabric
3
3
  // recorder and its helpers live in exactly one place.
4
4
  export * from './fake-fabric.js';
5
+ export * from './lowering-equivalence.js';
5
6
  export * from './wait-for.js';
@@ -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
+ }
@@ -1,3 +1,11 @@
1
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>;
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>;
3
10
  export declare function advanceTicks(count: number): Promise<void>;
11
+ export declare function advanceMs(durationMs?: number): Promise<void>;
package/build/wait-for.js CHANGED
@@ -16,6 +16,12 @@
16
16
  // that trades a fast failure for a slow one and keeps the race.
17
17
  const TICK_MS = 0;
18
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;
19
25
  // Read off the host rather than imported, the same reason the engine's animations/raf.ts does:
20
26
  // this package's tsconfig carries no DOM and no Node lib, so the ambient names do not exist.
21
27
  function tick() {
@@ -42,21 +48,30 @@ export async function waitUntil(condition, label, timeoutMs = DEFAULT_TIMEOUT_MS
42
48
  await tick();
43
49
  }
44
50
  }
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.
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.
48
54
  //
49
55
  // This is the honest way to write a "does not free-run" test: settle FIRST on a real condition,
50
56
  // 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) {
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;
53
61
  const deadline = now() + timeoutMs;
54
62
  let last = sample();
55
63
  let stable = 0;
56
- while (stable < stableTicks) {
64
+ let quietSince = now();
65
+ while (stable < stableTicks || now() - quietSince < quietMs) {
57
66
  await tick();
58
67
  const current = sample();
59
- stable = current === last ? stable + 1 : 0;
68
+ if (current === last) {
69
+ stable += 1;
70
+ }
71
+ else {
72
+ stable = 0;
73
+ quietSince = now();
74
+ }
60
75
  last = current;
61
76
  if (now() > deadline) {
62
77
  throw new Error(`waitForQuiet timed out after ${timeoutMs}ms, still changing: ${label}`);
@@ -70,3 +85,11 @@ export async function advanceTicks(count) {
70
85
  for (let index = 0; index < count; index += 1)
71
86
  await tick();
72
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.2.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",