@quiesco/agent 0.0.0-stage → 0.1.1

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/metro.cjs +144 -0
  4. package/package.json +54 -3
  5. package/src/actions/hit-test.ts +42 -0
  6. package/src/actions/host.ts +194 -0
  7. package/src/actions/js.ts +245 -0
  8. package/src/actions/multitouch.ts +179 -0
  9. package/src/actions/native.ts +500 -0
  10. package/src/actions/performer.ts +87 -0
  11. package/src/actions/value.ts +460 -0
  12. package/src/actions/wheel.ts +260 -0
  13. package/src/api-types.ts +32 -0
  14. package/src/api.ts +11 -0
  15. package/src/assertions/index.ts +160 -0
  16. package/src/backdoor.ts +54 -0
  17. package/src/build-config.ts +6 -0
  18. package/src/connection/client.ts +196 -0
  19. package/src/connection/params.ts +63 -0
  20. package/src/early.ts +10 -0
  21. package/src/errors.ts +23 -0
  22. package/src/events.ts +136 -0
  23. package/src/expo-fetch.js +13 -0
  24. package/src/fiber-adapter/default-deps.native.ts +36 -0
  25. package/src/fiber-adapter/default-deps.ts +38 -0
  26. package/src/fiber-adapter/fiber.ts +81 -0
  27. package/src/fiber-adapter/hook.ts +154 -0
  28. package/src/fiber-adapter/host.ts +210 -0
  29. package/src/fiber-adapter/index.ts +221 -0
  30. package/src/fiber-adapter/scheduler.ts +70 -0
  31. package/src/fiber-adapter/traverse.ts +260 -0
  32. package/src/fiber-adapter/types.ts +142 -0
  33. package/src/index.ts +7 -0
  34. package/src/install.ts +6 -0
  35. package/src/json-check.ts +43 -0
  36. package/src/platform.ts +155 -0
  37. package/src/query/index.ts +5 -0
  38. package/src/query/matcher.ts +121 -0
  39. package/src/query/semantic.ts +266 -0
  40. package/src/query/tree.ts +116 -0
  41. package/src/query/visibility.ts +60 -0
  42. package/src/reset/index.ts +59 -0
  43. package/src/reset/storage-defaults.native.ts +26 -0
  44. package/src/reset/storage-defaults.ts +39 -0
  45. package/src/root/QuiescoRoot.tsx +28 -0
  46. package/src/runtime.ts +1003 -0
  47. package/src/singleton.ts +18 -0
  48. package/src/stub.ts +28 -0
  49. package/src/sync/frame-stable.ts +142 -0
  50. package/src/sync/idle-callback.ts +142 -0
  51. package/src/sync/index.ts +17 -0
  52. package/src/sync/native-settle.ts +41 -0
  53. package/src/sync/network.ts +299 -0
  54. package/src/sync/offline.ts +121 -0
  55. package/src/sync/react.ts +28 -0
  56. package/src/sync/timer.ts +124 -0
  57. package/src/sync/tracker.ts +51 -0
  58. package/src/sync/wait-for-idle.ts +141 -0
  59. package/src/version.ts +2 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Build the renderer-independent HostNode tree from fiber roots.
