@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 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((n) => n.viewName === 'RCTView' && n.props.testID === 'tap-target');
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
- Each test calls `installFabric()` fresh (or `.reset()` an existing handle between assertions in the
41
- same test) — the fake slot is a `globalThis` singleton, so a stale handle from a previous test would
42
- otherwise leak state into the next one.
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 *is* the test double every other package's `vitest` suite
86
+ This package has no tests of its own — it _is_ the test double every other package's `vitest` suite
82
87
  imports.
@@ -1,18 +1,12 @@
1
- // One shared fake `nativeFabricUIManager` for the unit suite. `installFabric()`
2
- // puts a fresh recording slot on `globalThis` and returns a handle to inspect what the
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
- // Faithful persistent (clone-on-write) semantics, identical to what the engine drives
6
- // against real Fabric: every clone is a NEW identity; `*NewProps` MERGES the payload onto
7
- // the previous props (the engine always sends a minimal diff — see `diffProps` in
8
- // commit.ts — and relies on native Fabric to merge it onto the retained props; a removed
9
- // key arrives as literal `null` and is kept as `null`, not deleted, so a test can still see
10
- // "explicitly reset" distinct from "never set"); the `*Children` variants reset children
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 = { tag, viewName, props, children: [], instanceHandle };
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) => ({ ...node, children: [] }),
37
- cloneNodeWithNewChildrenAndProps: (node, newProps) => ({ ...node, props: mergeFabricProps(node.props, newProps), children: [] }),
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 && child.parentFamilyTag !== parent.tag) {
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 ? `(${node.children.map(serializeNode).join('')})` : '';
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
@@ -1 +1,2 @@
1
1
  export * from './fake-fabric';
2
+ export * from './wait-for';
package/build/index.js CHANGED
@@ -2,3 +2,4 @@
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 './wait-for.js';
@@ -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.1.5",
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",