@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.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/metro.cjs +144 -0
- package/package.json +54 -3
- package/src/actions/hit-test.ts +42 -0
- package/src/actions/host.ts +194 -0
- package/src/actions/js.ts +245 -0
- package/src/actions/multitouch.ts +179 -0
- package/src/actions/native.ts +500 -0
- package/src/actions/performer.ts +87 -0
- package/src/actions/value.ts +460 -0
- package/src/actions/wheel.ts +260 -0
- package/src/api-types.ts +32 -0
- package/src/api.ts +11 -0
- package/src/assertions/index.ts +160 -0
- package/src/backdoor.ts +54 -0
- package/src/build-config.ts +6 -0
- package/src/connection/client.ts +196 -0
- package/src/connection/params.ts +63 -0
- package/src/early.ts +10 -0
- package/src/errors.ts +23 -0
- package/src/events.ts +136 -0
- package/src/expo-fetch.js +13 -0
- package/src/fiber-adapter/default-deps.native.ts +36 -0
- package/src/fiber-adapter/default-deps.ts +38 -0
- package/src/fiber-adapter/fiber.ts +81 -0
- package/src/fiber-adapter/hook.ts +154 -0
- package/src/fiber-adapter/host.ts +210 -0
- package/src/fiber-adapter/index.ts +221 -0
- package/src/fiber-adapter/scheduler.ts +70 -0
- package/src/fiber-adapter/traverse.ts +260 -0
- package/src/fiber-adapter/types.ts +142 -0
- package/src/index.ts +7 -0
- package/src/install.ts +6 -0
- package/src/json-check.ts +43 -0
- package/src/platform.ts +155 -0
- package/src/query/index.ts +5 -0
- package/src/query/matcher.ts +121 -0
- package/src/query/semantic.ts +266 -0
- package/src/query/tree.ts +116 -0
- package/src/query/visibility.ts +60 -0
- package/src/reset/index.ts +59 -0
- package/src/reset/storage-defaults.native.ts +26 -0
- package/src/reset/storage-defaults.ts +39 -0
- package/src/root/QuiescoRoot.tsx +28 -0
- package/src/runtime.ts +1003 -0
- package/src/singleton.ts +18 -0
- package/src/stub.ts +28 -0
- package/src/sync/frame-stable.ts +142 -0
- package/src/sync/idle-callback.ts +142 -0
- package/src/sync/index.ts +17 -0
- package/src/sync/native-settle.ts +41 -0
- package/src/sync/network.ts +299 -0
- package/src/sync/offline.ts +121 -0
- package/src/sync/react.ts +28 -0
- package/src/sync/timer.ts +124 -0
- package/src/sync/tracker.ts +51 -0
- package/src/sync/wait-for-idle.ts +141 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matcher evaluation over the HostNode tree (requirements.md §6.1).
|
|
3
|
+
*
|
|
4
|
+
* - `id` / `label` / `role` compare the host's own props; `id` / `text` / `label` accept a regex.
|
|
5
|
+
* - `type` is a semantic type, a host type or the name of a component rendering the host (semantic.ts).
|
|
6
|
+
* - `has`: some descendant matches. `not`: the node itself does not match.
|
|
7
|
+
* - `text` compares the concatenated text of the node's subtree. Because a container's text includes
|
|
8
|
+
* its children's text, only the DEEPEST matching nodes are returned: a node is dropped when one of
|
|
9
|
+
* its descendants also satisfies the whole matcher (e.g. `{ text: 'Login' }` returns the RCTText,
|
|
10
|
+
* not every View wrapping it).
|
|
11
|
+
* - `within` restricts the search to descendants of the single element it matches.
|
|
12
|
+
* - Results are in render (pre-order) order; `index` picks one of them.
|
|
13
|
+
*/
|
|
14
|
+
import type { ElementSummary, Matcher, TextMatch } from '@quiesco/protocol';
|
|
15
|
+
|
|
16
|
+
import { QuiescoError } from '../errors';
|
|
17
|
+
import type { HostNode } from '../fiber-adapter';
|
|
18
|
+
import { matchesType, widgetLabel } from './semantic';
|
|
19
|
+
|
|
20
|
+
export function toRegExp(m: TextMatch): RegExp | string {
|
|
21
|
+
return typeof m === 'string' ? m : new RegExp(m.regex.source, m.regex.flags);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function textMatches(value: string | undefined, pattern: TextMatch): boolean {
|
|
25
|
+
if (value === undefined) return false;
|
|
26
|
+
const p = toRegExp(pattern);
|
|
27
|
+
if (typeof p === 'string') return value === p;
|
|
28
|
+
p.lastIndex = 0;
|
|
29
|
+
return p.test(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether `node` itself satisfies the matcher: its own fields, `has` and `not` (`within` / `index`
|
|
34
|
+
* select among nodes and are not considered here).
|
|
35
|
+
*/
|
|
36
|
+
export function matchesSelf(node: HostNode, m: Matcher): boolean {
|
|
37
|
+
if (m.id !== undefined && !textMatches(node.testID, m.id)) return false;
|
|
38
|
+
if (m.role !== undefined && node.role !== m.role) return false;
|
|
39
|
+
if (m.label !== undefined && !textMatches(widgetLabel(node), m.label)) return false;
|
|
40
|
+
if (m.text !== undefined && !textMatches(node.text, m.text)) return false;
|
|
41
|
+
if (m.type !== undefined && !matchesType(node, m.type)) return false;
|
|
42
|
+
if (m.has !== undefined && !hasMatchingDescendant(node, m.has)) return false;
|
|
43
|
+
if (m.not !== undefined && matchesSelf(node, m.not)) return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function* preorder(nodes: readonly HostNode[]): Generator<HostNode> {
|
|
48
|
+
const stack = [...nodes].reverse();
|
|
49
|
+
while (stack.length > 0) {
|
|
50
|
+
const n = stack.pop() as HostNode;
|
|
51
|
+
yield n;
|
|
52
|
+
for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i] as HostNode);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasMatchingDescendant(node: HostNode, m: Matcher): boolean {
|
|
57
|
+
for (const d of preorder(node.children)) if (matchesSelf(d, m)) return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** All nodes matching `m` (ignoring `index`) under `roots`. Throws if `within` is missing or ambiguous. */
|
|
62
|
+
export function findAllIgnoringIndex(roots: readonly HostNode[], m: Matcher): HostNode[] {
|
|
63
|
+
let scope = roots;
|
|
64
|
+
if (m.within !== undefined) scope = resolveOne(roots, m.within).children;
|
|
65
|
+
const out: HostNode[] = [];
|
|
66
|
+
for (const n of preorder(scope)) {
|
|
67
|
+
if (!matchesSelf(n, m)) continue;
|
|
68
|
+
if (m.text !== undefined && hasMatchingDescendant(n, m)) continue;
|
|
69
|
+
out.push(n);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Elements for `m`. With `index`, at most one element (empty if out of range).
|
|
76
|
+
* Assertions use this directly and apply their own semantics to the count.
|
|
77
|
+
*/
|
|
78
|
+
export function findAll(roots: readonly HostNode[], m: Matcher): HostNode[] {
|
|
79
|
+
const all = findAllIgnoringIndex(roots, m);
|
|
80
|
+
if (m.index === undefined) return all;
|
|
81
|
+
const picked = all[m.index];
|
|
82
|
+
return picked === undefined ? [] : [picked];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type Summarizer = (node: HostNode) => ElementSummary;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Exactly one element, for actions and `within`: ELEMENT_NOT_FOUND / MULTIPLE_MATCHES otherwise.
|
|
89
|
+
* `summarize` is used to list the candidates in MULTIPLE_MATCHES.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveOne(roots: readonly HostNode[], m: Matcher, summarize?: Summarizer): HostNode {
|
|
92
|
+
const found = findAll(roots, m);
|
|
93
|
+
if (found.length === 0) throw new QuiescoError('ELEMENT_NOT_FOUND', `No element matches ${describeMatcher(m)}`, { matcher: m });
|
|
94
|
+
if (found.length > 1) {
|
|
95
|
+
throw new QuiescoError(
|
|
96
|
+
'MULTIPLE_MATCHES',
|
|
97
|
+
`${found.length} elements match ${describeMatcher(m)}; add \`index\` or a more specific matcher`,
|
|
98
|
+
{ matcher: m, matches: summarize === undefined ? [] : found.map(summarize) },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return found[0] as HostNode;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fmt(t: TextMatch): string {
|
|
105
|
+
return typeof t === 'string' ? JSON.stringify(t) : `/${t.regex.source}/${t.regex.flags}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Human-readable matcher, e.g. `{ id: "submit", within: { id: "form" } }`. */
|
|
109
|
+
export function describeMatcher(m: Matcher): string {
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
if (m.id !== undefined) parts.push(`id: ${fmt(m.id)}`);
|
|
112
|
+
if (m.text !== undefined) parts.push(`text: ${fmt(m.text)}`);
|
|
113
|
+
if (m.label !== undefined) parts.push(`label: ${fmt(m.label)}`);
|
|
114
|
+
if (m.role !== undefined) parts.push(`role: ${JSON.stringify(m.role)}`);
|
|
115
|
+
if (m.type !== undefined) parts.push(`type: ${JSON.stringify(m.type)}`);
|
|
116
|
+
if (m.has !== undefined) parts.push(`has: ${describeMatcher(m.has)}`);
|
|
117
|
+
if (m.not !== undefined) parts.push(`not: ${describeMatcher(m.not)}`);
|
|
118
|
+
if (m.within !== undefined) parts.push(`within: ${describeMatcher(m.within)}`);
|
|
119
|
+
if (m.index !== undefined) parts.push(`index: ${m.index}`);
|
|
120
|
+
return `{ ${parts.join(', ')} }`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-independent element kinds and states (Phase 6.1).
|
|
3
|
+
*
|
|
4
|
+
* The semantic type comes from, in order:
|
|
5
|
+
* 1. the accessibility role (`accessibilityRole` / `role`), which the app states explicitly;
|
|
6
|
+
* 2. a scroll host rendered by a virtualized list (its owner has a `renderItem` prop) → `list`;
|
|
7
|
+
* 3. the host component type (`RCTText`, `AndroidSwitch`, `input`, ...). Host type strings are not
|
|
8
|
+
* minified, so this works in release builds;
|
|
9
|
+
* 4. the display name of a component rendering the host (`Pressable`, `TouchableOpacity`, ...);
|
|
10
|
+
* only components with an explicit displayName are reliable in release builds;
|
|
11
|
+
* 5. a host whose owner has an `onPress` handler → `button`.
|
|
12
|
+
*
|
|
13
|
+
* Host names are React Native 0.88's (findings.md "6.1 源码确认") plus common community components.
|
|
14
|
+
*
|
|
15
|
+
* Widgets made of several hosts: a host rendered directly by its parent host's component
|
|
16
|
+
* (`renderedWithParent`) with the same semantic type as the parent is a PART of the parent widget,
|
|
17
|
+
* e.g. react-native-web's Switch = root `<div>` (testID, typed via the Switch displayName) + an
|
|
18
|
+
* `<input role="switch">` holding `checked` / `disabled`. Parts do not match `type` on their own,
|
|
19
|
+
* and the widget reads its state (checked, value, enabled, focus) from its parts.
|
|
20
|
+
*/
|
|
21
|
+
import type { JsonValue, SemanticType } from '@quiesco/protocol';
|
|
22
|
+
|
|
23
|
+
import type { HostNode } from '../fiber-adapter';
|
|
24
|
+
|
|
25
|
+
const ROLES: Readonly<Record<string, SemanticType>> = {
|
|
26
|
+
button: 'button',
|
|
27
|
+
togglebutton: 'button',
|
|
28
|
+
imagebutton: 'button',
|
|
29
|
+
link: 'link',
|
|
30
|
+
header: 'header',
|
|
31
|
+
heading: 'header',
|
|
32
|
+
text: 'text',
|
|
33
|
+
image: 'image',
|
|
34
|
+
img: 'image',
|
|
35
|
+
switch: 'switch',
|
|
36
|
+
checkbox: 'checkbox',
|
|
37
|
+
radio: 'radio',
|
|
38
|
+
adjustable: 'slider',
|
|
39
|
+
slider: 'slider',
|
|
40
|
+
search: 'input',
|
|
41
|
+
searchbox: 'input',
|
|
42
|
+
textbox: 'input',
|
|
43
|
+
progressbar: 'progress',
|
|
44
|
+
combobox: 'picker',
|
|
45
|
+
spinbutton: 'picker',
|
|
46
|
+
list: 'list',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const HOSTS: Readonly<Record<string, SemanticType>> = {
|
|
50
|
+
// React Native 0.88
|
|
51
|
+
RCTText: 'text',
|
|
52
|
+
RCTVirtualText: 'text',
|
|
53
|
+
RCTSelectableText: 'text',
|
|
54
|
+
RCTSinglelineTextInputView: 'input',
|
|
55
|
+
RCTMultilineTextInputView: 'input',
|
|
56
|
+
AndroidTextInput: 'input',
|
|
57
|
+
RCTImageView: 'image',
|
|
58
|
+
Switch: 'switch',
|
|
59
|
+
RCTSwitch: 'switch',
|
|
60
|
+
AndroidSwitch: 'switch',
|
|
61
|
+
ActivityIndicatorView: 'progress',
|
|
62
|
+
AndroidProgressBar: 'progress',
|
|
63
|
+
RCTScrollView: 'scroll',
|
|
64
|
+
AndroidHorizontalScrollView: 'scroll',
|
|
65
|
+
// Community components
|
|
66
|
+
RNCSlider: 'slider',
|
|
67
|
+
RNCPicker: 'picker',
|
|
68
|
+
RNCAndroidDialogPicker: 'picker',
|
|
69
|
+
RNCAndroidDropdownPicker: 'picker',
|
|
70
|
+
RNDateTimePicker: 'picker',
|
|
71
|
+
ExpoImage: 'image',
|
|
72
|
+
// DOM (react-native-web)
|
|
73
|
+
textarea: 'input',
|
|
74
|
+
img: 'image',
|
|
75
|
+
select: 'picker',
|
|
76
|
+
a: 'link',
|
|
77
|
+
button: 'button',
|
|
78
|
+
progress: 'progress',
|
|
79
|
+
h1: 'header',
|
|
80
|
+
h2: 'header',
|
|
81
|
+
h3: 'header',
|
|
82
|
+
h4: 'header',
|
|
83
|
+
h5: 'header',
|
|
84
|
+
h6: 'header',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const DOM_INPUT_TYPES: Readonly<Record<string, SemanticType>> = {
|
|
88
|
+
checkbox: 'checkbox',
|
|
89
|
+
radio: 'radio',
|
|
90
|
+
range: 'slider',
|
|
91
|
+
button: 'button',
|
|
92
|
+
submit: 'button',
|
|
93
|
+
reset: 'button',
|
|
94
|
+
image: 'button',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Components with an explicit displayName (or stable name) that identify the element's kind. */
|
|
98
|
+
const OWNERS: Readonly<Record<string, SemanticType>> = {
|
|
99
|
+
Text: 'text',
|
|
100
|
+
TextInput: 'input',
|
|
101
|
+
Image: 'image',
|
|
102
|
+
ImageBackground: 'image',
|
|
103
|
+
ScrollView: 'scroll',
|
|
104
|
+
ActivityIndicator: 'progress',
|
|
105
|
+
Switch: 'switch',
|
|
106
|
+
Slider: 'slider',
|
|
107
|
+
Picker: 'picker',
|
|
108
|
+
Checkbox: 'checkbox',
|
|
109
|
+
CheckBox: 'checkbox',
|
|
110
|
+
Pressable: 'button',
|
|
111
|
+
TouchableOpacity: 'button',
|
|
112
|
+
TouchableHighlight: 'button',
|
|
113
|
+
TouchableNativeFeedback: 'button',
|
|
114
|
+
Button: 'button',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function isFunction(v: unknown): boolean {
|
|
118
|
+
return typeof v === 'function';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function semanticType(node: HostNode): SemanticType | undefined {
|
|
122
|
+
if (node.role !== undefined) {
|
|
123
|
+
const r = ROLES[node.role.toLowerCase()];
|
|
124
|
+
if (r !== undefined) return r;
|
|
125
|
+
}
|
|
126
|
+
const host = node.type === 'input' ? (DOM_INPUT_TYPES[String(node.props['type'] ?? '')] ?? 'input') : HOSTS[node.type];
|
|
127
|
+
const owner = node.ownerProps();
|
|
128
|
+
if ((host === 'scroll' || node.clipsContent) && isFunction(owner['renderItem'])) return 'list';
|
|
129
|
+
if (host !== undefined) return host;
|
|
130
|
+
for (const name of node.ownerNames()) {
|
|
131
|
+
const o = OWNERS[name];
|
|
132
|
+
if (o !== undefined) return o;
|
|
133
|
+
}
|
|
134
|
+
return isFunction(owner['onPress']) ? 'button' : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether `node` is a part of its parent's widget (see the module comment). */
|
|
138
|
+
export function isWidgetPart(node: HostNode, type: SemanticType | undefined = semanticType(node)): boolean {
|
|
139
|
+
return type !== undefined && node.renderedWithParent && node.parent !== null && semanticType(node.parent) === type;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The parts of `node`'s widget (direct children rendered with it, of the same semantic type). */
|
|
143
|
+
export function widgetParts(node: HostNode, type: SemanticType | undefined = semanticType(node)): HostNode[] {
|
|
144
|
+
if (type === undefined) return [];
|
|
145
|
+
return node.children.filter((c) => c.renderedWithParent && semanticType(c) === type);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Whether `type` (from a matcher) names this element: semantic type, host type or a component name. */
|
|
149
|
+
export function matchesType(node: HostNode, type: string): boolean {
|
|
150
|
+
if (node.type === type || node.ownerNames().includes(type)) return true;
|
|
151
|
+
const kind = semanticType(node);
|
|
152
|
+
return kind === type && !isWidgetPart(node, kind);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The element's accessibility label, or its widget part's (RNW puts a Switch's aria-label on the inner input). */
|
|
156
|
+
export function widgetLabel(node: HostNode): string | undefined {
|
|
157
|
+
if (node.label !== undefined) return node.label;
|
|
158
|
+
for (const part of widgetParts(node)) if (part.label !== undefined) return part.label;
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Focused, or one of its widget parts is (the focusable `<input>` of a web Switch). */
|
|
163
|
+
export function isFocusedWidget(node: HostNode): boolean {
|
|
164
|
+
return node.isFocused() || widgetParts(node).some((p) => p.isFocused());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Props as the app wrote them: host props first, then the owning composites (Pressable `disabled`, ...). */
|
|
168
|
+
function propOf(node: HostNode, name: string): unknown {
|
|
169
|
+
const own = node.props[name];
|
|
170
|
+
return own !== undefined ? own : node.ownerProps()[name];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function accessibilityState(node: HostNode): Record<string, unknown> {
|
|
174
|
+
const s = propOf(node, 'accessibilityState');
|
|
175
|
+
return typeof s === 'object' && s !== null ? (s as Record<string, unknown>) : {};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isTrue(v: unknown): boolean {
|
|
179
|
+
return v === true || v === 'true';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function selfDisabled(node: HostNode): boolean {
|
|
183
|
+
return (
|
|
184
|
+
isTrue(propOf(node, 'disabled')) ||
|
|
185
|
+
isTrue(accessibilityState(node)['disabled']) ||
|
|
186
|
+
isTrue(propOf(node, 'aria-disabled')) ||
|
|
187
|
+
propOf(node, 'editable') === false
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* False when the element, an ancestor (a disabled Pressable disables its label too) or one of its
|
|
193
|
+
* widget parts is disabled.
|
|
194
|
+
*/
|
|
195
|
+
export function isEnabled(node: HostNode): boolean {
|
|
196
|
+
for (let n: HostNode | null = node; n !== null; n = n.parent) if (selfDisabled(n)) return false;
|
|
197
|
+
return !widgetParts(node).some(selfDisabled);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function toChecked(v: unknown): boolean | 'mixed' | undefined {
|
|
201
|
+
if (v === true || v === 'true') return true;
|
|
202
|
+
if (v === false || v === 'false') return false;
|
|
203
|
+
if (v === 'mixed') return 'mixed';
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Checked state: accessibilityState / aria-checked, a DOM checkbox, a Switch's `value`, or a widget part's. */
|
|
208
|
+
export function checkedState(node: HostNode, type: SemanticType | undefined = semanticType(node)): boolean | 'mixed' | undefined {
|
|
209
|
+
const own = ownChecked(node, type);
|
|
210
|
+
if (own !== undefined) return own;
|
|
211
|
+
for (const part of widgetParts(node, type)) {
|
|
212
|
+
const c = ownChecked(part, type);
|
|
213
|
+
if (c !== undefined) return c;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function ownChecked(node: HostNode, type: SemanticType | undefined): boolean | 'mixed' | undefined {
|
|
219
|
+
const fromA11y = toChecked(accessibilityState(node)['checked']) ?? toChecked(propOf(node, 'aria-checked'));
|
|
220
|
+
if (fromA11y !== undefined) return fromA11y;
|
|
221
|
+
if (type === 'switch' || type === 'checkbox' || type === 'radio') {
|
|
222
|
+
return toChecked(propOf(node, 'checked')) ?? toChecked(propOf(node, 'value'));
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function jsonScalar(v: unknown): JsonValue | undefined {
|
|
228
|
+
if (typeof v === 'string' || typeof v === 'boolean') return v;
|
|
229
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The element's value: input text, switch state, slider / picker value, accessibilityValue, or a widget part's. */
|
|
234
|
+
export function elementValue(node: HostNode, type: SemanticType | undefined = semanticType(node)): JsonValue | undefined {
|
|
235
|
+
const own = ownValue(node, type);
|
|
236
|
+
if (own !== undefined) return own;
|
|
237
|
+
for (const part of widgetParts(node, type)) {
|
|
238
|
+
const v = ownValue(part, type);
|
|
239
|
+
if (v !== undefined) return v;
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function ownValue(node: HostNode, type: SemanticType | undefined): JsonValue | undefined {
|
|
245
|
+
const text = node.inputValue();
|
|
246
|
+
if (text !== undefined) return text;
|
|
247
|
+
if (type === 'switch' || type === 'checkbox' || type === 'radio') {
|
|
248
|
+
const c = ownChecked(node, type);
|
|
249
|
+
if (typeof c === 'boolean') return c;
|
|
250
|
+
}
|
|
251
|
+
if (type === 'picker') {
|
|
252
|
+
const v = jsonScalar(propOf(node, 'selectedValue')) ?? jsonScalar(propOf(node, 'value'));
|
|
253
|
+
if (v !== undefined) return v;
|
|
254
|
+
}
|
|
255
|
+
if (type === 'slider' || type === 'progress') {
|
|
256
|
+
const v = jsonScalar(propOf(node, 'value'));
|
|
257
|
+
if (v !== undefined) return v;
|
|
258
|
+
}
|
|
259
|
+
const a11y = propOf(node, 'accessibilityValue');
|
|
260
|
+
if (typeof a11y === 'object' && a11y !== null) {
|
|
261
|
+
const o = a11y as Record<string, unknown>;
|
|
262
|
+
const v = jsonScalar(o['text']) ?? jsonScalar(o['now']);
|
|
263
|
+
if (v !== undefined) return v;
|
|
264
|
+
}
|
|
265
|
+
return jsonScalar(propOf(node, 'aria-valuetext')) ?? jsonScalar(propOf(node, 'aria-valuenow'));
|
|
266
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization of HostNodes: element summaries (query / assert results), the compact diagnostic
|
|
3
|
+
* tree (`getTree`), and the structure hash used by the frame-stable tracker.
|
|
4
|
+
*/
|
|
5
|
+
import type { ElementSummary, JsonValue, Rect, TreeNode } from '@quiesco/protocol';
|
|
6
|
+
|
|
7
|
+
import type { HostNode, Viewport } from '../fiber-adapter';
|
|
8
|
+
import { checkedState, elementValue, isEnabled, isFocusedWidget, semanticType, widgetLabel } from './semantic';
|
|
9
|
+
import { visibleRatio } from './visibility';
|
|
10
|
+
|
|
11
|
+
/** Props never worth sending: structural, or huge and useless for assertions. */
|
|
12
|
+
// `ref`: React 19 passes it as a prop; a DOM ref holds the element and its fiber (huge, circular).
|
|
13
|
+
const SKIPPED_PROPS = new Set(['children', 'style', 'collapsable', 'collapsableChildren', 'ref']);
|
|
14
|
+
const MAX_DEPTH = 4;
|
|
15
|
+
|
|
16
|
+
function isReactElement(v: object): boolean {
|
|
17
|
+
return '$$typeof' in v;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** JSON-safe copy: drops functions, symbols, React elements and cycles; bounds depth. */
|
|
21
|
+
export function toJsonValue(v: unknown, depth = 0, seen: Set<object> = new Set()): JsonValue | undefined {
|
|
22
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean') return v;
|
|
23
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
24
|
+
if (typeof v !== 'object' || depth >= MAX_DEPTH || seen.has(v) || isReactElement(v)) return undefined;
|
|
25
|
+
seen.add(v);
|
|
26
|
+
try {
|
|
27
|
+
if (Array.isArray(v)) return v.map((x) => toJsonValue(x, depth + 1, seen) ?? null);
|
|
28
|
+
const out: Record<string, JsonValue> = {};
|
|
29
|
+
for (const [k, x] of Object.entries(v)) {
|
|
30
|
+
const j = toJsonValue(x, depth + 1, seen);
|
|
31
|
+
if (j !== undefined) out[k] = j;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
} finally {
|
|
35
|
+
seen.delete(v);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function serializableProps(props: Readonly<Record<string, unknown>>): Record<string, JsonValue> {
|
|
40
|
+
const out: Record<string, JsonValue> = {};
|
|
41
|
+
for (const [k, v] of Object.entries(props)) {
|
|
42
|
+
if (SKIPPED_PROPS.has(k)) continue;
|
|
43
|
+
const j = toJsonValue(v);
|
|
44
|
+
if (j !== undefined) out[k] = j;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function summarize(node: HostNode, viewport: Viewport | null): ElementSummary {
|
|
50
|
+
const rect = node.measure();
|
|
51
|
+
const ratio = visibleRatio(node, viewport, rect);
|
|
52
|
+
const kind = semanticType(node);
|
|
53
|
+
const s: ElementSummary = {
|
|
54
|
+
type: node.type,
|
|
55
|
+
text: node.text,
|
|
56
|
+
rect,
|
|
57
|
+
visible: ratio > 0,
|
|
58
|
+
visibleRatio: Math.round(ratio * 1000) / 1000,
|
|
59
|
+
focused: isFocusedWidget(node),
|
|
60
|
+
enabled: isEnabled(node),
|
|
61
|
+
props: serializableProps(node.props),
|
|
62
|
+
};
|
|
63
|
+
if (node.testID !== undefined) s.testID = node.testID;
|
|
64
|
+
if (node.role !== undefined) s.role = node.role;
|
|
65
|
+
const label = widgetLabel(node);
|
|
66
|
+
if (label !== undefined) s.label = label;
|
|
67
|
+
if (kind !== undefined) s.semanticType = kind;
|
|
68
|
+
const checked = checkedState(node, kind);
|
|
69
|
+
if (checked !== undefined) s.checked = checked;
|
|
70
|
+
const value = elementValue(node, kind);
|
|
71
|
+
if (value !== undefined) s.value = value;
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function roundRect(r: Rect): Rect {
|
|
76
|
+
return { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function toTreeNode(node: HostNode): TreeNode {
|
|
80
|
+
const t: TreeNode = { type: node.type, children: node.children.map(toTreeNode) };
|
|
81
|
+
if (node.testID !== undefined) t.testID = node.testID;
|
|
82
|
+
const text = node.ownText;
|
|
83
|
+
if (text !== '') t.text = text;
|
|
84
|
+
if (node.role !== undefined) t.role = node.role;
|
|
85
|
+
const kind = semanticType(node);
|
|
86
|
+
if (kind !== undefined) t.semanticType = kind;
|
|
87
|
+
const rect = node.measure();
|
|
88
|
+
if (rect !== null) t.rect = roundRect(rect);
|
|
89
|
+
return t;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `getTree` result. Several top-level hosts are wrapped in a synthetic `root` node. */
|
|
93
|
+
export function getTree(roots: readonly HostNode[]): TreeNode | null {
|
|
94
|
+
if (roots.length === 0) return null;
|
|
95
|
+
if (roots.length === 1) return toTreeNode(roots[0] as HostNode);
|
|
96
|
+
return { type: 'root', children: roots.map(toTreeNode) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Structure snapshot for the frame-stable tracker (requirements.md §6.2-5): one line per host node in
|
|
101
|
+
* tree order with its type, testID, text, rounded rect and child count. Two frames are stable when the
|
|
102
|
+
* lines are equal; the lines also say which node changed when the tree never settles.
|
|
103
|
+
*/
|
|
104
|
+
export function structureLines(roots: readonly HostNode[], skip?: ReadonlySet<HostNode>): string[] {
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
const stack = [...roots].reverse();
|
|
107
|
+
while (stack.length > 0) {
|
|
108
|
+
const n = stack.pop() as HostNode;
|
|
109
|
+
if (skip?.has(n) === true) continue; // and its subtree
|
|
110
|
+
const r = n.measure();
|
|
111
|
+
const rect = r === null ? '-' : `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.width)}x${Math.round(r.height)}`;
|
|
112
|
+
lines.push(`${n.type}${n.testID === undefined ? '' : `#${n.testID}`}${n.ownText === '' ? '' : ` ${JSON.stringify(n.ownText)}`} @${rect} children=${n.children.length}`);
|
|
113
|
+
for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i] as HostNode);
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility rules (requirements.md §6.1; clipping added in Phase 6.1). The visible part of an
|
|
3
|
+
* element is its rect intersected with:
|
|
4
|
+
* - the viewport (window), when its size is known;
|
|
5
|
+
* - every ancestor that clips its content: scroll views and `overflow` other than `visible`.
|
|
6
|
+
*
|
|
7
|
+
* `visibleRatio` = visible area / element area. An element is visible when the ratio is > 0 and
|
|
8
|
+
* neither it nor an ancestor is hidden by style (`display: none` / `opacity: 0`).
|
|
9
|
+
*
|
|
10
|
+
* Not considered (documented limitations): being covered by another element (actions check that
|
|
11
|
+
* separately, ELEMENT_OBSCURED), and transforms not reflected in the measured rect.
|
|
12
|
+
*/
|
|
13
|
+
import type { Rect } from '@quiesco/protocol';
|
|
14
|
+
|
|
15
|
+
import type { HostNode, Viewport } from '../fiber-adapter';
|
|
16
|
+
|
|
17
|
+
export function hiddenByAncestorStyle(node: HostNode): boolean {
|
|
18
|
+
for (let n: HostNode | null = node; n !== null; n = n.parent) if (n.hiddenByStyle) return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function intersect(a: Rect, b: Rect): Rect | null {
|
|
23
|
+
const x = Math.max(a.x, b.x);
|
|
24
|
+
const y = Math.max(a.y, b.y);
|
|
25
|
+
const w = Math.min(a.x + a.width, b.x + b.width) - x;
|
|
26
|
+
const h = Math.min(a.y + a.height, b.y + b.height) - y;
|
|
27
|
+
return w > 0 && h > 0 ? { x, y, width: w, height: h } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function intersectsViewport(rect: Rect, viewport: Viewport): boolean {
|
|
31
|
+
return intersect(rect, { x: 0, y: 0, width: viewport.width, height: viewport.height }) !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The part of `rect` not clipped by the viewport or by clipping ancestors of `node`, or null when
|
|
36
|
+
* nothing is left. Ancestors that cannot be measured do not clip.
|
|
37
|
+
*/
|
|
38
|
+
export function visiblePart(node: HostNode, viewport: Viewport | null, rect: Rect): Rect | null {
|
|
39
|
+
let part: Rect | null = rect;
|
|
40
|
+
if (viewport !== null) part = intersect(part, { x: 0, y: 0, width: viewport.width, height: viewport.height });
|
|
41
|
+
for (let a = node.parent; a !== null && part !== null; a = a.parent) {
|
|
42
|
+
if (!a.clipsContent) continue;
|
|
43
|
+
const clip = a.measure();
|
|
44
|
+
if (clip !== null) part = intersect(part, clip);
|
|
45
|
+
}
|
|
46
|
+
return part;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Visible fraction of the element's area, 0–1. */
|
|
50
|
+
export function visibleRatio(node: HostNode, viewport: Viewport | null, rect: Rect | null = node.measure()): number {
|
|
51
|
+
if (hiddenByAncestorStyle(node)) return 0;
|
|
52
|
+
if (rect === null || rect.width <= 0 || rect.height <= 0) return 0;
|
|
53
|
+
const part = visiblePart(node, viewport, rect);
|
|
54
|
+
if (part === null) return 0;
|
|
55
|
+
return Math.min(1, (part.width * part.height) / (rect.width * rect.height));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isVisible(node: HostNode, viewport: Viewport | null, rect: Rect | null = node.measure()): boolean {
|
|
59
|
+
return visibleRatio(node, viewport, rect) > 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft reset (requirements.md §6.5):
|
|
3
|
+
* 1. clear persistent storage (built-in adapters for the platform + adapters registered by the app),
|
|
4
|
+
* 2. run the app's onReset hooks (in registration order),
|
|
5
|
+
* 3. remount the whole app by changing QuiescoRoot's key,
|
|
6
|
+
* 4. (the runtime then waits for idle).
|
|
7
|
+
* A failing step aborts the reset with RESET_FAILED naming the step.
|
|
8
|
+
*/
|
|
9
|
+
import type { StorageAdapter } from '../api-types';
|
|
10
|
+
import { QuiescoError } from '../errors';
|
|
11
|
+
|
|
12
|
+
export class ResetManager {
|
|
13
|
+
private readonly hooks: (() => void | Promise<void>)[] = [];
|
|
14
|
+
private readonly storages: StorageAdapter[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(private readonly builtInStorage: () => StorageAdapter[]) {}
|
|
17
|
+
|
|
18
|
+
onReset(hook: () => void | Promise<void>): () => void {
|
|
19
|
+
if (typeof hook !== 'function') throw new TypeError('quiesco.onReset(hook): hook must be a function');
|
|
20
|
+
this.hooks.push(hook);
|
|
21
|
+
return () => {
|
|
22
|
+
const i = this.hooks.indexOf(hook);
|
|
23
|
+
if (i >= 0) this.hooks.splice(i, 1);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerStorage(adapter: StorageAdapter): () => void {
|
|
28
|
+
if (adapter == null || typeof adapter.clear !== 'function') throw new TypeError('quiesco.registerStorage(adapter): adapter.clear must be a function');
|
|
29
|
+
this.storages.push(adapter);
|
|
30
|
+
return () => {
|
|
31
|
+
const i = this.storages.indexOf(adapter);
|
|
32
|
+
if (i >= 0) this.storages.splice(i, 1);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Steps 1–3. `remount` is null when QuiescoRoot is not mounted. */
|
|
37
|
+
async run(remount: (() => void) | null): Promise<void> {
|
|
38
|
+
for (const s of [...this.builtInStorage(), ...this.storages]) {
|
|
39
|
+
await step(`storage:${s.name}`, () => s.clear());
|
|
40
|
+
}
|
|
41
|
+
let i = 0;
|
|
42
|
+
for (const hook of [...this.hooks]) {
|
|
43
|
+
await step(`onReset#${i++}`, hook);
|
|
44
|
+
}
|
|
45
|
+
if (remount === null) {
|
|
46
|
+
throw new QuiescoError('RESET_FAILED', 'Soft reset failed at remount: QuiescoRoot is not mounted', { step: 'remount', message: 'QuiescoRoot is not mounted' });
|
|
47
|
+
}
|
|
48
|
+
await step('remount', remount);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function step(name: string, fn: () => void | Promise<void>): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
await fn();
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
57
|
+
throw new QuiescoError('RESET_FAILED', `Soft reset failed at ${name}: ${message}`, { step: name, message });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in stores cleared on soft reset, iOS / Android (Metro picks this over storage-defaults.ts).
|
|
3
|
+
* AsyncStorage is optional: Metro treats a `require` directly inside a `try` block as an optional
|
|
4
|
+
* dependency (transformer.allowOptionalDependencies, on by default), so apps without it still bundle.
|
|
5
|
+
* Only the default (legacy) store is cleared; stores from `createAsyncStorage(name)` need an adapter.
|
|
6
|
+
*/
|
|
7
|
+
import type { StorageAdapter } from '../api-types';
|
|
8
|
+
|
|
9
|
+
declare const require: (id: string) => unknown;
|
|
10
|
+
|
|
11
|
+
interface AsyncStorageLike {
|
|
12
|
+
clear(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function defaultStorageAdapters(): StorageAdapter[] {
|
|
16
|
+
let asyncStorage: AsyncStorageLike | undefined;
|
|
17
|
+
try {
|
|
18
|
+
const mod = require('@react-native-async-storage/async-storage') as { default?: AsyncStorageLike } & Partial<AsyncStorageLike>;
|
|
19
|
+
asyncStorage = mod.default ?? (mod as AsyncStorageLike);
|
|
20
|
+
} catch {
|
|
21
|
+
asyncStorage = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (asyncStorage === undefined || typeof asyncStorage.clear !== 'function') return [];
|
|
24
|
+
const s = asyncStorage;
|
|
25
|
+
return [{ name: 'AsyncStorage', clear: () => s.clear() }];
|
|
26
|
+
}
|