3
+ * Rules (docs/spikes/07-web-fiber-query.md "查询规则"):
4
+ * - Only HostComponent fibers become nodes; composite fibers are transparent.
5
+ * - Text = HostText fibers + `memoizedProps.children` (string/number) of hosts WITHOUT child fibers
6
+ * (react-dom's shouldSetTextContent case; reading it only when there are no child fibers avoids
7
+ * double counting on Fabric).
8
+ * - HostSingleton (html/head/body) is transparent; HostHoistable (<title>, <link>, ...) is skipped.
9
+ */
10
+ import type { Rect } from '@quiesco/protocol';
11
+
12
+ import type { Fiber } from './fiber';
13
+ import { WorkTag, isObject, walkSiblings } from './fiber';
14
+ import type { HostPlatform } from './host';
15
+ import type { CompositeNode, HandlerRef, HostNode, InstanceRef, PropsRef } from './types';
16
+
17
+ class Node implements HostNode {
18
+ readonly type: string;
19
+ readonly props: Readonly<Record<string, unknown>>;
20
+ readonly testID: string | undefined;
21
+ readonly label: string | undefined;
22
+ readonly role: string | undefined;
23
+ readonly hiddenByStyle: boolean;
24
+ readonly clipsContent: boolean;
25
+ readonly renderedWithParent: boolean;
26
+ readonly children: Node[] = [];
27
+ text = '';
28
+ ownText = '';
29
+
30
+ constructor(
31
+ private readonly fiber: Fiber,
32
+ private readonly platform: HostPlatform,
33
+ readonly parent: Node | null,
34
+ viaComponent: boolean,
35
+ ) {
36
+ const props = isObject(fiber.memoizedProps) ? fiber.memoizedProps : {};
37
+ this.type = typeof fiber.type === 'string' ? fiber.type : String(fiber.type);
38
+ this.props = props;
39
+ this.testID = platform.testID(props);
40
+ this.label = platform.label(props);
41
+ this.role = platform.role(props);
42
+ this.hiddenByStyle = platform.hiddenByStyle(fiber, props);
43
+ this.clipsContent = platform.clipsContent(this.type, fiber, props);
44
+ this.renderedWithParent = parent !== null && !viaComponent;
45
+ }
46
+
47
+ ownerNames(): readonly string[] {
48
+ const out: string[] = [];
49
+ for (let f = this.fiber.return; f !== null && !isHostBoundary(f); f = f.return) {
50
+ const name = componentName(f.type);
51
+ if (name !== null && !out.includes(name)) out.push(name);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ isFocused(): boolean {
57
+ return this.platform.isFocused(this.fiber);
58
+ }
59
+
60
+ focus(): boolean {
61
+ return this.platform.focus(this.fiber);
62
+ }
63
+
64
+ inputValue(): string | undefined {
65
+ return this.platform.inputValue(this.type, this.fiber, this.props as Record<string, unknown>);
66
+ }
67
+
68
+ measure(): Rect | null {
69
+ return this.platform.measure(this.fiber);
70
+ }
71
+
72
+ nativeTag(): number | null {
73
+ return this.platform.nativeTag(this.fiber);
74
+ }
75
+
76
+ isSameElement(other: HostNode): boolean {
77
+ if (other === this) return true;
78
+ if (!(other instanceof Node)) return false;
79
+ const a = this.fiber.stateNode;
80
+ return (a !== null && a !== undefined && a === other.fiber.stateNode) || this.fiber === other.fiber;
81
+ }
82
+
83
+ ownerProps(): Readonly<Record<string, unknown>> {
84
+ const out: Record<string, unknown> = {};
85
+ for (let f = this.fiber.return; f !== null && !isHostBoundary(f); f = f.return) {
86
+ if (f.tag === WorkTag.HostText || !isObject(f.memoizedProps)) continue;
87
+ for (const [k, v] of Object.entries(f.memoizedProps)) if (!(k in out)) out[k] = v;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ findHandler(name: string): HandlerRef | null {
93
+ for (let f: Fiber | null = this.fiber; f !== null && f.tag !== WorkTag.HostRoot; f = f.return) {
94
+ const p = f.memoizedProps;
95
+ if (isObject(p) && typeof p[name] === 'function') return { fn: p[name] as HandlerRef['fn'], props: p };
96
+ }
97
+ return null;
98
+ }
99
+
100
+ findProps(test: (props: Readonly<Record<string, unknown>>) => boolean, maxDepth = DEFAULT_PROPS_DEPTH): PropsRef | null {
101
+ return findPropsFrom(this.fiber, test, maxDepth);
102
+ }
103
+
104
+ findInstance(method: string): InstanceRef | null {
105
+ for (let f = this.fiber.return; f !== null && f.tag !== WorkTag.HostRoot; f = f.return) {
106
+ const inst = f.stateNode;
107
+ if (f.tag !== WorkTag.ClassComponent || !isObject(inst) || typeof inst[method] !== 'function') continue;
108
+ const owner = f;
109
+ const platform = this.platform;
110
+ return {
111
+ call: (...args) => (inst[method] as (...a: unknown[]) => unknown).apply(inst, args),
112
+ hostNodes: () => buildHostTree(owner, platform),
113
+ raw: () => inst,
114
+ };
115
+ }
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /** How far up `findProps` looks by default: the components wrapping a control, not the whole screen. */
121
+ const DEFAULT_PROPS_DEPTH = 12;
122
+
123
+ function findPropsFrom(start: Fiber, test: (props: Readonly<Record<string, unknown>>) => boolean, maxDepth: number): PropsRef | null {
124
+ let depth = 0;
125
+ for (let f: Fiber | null = start; f !== null && f.tag !== WorkTag.HostRoot && depth <= maxDepth; f = f.return, depth++) {
126
+ if (f.tag === WorkTag.HostText || !isObject(f.memoizedProps)) continue;
127
+ if (test(f.memoizedProps)) return { props: f.memoizedProps, name: COMPONENT_TAGS.has(f.tag) ? componentName(f.type) : null };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ class Composite implements CompositeNode {
133
+ readonly name: string | null;
134
+ readonly props: Readonly<Record<string, unknown>>;
135
+ readonly testID: string | undefined;
136
+ readonly rendersHost: boolean;
137
+
138
+ constructor(private readonly fiber: Fiber) {
139
+ this.name = componentName(fiber.type);
140
+ this.props = isObject(fiber.memoizedProps) ? fiber.memoizedProps : {};
141
+ this.testID = typeof this.props['testID'] === 'string' ? this.props['testID'] : undefined;
142
+ this.rendersHost = hasHost(fiber.child);
143
+ }
144
+
145
+ findProps(test: (props: Readonly<Record<string, unknown>>) => boolean, maxDepth = DEFAULT_PROPS_DEPTH): PropsRef | null {
146
+ return findPropsFrom(this.fiber, test, maxDepth);
147
+ }
148
+ }
149
+
150
+ function hasHost(first: Fiber | null): boolean {
151
+ for (const f of walkSiblings(first)) if (f.tag === WorkTag.HostComponent || f.tag === WorkTag.HostText) return true;
152
+ return false;
153
+ }
154
+
155
+ /**
156
+ * Component fibers under `fiber` (pre-order) that render no host element and satisfy `test`. A match's
157
+ * descendants are not searched: a wrapper passing its testID down to an inner component is one target.
158
+ */
159
+ export function findHostlessComposites(fiber: Fiber, test: (c: CompositeNode) => boolean): CompositeNode[] {
160
+ const out: CompositeNode[] = [];
161
+ const stack: Fiber[] = [];
162
+ let f: Fiber | null = fiber.child;
163
+ while (f !== null || stack.length > 0) {
164
+ if (f === null) f = stack.pop() ?? null;
165
+ if (f === null) break;
166
+ if (f.sibling !== null) stack.push(f.sibling);
167
+ if (COMPONENT_TAGS.has(f.tag) && !hasHost(f.child)) {
168
+ const c = new Composite(f);
169
+ if (test(c)) {
170
+ out.push(c);
171
+ f = null;
172
+ continue;
173
+ }
174
+ }
175
+ f = f.child;
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /** Display name of a component type: function / class, forwardRef, memo. Null for anonymous ones. */
181
+ function componentName(type: unknown): string | null {
182
+ const named = (t: unknown): string | null => {
183
+ if (typeof t !== 'function' && !isObject(t)) return null;
184
+ const o = t as { displayName?: unknown; name?: unknown };
185
+ if (typeof o.displayName === 'string' && o.displayName !== '') return o.displayName;
186
+ return typeof t === 'function' && typeof o.name === 'string' && o.name !== '' ? o.name : null;
187
+ };
188
+ const direct = named(type);
189
+ if (direct !== null || !isObject(type)) return direct;
190
+ return named(type['render']) ?? named(type['type']);
191
+ }
192
+
193
+ const COMPONENT_TAGS: ReadonlySet<number> = new Set([
194
+ WorkTag.FunctionComponent,
195
+ WorkTag.ClassComponent,
196
+ WorkTag.ForwardRef,
197
+ WorkTag.MemoComponent,
198
+ WorkTag.SimpleMemoComponent,
199
+ ]);
200
+
201
+ function isHostBoundary(f: Fiber): boolean {
202
+ return f.tag === WorkTag.HostComponent || f.tag === WorkTag.HostRoot || f.tag === WorkTag.HostPortal;
203
+ }
204
+
205
+ function stringChildren(fiber: Fiber): string {
206
+ if (fiber.child !== null || !isObject(fiber.memoizedProps)) return '';
207
+ const c = fiber.memoizedProps['children'];
208
+ return typeof c === 'string' || typeof c === 'number' ? String(c) : '';
209
+ }
210
+
211
+ /**
212
+ * Collect host nodes under `first` and its siblings into `out` (children of `parent`).
213
+ * `viaComponent`: a component fiber lies between `parent` and `first`.
214
+ * Returns [all text, text not inside a child host], both in render order.
215
+ */
216
+ function collect(first: Fiber | null, parent: Node | null, platform: HostPlatform, out: Node[], viaComponent = false): [string, string] {
217
+ let text = '';
218
+ let own = '';
219
+ for (let f = first; f !== null; f = f.sibling) {
220
+ switch (f.tag) {
221
+ case WorkTag.HostComponent: {
222
+ const node = new Node(f, platform, parent, viaComponent);
223
+ const [childText, childOwn] = collect(f.child, node, platform, node.children);
224
+ const mine = stringChildren(f);
225
+ node.text = mine + childText;
226
+ node.ownText = mine + childOwn;
227
+ out.push(node);
228
+ text += node.text;
229
+ break;
230
+ }
231
+ case WorkTag.HostText:
232
+ if (typeof f.memoizedProps === 'string' || typeof f.memoizedProps === 'number') {
233
+ text += String(f.memoizedProps);
234
+ own += String(f.memoizedProps);
235
+ }
236
+ break;
237
+ case WorkTag.HostHoistable:
238
+ break;
239
+ default: {
240
+ const [t, o] = collect(f.child, parent, platform, out, viaComponent || COMPONENT_TAGS.has(f.tag));
241
+ text += t;
242
+ own += o;
243
+ }
244
+ }
245
+ }
246
+ return [text, own];
247
+ }
248
+
249
+ /** Host nodes directly below `fiber` (the fiber itself is not included). */
250
+ export function buildHostTree(fiber: Fiber, platform: HostPlatform): HostNode[] {
251
+ const out: Node[] = [];
252
+ collect(fiber.child, null, platform, out);
253
+ return out;
254
+ }
255
+
256
+ /** First fiber (pre-order) whose component type is `type`. */
257
+ export function findFiberByType(start: Fiber, type: unknown): Fiber | null {
258
+ for (const f of walkSiblings(start)) if (f.type === type) return f;
259
+ return null;
260
+ }
@@ -0,0 +1,142 @@
1
+ import type { Rect } from '@quiesco/protocol';
2
+
3
+ /**
4
+ * Renderer-independent element model. This is the ONLY shape the rest of the agent sees;
5
+ * Fiber objects never leave `fiber-adapter/` (requirements.md §3-3).
6
+ */
7
+ export interface HostNode {
8
+ /** Host component type: `RCTView`, `RCTText`, `div`, ... */
9
+ readonly type: string;
10
+ /** Raw React props of the host fiber (read-only view; may contain functions / elements). */
11
+ readonly props: Readonly<Record<string, unknown>>;
12
+ /** `testID` (native) or `data-testid` (react-native-web). */
13
+ readonly testID: string | undefined;
14
+ /** `accessibilityLabel` / `aria-label`. */
15
+ readonly label: string | undefined;
16
+ /** `accessibilityRole` / `role`. */
17
+ readonly role: string | undefined;
18
+ /** Concatenated text of this node's subtree, in render order. */
19
+ readonly text: string;
20
+ /** Text directly inside this node, excluding text of child host nodes (e.g. an RCTText's own string). */
21
+ readonly ownText: string;
22
+ /** `display: none` or `opacity: 0` on this node itself (ancestors are not considered). */
23
+ readonly hiddenByStyle: boolean;
24
+ /**
25
+ * Whether this host clips its descendants to its own bounds: scroll views, and `overflow` other
26
+ * than `visible` (style on native, computed style on the web).
27
+ */
28
+ readonly clipsContent: boolean;
29
+ /**
30
+ * True when no component (function / class / forwardRef / memo) sits between this host and its
31
+ * parent host: it was created in the same render as the parent, e.g. the `<input>` inside
32
+ * react-native-web's Switch root `<div>`. Structural, so it survives minification.
33
+ */
34
+ readonly renderedWithParent: boolean;
35
+ readonly parent: HostNode | null;
36
+ readonly children: readonly HostNode[];
37
+ /** Window coordinates in logical units (dp / pt / CSS px), or null if the node cannot be measured. */
38
+ measure(): Rect | null;
39
+ /** React view tag of the underlying native view (Fabric), or null (web, unmounted). */
40
+ nativeTag(): number | null;
41
+ /**
42
+ * Whether `other` is the same rendered element. HostNode objects are rebuilt on every tree walk, so
43
+ * identity is the underlying host instance (Fabric instance / DOM element), not the object.
44
+ */
45
+ isSameElement(other: HostNode): boolean;
46
+ /**
47
+ * Merged props of the composite components that render this host directly (between it and the
48
+ * nearest ancestor host), nearest first. E.g. a Pressable's `disabled`, a TextInput's `value`.
49
+ */
50
+ ownerProps(): Readonly<Record<string, unknown>>;
51
+ /**
52
+ * Names of the composite components that render this host directly (between it and the nearest
53
+ * ancestor host), nearest first. Only named components appear; release builds minify function
54
+ * names, so only explicit `displayName`s (Pressable, TextInput, ...) are reliable there.
55
+ */
56
+ ownerNames(): readonly string[];
57
+ /**
58
+ * Current text of a text input (native: the `text` prop TextInput passes to its host, i.e. the value
59
+ * or the last typed text; web: the DOM element's live `value`). Undefined for other elements.
60
+ */
61
+ inputValue(): string | undefined;
62
+ /**
63
+ * Give the element the input focus (text inputs). Web: DOM `focus()` and the caret moved to the end;
64
+ * native: the host instance's `focus()`. False when the element cannot be focused this way.
65
+ */
66
+ focus(): boolean;
67
+ /** Whether the element has the input focus (focused text input; `document.activeElement` on the web). */
68
+ isFocused(): boolean;
69
+ /** Nearest component (this host or an ancestor, up to the root) with a function prop `name`. */
70
+ findHandler(name: string): HandlerRef | null;
71
+ /**
72
+ * Nearest fiber (this host, then its ancestors, at most `maxDepth` fibers up) whose props satisfy
73
+ * `test`, with the name of its component (null for hosts and anonymous components). Used to find the
74
+ * component behind a control (Slider, Picker, ...) by the shape of its props.
75
+ */
76
+ findProps(test: (props: Readonly<Record<string, unknown>>) => boolean, maxDepth?: number): PropsRef | null;
77
+ /** Nearest ancestor class component instance with a method `name` (e.g. ScrollView's `scrollTo`). */
78
+ findInstance(method: string): InstanceRef | null;
79
+ }
80
+
81
+ export interface HandlerRef {
82
+ fn: (...args: unknown[]) => unknown;
83
+ /** All props of the component that owns the handler (for `disabled`, sibling handlers, ...). */
84
+ props: Readonly<Record<string, unknown>>;
85
+ }
86
+
87
+ export interface PropsRef {
88
+ props: Readonly<Record<string, unknown>>;
89
+ name: string | null;
90
+ }
91
+
92
+ /**
93
+ * A component that renders no host element of its own (e.g. Android's DateTimePicker, which opens a
94
+ * system dialog imperatively and renders null). Only value actions can target it (Phase 6.3).
95
+ */
96
+ export interface CompositeNode {
97
+ readonly name: string | null;
98
+ readonly props: Readonly<Record<string, unknown>>;
99
+ readonly testID: string | undefined;
100
+ /** Whether it renders any host element. */
101
+ readonly rendersHost: boolean;
102
+ findProps(test: (props: Readonly<Record<string, unknown>>) => boolean, maxDepth?: number): PropsRef | null;
103
+ }
104
+
105
+ export interface InstanceRef {
106
+ /** Call the method on the instance. */
107
+ call(...args: unknown[]): unknown;
108
+ /** Top-level host nodes rendered by the component (e.g. the native scroll view). */
109
+ hostNodes(): HostNode[];
110
+ /** The component instance itself, for reading documented-but-internal state (e.g. list metrics). */
111
+ raw(): Record<string, unknown>;
112
+ }
113
+
114
+ export type RendererKind = 'native' | 'web';
115
+
116
+ export interface RendererInfo {
117
+ id: number;
118
+ packageName: string;
119
+ version: string;
120
+ /** 0 = production, 1 = development (React DevTools convention). */
121
+ bundleType: number;
122
+ kind: RendererKind;
123
+ supported: boolean;
124
+ }
125
+
126
+ export interface AdapterDiagnostics {
127
+ /** `existing`: wrapped a hook installed by React DevTools; `installed`: we installed a minimal hook. */
128
+ hookSource: 'existing' | 'installed' | 'none';
129
+ schedulerPatched: boolean;
130
+ renderers: RendererInfo[];
131
+ rootCount: number;
132
+ /** Problems the user must fix (e.g. the agent was imported after React Native's renderer). */
133
+ problems: string[];
134
+ /** Non-fatal notices (e.g. untested renderer version). */
135
+ warnings: string[];
136
+ }
137
+
138
+ /** Window size in the same units as `HostNode.measure()`. */
139
+ export interface Viewport {
140
+ width: number;
141
+ height: number;
142
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Keep this import first: it installs the DevTools hook, the scheduler patch and the trackers.
2
+ import './install';
3
+
4
+ export { QuiescoRoot } from './root/QuiescoRoot';
5
+ export type { QuiescoRootProps } from './root/QuiescoRoot';
6
+ export { quiesco } from './api';
7
+ export type { Backdoor, QuiescoApi, StorageAdapter } from './api-types';
package/src/install.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Side-effect module: must be evaluated before React Native's renderer and before the app's code
2
+ // (docs/spikes/01 §1, 02 §2). `@quiesco/agent` imports it first; keep it that way.
3
+ import './early';
4
+ import { getRuntime } from './singleton';
5
+
6
+ getRuntime();
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Strict JSON check for backdoor results (requirements.md §6.6): reports the first value that would not
3
+ * survive JSON.stringify / JSON.parse unchanged, as a path like `$.items[2].createdAt`.
4
+ */
5
+ export function findNonJson(value: unknown, path = '$', seen: Set<object> = new Set()): { path: string; reason: string } | null {
6
+ switch (typeof value) {
7
+ case 'string':
8
+ case 'boolean':
9
+ return null;
10
+ case 'number':
11
+ return Number.isFinite(value) ? null : { path, reason: String(value) };
12
+ case 'undefined':
13
+ return { path, reason: 'undefined' };
14
+ case 'function':
15
+ case 'symbol':
16
+ case 'bigint':
17
+ return { path, reason: typeof value };
18
+ }
19
+ if (value === null) return null;
20
+ const obj = value as object;
21
+ if (seen.has(obj)) return { path, reason: 'circular reference' };
22
+ if (Array.isArray(obj)) {
23
+ seen.add(obj);
24
+ for (let i = 0; i < obj.length; i++) {
25
+ const bad = findNonJson(obj[i], `${path}[${i}]`, seen);
26
+ if (bad !== null) return bad;
27
+ }
28
+ seen.delete(obj);
29
+ return null;
30
+ }
31
+ const proto = Object.getPrototypeOf(obj) as unknown;
32
+ if (proto !== Object.prototype && proto !== null) {
33
+ const name = (obj as { constructor?: { name?: string } }).constructor?.name ?? 'object';
34
+ return { path, reason: `${name} instance (only plain objects and arrays)` };
35
+ }
36
+ seen.add(obj);
37
+ for (const [k, v] of Object.entries(obj)) {
38
+ const bad = findNonJson(v, /^[A-Za-z_$][\w$]*$/.test(k) ? `${path}.${k}` : `${path}[${JSON.stringify(k)}]`, seen);
39
+ if (bad !== null) return bad;
40
+ }
41
+ seen.delete(obj);
42
+ return null;
43
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Public React Native APIs the agent needs at runtime, loaded lazily (never at agent import time:
3
+ * that would load React Native before the DevTools hook is installed). Injected for unit tests.
4
+ * On web, `react-native` is aliased to react-native-web, which provides the same APIs.
5
+ */
6
+ import type { AppStateName, Platform, Rect } from '@quiesco/protocol';
7
+
8
+ import type { NativeBridge } from './actions/native';
9
+ import type { InteractionManagerLike } from './sync';
10
+
11
+ declare const require: (id: string) => unknown;
12
+
13
+ export interface SocketLike {
14
+ send(data: string): void;
15
+ close(code?: number, reason?: string): void;
16
+ onopen: (() => void) | null;
17
+ onmessage: ((ev: { data: unknown }) => void) | null;
18
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
19
+ onerror: ((ev: unknown) => void) | null;
20
+ }
21
+
22
+ export interface AppDeps {
23
+ platform(): Platform;
24
+ dismissKeyboard(): void;
25
+ /** The software keyboard's frame in screen coordinates while it is shown, else null. */
26
+ keyboardFrame(): Rect | null;
27
+ /** @quiesco/native when it is installed and its native code is linked, else null. */
28
+ nativeModule(): NativeModuleLike | null;
29
+ createSocket(url: string): SocketLike;
30
+ /** Legacy InteractionManager (RN < 0.88), or null. */
31
+ interactionManager(): InteractionManagerLike | null;
32
+ /** React Native's AppState (react-native-web: from the page visibility). */
33
+ appState(): AppStateName;
34
+ /**
35
+ * Calls `listener` on every AppState change; returns the unsubscribe. Events still arrive while Android
36
+ * pauses the JS timers (another activity on top, e.g. a permission dialog).
37
+ */
38
+ onAppStateChange?(listener: () => void): () => void;
39
+ /** The window in logical units and physical pixels per unit. */
40
+ windowInfo(): { width: number; height: number; pixelRatio: number };
41
+ /** What the app sees of the device settings (Phase 6.6 read-backs). */
42
+ settings(): { colorScheme: 'light' | 'dark' | null; fontScale: number; locale: string };
43
+ }
44
+
45
+ /** What the agent uses of @quiesco/native besides the action bridge. */
46
+ export type NativeModuleLike = NativeBridge & {
47
+ /** `args`: base64 of the launch arguments' JSON (Phase 6.5); missing in older native builds. */
48
+ getLaunchParams(): { port: string | null; session: string | null; args?: string | null };
49
+ /** iOS: leave for the home screen. Missing on Android and in older native builds. */
50
+ suspend?(): Promise<boolean>;
51
+ /** iOS: rotate the window scene; resolves with an error message, or '' when requested (Phase 6.6). */
52
+ setOrientation?(landscape: boolean): Promise<string>;
53
+ /** Android: post a notification as the app (Phase 6.7). */
54
+ postNotification?(title: string, body: string, data: Record<string, string>): Promise<{ enabled: boolean }>;
55
+ /** The clipboard's text ('' when it holds none), read inside the app (Phase 6.6). */
56
+ getClipboard?(): Promise<string>;
57
+ setClipboard?(text: string): Promise<void>;
58
+ /** The WebView inside a view (Phase 6.8): its rect in the window and load state. */
59
+ webviewInfo?(tag: number): Promise<{ found: boolean; x?: number; y?: number; width?: number; height?: number; insetTop?: number; insetLeft?: number; loading?: boolean }>;
60
+ /** Evaluate a string-valued expression in the WebView's main frame. */
61
+ webviewEval?(tag: number, script: string): Promise<string>;
62
+ /** Run `source` at document start of the WebView's later loads; false where the WebView cannot. */
63
+ webviewAddStartScript?(tag: number, source: string): Promise<boolean>;
64
+ /** Show / hide a scroll view's indicators (Phase 6.9 full-page screenshots); what they were: 1 shown, 0 hidden, -1 not found. */
65
+ setScrollIndicators?(tag: number, visible: boolean): Promise<number>;
66
+ };
67
+
68
+ interface RN {
69
+ Platform: { OS: string };
70
+ Keyboard: {
71
+ dismiss(): void;
72
+ // React Native ≥ 0.70; react-native-web has neither (no software keyboard to track).
73
+ isVisible?(): boolean;
74
+ metrics?(): { screenX: number; screenY: number; width: number; height: number } | null | undefined;
75
+ };
76
+ InteractionManager?: InteractionManagerLike;
77
+ AppState: { currentState: string | null | undefined; addEventListener(type: 'change', listener: () => void): { remove(): void } };
78
+ Dimensions: { get(dim: 'window'): { width: number; height: number } };
79
+ PixelRatio: { get(): number; getFontScale(): number };
80
+ Appearance: { getColorScheme(): string | null | undefined };
81
+ }
82
+
83
+ const rn = (): RN => require('react-native') as RN;
84
+
85
+ type MatchMedia = (query: string) => { matches: boolean };
86
+
87
+ /**
88
+ * The color scheme the app sees, read without disturbing it. Where `matchMedia` exists (the web), a
89
+ * fresh MediaQueryList is asked: reading `matches` on react-native-web's own list (Appearance) updates
90
+ * its remembered state, and Chromium then dispatches no change event to the app's listeners
91
+ * (useColorScheme never updates; found in Phase 6.6). Native: React Native's Appearance.
92
+ */
93
+ export function readColorScheme(matchMedia: MatchMedia | undefined, appearance: () => string | null | undefined): 'light' | 'dark' | null {
94
+ if (typeof matchMedia === 'function') return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
95
+ const s = appearance();
96
+ return s === 'light' || s === 'dark' ? s : null;
97
+ }
98
+
99
+ export const defaultAppDeps: AppDeps = {
100
+ platform() {
101
+ const os = rn().Platform.OS;
102
+ return os === 'ios' || os === 'android' ? os : 'web';
103
+ },
104
+ dismissKeyboard() {
105
+ rn().Keyboard.dismiss();
106
+ },
107
+ keyboardFrame() {
108
+ const k = rn().Keyboard;
109
+ if (k.isVisible?.() !== true) return null;
110
+ const m = k.metrics?.();
111
+ return m === null || m === undefined ? null : { x: m.screenX, y: m.screenY, width: m.width, height: m.height };
112
+ },
113
+ nativeModule() {
114
+ // Optional dependency: Metro bundles apps without @quiesco/native when the require sits directly
115
+ // in a try block (transformer.allowOptionalDependencies, on by default).
116
+ try {
117
+ const mod = require('@quiesco/native') as { QuiescoNative?: ReturnType<AppDeps['nativeModule']> };
118
+ return mod.QuiescoNative ?? null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ },
123
+ createSocket(url) {
124
+ const WS = (globalThis as unknown as { WebSocket: new (url: string) => SocketLike }).WebSocket;
125
+ return new WS(url);
126
+ },
127
+ appState() {
128
+ const s = rn().AppState.currentState;
129
+ return s === 'active' || s === 'background' || s === 'inactive' ? s : 'unknown';
130
+ },
131
+ onAppStateChange(listener) {
132
+ const sub = rn().AppState.addEventListener('change', listener);
133
+ return () => sub.remove();
134
+ },
135
+ windowInfo() {
136
+ const { width, height } = rn().Dimensions.get('window');
137
+ return { width, height, pixelRatio: rn().PixelRatio.get() };
138
+ },
139
+ settings() {
140
+ const mm = (globalThis as { matchMedia?: MatchMedia }).matchMedia;
141
+ return {
142
+ colorScheme: readColorScheme(mm?.bind(globalThis), () => rn().Appearance.getColorScheme()),
143
+ fontScale: rn().PixelRatio.getFontScale(),
144
+ locale: Intl.DateTimeFormat().resolvedOptions().locale,
145
+ };
146
+ },
147
+ interactionManager() {
148
+ try {
149
+ // RN 0.88 replaced it with a getter that throws in __DEV__ (removed from core).
150
+ return rn().InteractionManager ?? null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ },
155
+ };
@@ -0,0 +1,5 @@
1
+ export { describeMatcher, findAll, findAllIgnoringIndex, matchesSelf, resolveOne, toRegExp } from './matcher';
2
+ export type { Summarizer } from './matcher';
3
+ export { getTree, serializableProps, structureLines, summarize, toJsonValue, toTreeNode } from './tree';
4
+ export { hiddenByAncestorStyle, intersectsViewport, isVisible, visiblePart, visibleRatio } from './visibility';
5
+ export { checkedState, elementValue, isEnabled, isFocusedWidget, isWidgetPart, matchesType, semanticType, widgetParts } from './semantic';