@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quiesco contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @quiesco/agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The in-app agent of Quiesco: element queries, synchronization trackers (network, timers, React,
|
|
4
|
+
animations), actions, soft reset and backdoors. It connects to the test run from inside the e2e build of
|
|
5
|
+
your app; without `EXPO_PUBLIC_QUIESCO=1` it is replaced by a no-op stub.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @quiesco/agent @quiesco/native
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- `metro.config.js`: `module.exports = withQuiesco(getDefaultConfig(__dirname))` with
|
|
12
|
+
`const { withQuiesco } = require('@quiesco/agent/metro')`.
|
|
13
|
+
- Import `@quiesco/agent` first in the entry file and wrap the root in `<QuiescoRoot>`.
|
|
14
|
+
|
|
15
|
+
`npx quiesco init` sets up Metro and the entry file. In-app API (`quiesco.backdoor`, `quiesco.onReset`, …):
|
|
16
|
+
[API reference](https://shift-14cc7601.mintlify.site/api).
|
|
17
|
+
|
|
18
|
+
Peer dependencies: `react` >= 19.2, `react-native` >= 0.83 (New Architecture). Supported: Expo SDK 58.
|
|
19
|
+
|
|
20
|
+
Part of [Quiesco](https://github.com/h2nology/quiesco): gray-box end-to-end testing for Expo apps on iOS, Android and the web. Setup: `npx quiesco init` ([quick start](https://github.com/h2nology/quiesco#quick-start-web-about-10-minutes)).
|
|
21
|
+
|
|
22
|
+
## License
|
|
23
|
+
|
|
24
|
+
MIT
|
package/metro.cjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Metro integration (docs/spikes/06-prod-strip.md, approach B).
|
|
4
|
+
*
|
|
5
|
+
* // metro.config.js
|
|
6
|
+
* const { getDefaultConfig } = require('expo/metro-config');
|
|
7
|
+
* const { withQuiesco } = require('@quiesco/agent/metro');
|
|
8
|
+
* module.exports = withQuiesco(getDefaultConfig(__dirname));
|
|
9
|
+
*
|
|
10
|
+
* - Not an e2e build (`EXPO_PUBLIC_QUIESCO !== '1'`): `@quiesco/agent` resolves to the stub, so no agent
|
|
11
|
+
* code reaches the bundle. A guard inside node_modules would not work: babel-preset-expo does not
|
|
12
|
+
* inline EXPO_PUBLIC_* there.
|
|
13
|
+
* - e2e build: the agent's `build-config` module is replaced by a generated file carrying the fallback
|
|
14
|
+
* port / session (`EXPO_PUBLIC_QUIESCO_PORT`, `EXPO_PUBLIC_QUIESCO_SESSION`), used when no launch
|
|
15
|
+
* parameters are available (JS-only mode).
|
|
16
|
+
* - e2e build: the agent's early module (`src/early.ts`: React DevTools hook + scheduler patch) is put
|
|
17
|
+
* first in `serializer.getModulesRunBeforeMainModule`. React Native 0.88 loads its renderer from
|
|
18
|
+
* `setup-env`, which Metro runs before the app entry; a hook installed later is never seen by the
|
|
19
|
+
* renderer in release builds (no React DevTools hook there).
|
|
20
|
+
*
|
|
21
|
+
* - e2e build: `expo/fetch` imported by the app or a library resolves to `src/expo-fetch.js`, which
|
|
22
|
+
* re-exports it with a tracked `fetch` (Phase 6.10: the real module's exports are non-configurable
|
|
23
|
+
* getters, so the network tracker cannot patch them at runtime). Expo's own code reaches its fetch
|
|
24
|
+
* through relative paths and is not affected; the global fetch is tracked separately.
|
|
25
|
+
*
|
|
26
|
+
* - Always: the artifacts directory (`options.artifactsDir`, default `artifacts`, relative to the project
|
|
27
|
+
* root; `false` for none) is added to `resolver.blockList` (Phase 6.12).
|
|
28
|
+
* Metro sends every connected app an HMR update for any watched file that changes, even one outside
|
|
29
|
+
* the bundle; the dev client then shows its "Refreshing..." window over the app, and a touch from
|
|
30
|
+
* another parallel worker lands on that window. Screenshots and JSON written there would do that.
|
|
31
|
+
* `.expo/devices.json` is blocked for the same reason: Expo's dev server rewrites it on every manifest
|
|
32
|
+
* request, i.e. every time a dev client app starts (`ManifestMiddleware.saveDevicesAsync`).
|
|
33
|
+
*
|
|
34
|
+
* Plain CommonJS on purpose: Node loads metro.config.js without a TypeScript step.
|
|
35
|
+
*/
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
|
|
39
|
+
const AGENT = '@quiesco/agent';
|
|
40
|
+
const AGENT_SRC = path.join(__dirname, 'src');
|
|
41
|
+
const STUB = path.join(AGENT_SRC, 'stub.ts');
|
|
42
|
+
const EARLY = path.join(AGENT_SRC, 'early.ts');
|
|
43
|
+
const EXPO_FETCH = path.join(AGENT_SRC, 'expo-fetch.js');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {NodeJS.ProcessEnv} env
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isE2E(env) {
|
|
50
|
+
return env.EXPO_PUBLIC_QUIESCO === '1';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write the build-time connection fallback and return its path.
|
|
55
|
+
* @param {string} projectRoot
|
|
56
|
+
* @param {NodeJS.ProcessEnv} env
|
|
57
|
+
*/
|
|
58
|
+
function writeBuildConfig(projectRoot, env) {
|
|
59
|
+
const port = Number.parseInt(env.EXPO_PUBLIC_QUIESCO_PORT ?? '', 10);
|
|
60
|
+
const config = { port: Number.isInteger(port) ? port : null, session: env.EXPO_PUBLIC_QUIESCO_SESSION ?? null };
|
|
61
|
+
const dir = path.join(projectRoot, '.expo', 'quiesco');
|
|
62
|
+
const file = path.join(dir, 'build-config.js');
|
|
63
|
+
const source = `// Generated by @quiesco/agent/metro. Do not edit.\nexport const buildConfig = ${JSON.stringify(config)};\n`;
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== source) fs.writeFileSync(file, source);
|
|
66
|
+
return file;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {string} p absolute path */
|
|
70
|
+
const escapePath = (p) => path.resolve(p).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `blockList` (a RegExp, a list of them, or unset) plus the given files and directories (with everything
|
|
74
|
+
* under them).
|
|
75
|
+
* @param {RegExp | RegExp[] | undefined | null} blockList
|
|
76
|
+
* @param {{ files: string[]; dirs: string[] }} paths absolute paths
|
|
77
|
+
* @returns {RegExp[]}
|
|
78
|
+
*/
|
|
79
|
+
function blockPaths(blockList, { files, dirs }) {
|
|
80
|
+
const existing = blockList === undefined || blockList === null ? [] : Array.isArray(blockList) ? blockList : [blockList];
|
|
81
|
+
return [
|
|
82
|
+
...existing,
|
|
83
|
+
...files.map((f) => new RegExp(`^${escapePath(f)}$`)),
|
|
84
|
+
...dirs.map((d) => new RegExp(`^${escapePath(d)}(?:[\\\\/].*)?$`)),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @template {{ projectRoot?: string; resolver?: { resolveRequest?: Function | null; blockList?: RegExp | RegExp[] | null }; serializer?: { getModulesRunBeforeMainModule?: (entryFilePath: string) => string[] } }} T
|
|
90
|
+
* @param {T} config Metro config (e.g. from expo/metro-config)
|
|
91
|
+
* @param {{ enabled?: boolean; env?: NodeJS.ProcessEnv; projectRoot?: string; artifactsDir?: string | false }} [options]
|
|
92
|
+
* @returns {T}
|
|
93
|
+
*/
|
|
94
|
+
function withQuiesco(config, options = {}) {
|
|
95
|
+
const env = options.env ?? process.env;
|
|
96
|
+
const enabled = options.enabled ?? isE2E(env);
|
|
97
|
+
const projectRoot = options.projectRoot ?? config.projectRoot ?? process.cwd();
|
|
98
|
+
const buildConfigFile = enabled ? writeBuildConfig(projectRoot, env) : null;
|
|
99
|
+
const upstream = config.resolver?.resolveRequest ?? null;
|
|
100
|
+
const artifactsDir = options.artifactsDir ?? 'artifacts';
|
|
101
|
+
const blockList = blockPaths(config.resolver?.blockList, {
|
|
102
|
+
files: [path.join(projectRoot, '.expo', 'devices.json')],
|
|
103
|
+
dirs: artifactsDir === false ? [] : [path.resolve(projectRoot, artifactsDir)],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {{ originModulePath: string; resolveRequest: Function }} context
|
|
108
|
+
* @param {string} moduleName
|
|
109
|
+
* @param {string | null} platform
|
|
110
|
+
*/
|
|
111
|
+
function resolveRequest(context, moduleName, platform) {
|
|
112
|
+
if (!enabled && moduleName === AGENT) return { type: 'sourceFile', filePath: STUB };
|
|
113
|
+
if (buildConfigFile !== null && moduleName === './build-config' && path.dirname(context.originModulePath) === AGENT_SRC) {
|
|
114
|
+
return { type: 'sourceFile', filePath: buildConfigFile };
|
|
115
|
+
}
|
|
116
|
+
if (enabled && moduleName === 'expo/fetch' && path.resolve(context.originModulePath) !== EXPO_FETCH) {
|
|
117
|
+
return { type: 'sourceFile', filePath: EXPO_FETCH };
|
|
118
|
+
}
|
|
119
|
+
return upstream !== null ? upstream(context, moduleName, platform) : context.resolveRequest(context, moduleName, platform);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const resolver = { ...config.resolver, resolveRequest, blockList };
|
|
123
|
+
if (!enabled) return { ...config, resolver };
|
|
124
|
+
|
|
125
|
+
// Metro compares these paths with the (real) paths of modules in the graph.
|
|
126
|
+
const early = fs.realpathSync(EARLY);
|
|
127
|
+
const upstreamBefore = config.serializer?.getModulesRunBeforeMainModule;
|
|
128
|
+
/** @param {string} entryFilePath */
|
|
129
|
+
function getModulesRunBeforeMainModule(entryFilePath) {
|
|
130
|
+
const rest = typeof upstreamBefore === 'function' ? upstreamBefore(entryFilePath) : [];
|
|
131
|
+
return [early, ...rest.filter((p) => p !== early)];
|
|
132
|
+
}
|
|
133
|
+
// Expo's serializer (withExpoSerializers) captured the original `config.serializer` object in
|
|
134
|
+
// getDefaultConfig() and reads getModulesRunBeforeMainModule from it when exporting (release
|
|
135
|
+
// bundles); the dev server reads the returned config. Set it on both.
|
|
136
|
+
if (config.serializer !== undefined && config.serializer !== null) config.serializer.getModulesRunBeforeMainModule = getModulesRunBeforeMainModule;
|
|
137
|
+
return {
|
|
138
|
+
...config,
|
|
139
|
+
resolver,
|
|
140
|
+
serializer: { ...config.serializer, getModulesRunBeforeMainModule },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { withQuiesco, isE2E };
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quiesco/agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Quiesco in-app agent (React Native / Expo, iOS / Android / Web)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "h2nology",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"quiesco",
|
|
9
|
+
"e2e",
|
|
10
|
+
"testing",
|
|
11
|
+
"expo",
|
|
12
|
+
"react-native",
|
|
13
|
+
"agent",
|
|
14
|
+
"metro"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/h2nology/quiesco/tree/main/packages/agent#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/h2nology/quiesco.git",
|
|
20
|
+
"directory": "packages/agent"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/h2nology/quiesco/issues"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=24"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "./src/index.ts",
|
|
33
|
+
"react-native": "./src/index.ts",
|
|
34
|
+
"types": "./src/index.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./src/index.ts",
|
|
37
|
+
"./stub": "./src/stub.ts",
|
|
38
|
+
"./metro": "./metro.cjs"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"metro.cjs"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@quiesco/protocol": "0.1.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/react": "19.3.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": ">=19.2",
|
|
52
|
+
"react-native": ">=0.83"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
6
57
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Geometry shared by the action performers: centers, hit testing, element descriptions. */
|
|
2
|
+
import type { Rect } from '@quiesco/protocol';
|
|
3
|
+
|
|
4
|
+
import type { HostNode } from '../fiber-adapter';
|
|
5
|
+
|
|
6
|
+
export function center(r: Rect): { x: number; y: number } {
|
|
7
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function contains(r: Rect, p: { x: number; y: number }): boolean {
|
|
11
|
+
return p.x >= r.x && p.x < r.x + r.width && p.y >= r.y && p.y < r.y + r.height;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Whether `a` is `b` or one of its ancestors (compared as rendered elements, not objects). */
|
|
15
|
+
export function isAncestor(a: HostNode, b: HostNode): boolean {
|
|
16
|
+
for (let n: HostNode | null = b; n !== null; n = n.parent) if (a.isSameElement(n)) return true;
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function describe(n: HostNode): string {
|
|
21
|
+
const id = n.testID !== undefined ? ` testID="${n.testID}"` : '';
|
|
22
|
+
const text = n.text !== '' ? ` text="${n.text.slice(0, 40)}"` : '';
|
|
23
|
+
return `<${n.type}${id}${text}>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Topmost host node whose rect contains `p` (later in render order = drawn on top; zIndex / elevation
|
|
28
|
+
* are not considered). `pointerEvents: none` removes a subtree, `box-none` only the node itself.
|
|
29
|
+
*/
|
|
30
|
+
export function hitTest(roots: readonly HostNode[], p: { x: number; y: number }): HostNode | null {
|
|
31
|
+
let top: HostNode | null = null;
|
|
32
|
+
const visit = (n: HostNode): void => {
|
|
33
|
+
if (n.hiddenByStyle) return;
|
|
34
|
+
const pe = n.props['pointerEvents'];
|
|
35
|
+
if (pe === 'none') return;
|
|
36
|
+
const r = n.measure();
|
|
37
|
+
if (pe !== 'box-none' && r !== null && r.width > 0 && r.height > 0 && contains(r, p)) top = n;
|
|
38
|
+
for (const c of n.children) visit(c);
|
|
39
|
+
};
|
|
40
|
+
for (const r of roots) visit(r);
|
|
41
|
+
return top;
|
|
42
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `native` action mode without @quiesco/native: the host injects real input (web: Playwright mouse,
|
|
3
|
+
* touchscreen, keyboard, wheel). The agent picks the point and runs the same checks as the in-app
|
|
4
|
+
* native mode (stable position, nothing on top), then returns the HostInput; the host injects it and
|
|
5
|
+
* waits for idle. Nothing here depends on the platform: a provider that cannot inject a kind of input
|
|
6
|
+
* rejects it with a clear error.
|
|
7
|
+
*/
|
|
8
|
+
import type { Direction, HostInput, Matcher, Point, Rect, Speed } from '@quiesco/protocol';
|
|
9
|
+
|
|
10
|
+
import { QuiescoError } from '../errors';
|
|
11
|
+
import type { HostNode, Viewport } from '../fiber-adapter';
|
|
12
|
+
import { summarize } from '../query';
|
|
13
|
+
import type { Clock } from '../sync';
|
|
14
|
+
import { center, describe, hitTest, isAncestor } from './hit-test';
|
|
15
|
+
import type { ActionPerformer } from './performer';
|
|
16
|
+
import type { FingerPath } from './multitouch';
|
|
17
|
+
import { framesForSequence, pinchGestures, rotateGesture } from './multitouch';
|
|
18
|
+
import * as value from './value';
|
|
19
|
+
|
|
20
|
+
export interface HostContext {
|
|
21
|
+
clock: Clock;
|
|
22
|
+
roots: () => readonly HostNode[];
|
|
23
|
+
viewport: () => Viewport | null;
|
|
24
|
+
matcher: Matcher;
|
|
25
|
+
deadline: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LONG_PRESS_MS = 800;
|
|
29
|
+
const STABLE_POLL_MS = 16;
|
|
30
|
+
const SCROLL_FRACTION = 0.8;
|
|
31
|
+
|
|
32
|
+
export function hostPerformer(ctx: HostContext): ActionPerformer {
|
|
33
|
+
const sleep = (ms: number): Promise<void> =>
|
|
34
|
+
new Promise((r) => {
|
|
35
|
+
ctx.clock.setTimeout(r, ms);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The node's rect once it stops moving, after checking nothing covers the touch point (default: center). */
|
|
39
|
+
const ready = async (node: HostNode, point?: Point): Promise<Rect> => {
|
|
40
|
+
let prev: Rect | null = null;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const rect = node.measure();
|
|
43
|
+
if (rect === null) throw new QuiescoError('ELEMENT_NOT_FOUND', 'The element cannot be measured (unmounted?)', { matcher: ctx.matcher });
|
|
44
|
+
const same = prev !== null && Math.abs(prev.x - rect.x) < 0.5 && Math.abs(prev.y - rect.y) < 0.5 && Math.abs(prev.width - rect.width) < 0.5;
|
|
45
|
+
if (same || ctx.clock.now() >= ctx.deadline) {
|
|
46
|
+
const top = hitTest(ctx.roots(), point === undefined ? center(rect) : { x: rect.x + point.x, y: rect.y + point.y });
|
|
47
|
+
if (top !== null && !isAncestor(node, top)) {
|
|
48
|
+
throw new QuiescoError('ELEMENT_OBSCURED', `${describe(node)} is covered by ${describe(top)}; a real click would not reach it`, {
|
|
49
|
+
matcher: ctx.matcher,
|
|
50
|
+
element: summarize(node, ctx.viewport()),
|
|
51
|
+
obscuredBy: describe(top),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return rect;
|
|
55
|
+
}
|
|
56
|
+
prev = rect;
|
|
57
|
+
await sleep(STABLE_POLL_MS);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const tapAt = async (node: HostNode, extra: { count?: number; holdMs?: number; point?: Point } = {}): Promise<HostInput> => {
|
|
62
|
+
const rect = await ready(node, extra.point);
|
|
63
|
+
const c = extra.point === undefined ? center(rect) : { x: rect.x + extra.point.x, y: rect.y + extra.point.y };
|
|
64
|
+
const input: HostInput = { kind: 'tap', x: c.x, y: c.y };
|
|
65
|
+
if (extra.count !== undefined) input.count = extra.count;
|
|
66
|
+
if (extra.holdMs !== undefined) input.holdMs = extra.holdMs;
|
|
67
|
+
return input;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const typeAt = async (node: HostNode, text: string, clearFirst: boolean): Promise<HostInput> => {
|
|
71
|
+
const c = center(await ready(node));
|
|
72
|
+
return { kind: 'type', x: c.x, y: c.y, text, clearFirst };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Wheel over the element (at `from`, normalized, when given); `down` reveals content below. */
|
|
76
|
+
const wheel = async (node: HostNode, content: Direction, distance?: number, from?: Point): Promise<HostInput> => {
|
|
77
|
+
const rect = await ready(node);
|
|
78
|
+
const at = from === undefined ? center(rect) : { x: rect.x + from.x * rect.width, y: rect.y + from.y * rect.height };
|
|
79
|
+
const vertical = content === 'up' || content === 'down';
|
|
80
|
+
const d = distance ?? (vertical ? rect.height : rect.width) * SCROLL_FRACTION;
|
|
81
|
+
const sign = content === 'down' || content === 'right' ? 1 : -1;
|
|
82
|
+
return { kind: 'wheel', x: at.x, y: at.y, dx: vertical ? 0 : sign * d, dy: vertical ? sign * d : 0 };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const contentFor = (finger: Direction): Direction => ({ up: 'down', down: 'up', left: 'right', right: 'left' } as const)[finger];
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
viaHost: true,
|
|
89
|
+
tap: (node, point) => tapAt(node, point === undefined ? {} : { point }),
|
|
90
|
+
longPress: (node, durationMs, point) => tapAt(node, { holdMs: durationMs ?? LONG_PRESS_MS, ...(point === undefined ? {} : { point }) }),
|
|
91
|
+
multiTap: (node, count) => tapAt(node, { count }),
|
|
92
|
+
type: (node, text) => typeAt(node, text, false),
|
|
93
|
+
replaceText: (node, text) => typeAt(node, text, true),
|
|
94
|
+
clear: (node) => typeAt(node, '', true),
|
|
95
|
+
async pressKey(node, key) {
|
|
96
|
+
// The key goes to the focused element; focusing puts the caret at the end of the text.
|
|
97
|
+
if (!node.focus()) throw new QuiescoError('ACTION_NOT_SUPPORTED', `${describe(node)} cannot take the keyboard focus`, { action: 'pressKey', platform: 'web' });
|
|
98
|
+
return { kind: 'key', key: key === 'enter' ? 'Enter' : 'Backspace' };
|
|
99
|
+
},
|
|
100
|
+
scroll: (node, direction, distance, from) => wheel(node, direction, distance, from),
|
|
101
|
+
// A wheel moves content the way the finger swipe would (speed has no wheel equivalent).
|
|
102
|
+
swipe: (node, direction, _speed?: Speed, distance?: number, from?: Point) => wheel(node, contentFor(direction), distance, from),
|
|
103
|
+
async drag(node, to, holdMs, durationMs) {
|
|
104
|
+
const from = center(await ready(node));
|
|
105
|
+
return { kind: 'drag', from, to, durationMs, holdMs };
|
|
106
|
+
},
|
|
107
|
+
tapAt: async (point, holdMs) => ({ kind: 'tap', x: point.x, y: point.y, ...(holdMs === undefined ? {} : { holdMs }) }),
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Real browser input where the DOM has it: `<select>` → selectOption, date / time inputs → fill,
|
|
111
|
+
* a switch / checkbox → click (when it must change), a segment → click. Sliders, and components
|
|
112
|
+
* that are not DOM form controls, are set through their callbacks (docs/api.mdx, value controls).
|
|
113
|
+
*/
|
|
114
|
+
async setValue(control, v) {
|
|
115
|
+
const node = control.node;
|
|
116
|
+
if (node === null || value.isDisabledControl(control)) return value.jsSetValue(control, v, 'web');
|
|
117
|
+
if (node.type === 'select' && (control.kind === 'picker' || control.kind === 'select')) {
|
|
118
|
+
const i = value.pickerIndex(control, v, 'web');
|
|
119
|
+
const c = center(await ready(node));
|
|
120
|
+
return { kind: 'select', x: c.x, y: c.y, value: String((value.pickerItems(control)[i] as value.PickerItem).value) };
|
|
121
|
+
}
|
|
122
|
+
const inputType = node.type === 'input' ? String(node.props['type'] ?? 'text') : '';
|
|
123
|
+
if (control.dom === 'input' && inputType !== 'range') {
|
|
124
|
+
const c = center(await ready(node));
|
|
125
|
+
return { kind: 'fill', x: c.x, y: c.y, value: value.inputString(inputType, v, 'web') };
|
|
126
|
+
}
|
|
127
|
+
if (control.kind === 'switch' || control.kind === 'checkbox') {
|
|
128
|
+
if (typeof v !== 'boolean' || value.switchState(control) === v) return value.jsSetValue(control, v, 'web');
|
|
129
|
+
return tapAt(node);
|
|
130
|
+
}
|
|
131
|
+
if (control.kind === 'segmented') {
|
|
132
|
+
const i = value.segmentIndex(control, v, 'web');
|
|
133
|
+
const n = value.segmentValues(control).length;
|
|
134
|
+
const r = node.measure();
|
|
135
|
+
if (i === control.props['selectedIndex'] || r === null || n === 0) return value.jsSetValue(control, v, 'web');
|
|
136
|
+
return tapAt(node, { point: { x: ((i + 0.5) * r.width) / n, y: r.height / 2 } });
|
|
137
|
+
}
|
|
138
|
+
return value.jsSetValue(control, v, 'web');
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Mouse drag of the thumb. The thumb is the square child of the slider (react-native-community
|
|
143
|
+
* slider's web implementation insets its track by half a thumb); the host injects the drag, so the
|
|
144
|
+
* result is not read back here (the step's assertion does that).
|
|
145
|
+
*/
|
|
146
|
+
async adjustSlider(node, control, position) {
|
|
147
|
+
const rect = await ready(node);
|
|
148
|
+
const thumb = findThumb(node, rect);
|
|
149
|
+
const t = thumb?.width ?? 0;
|
|
150
|
+
const lo = rect.x + t / 2;
|
|
151
|
+
const hi = rect.x + rect.width - t / 2;
|
|
152
|
+
const range = value.sliderRange(control.props);
|
|
153
|
+
const current = typeof control.props['value'] === 'number' && range.max > range.min ? (control.props['value'] - range.min) / (range.max - range.min) : 0;
|
|
154
|
+
const y = thumb === null ? rect.y + rect.height / 2 : thumb.y + thumb.height / 2;
|
|
155
|
+
const from = thumb === null ? { x: lo + current * (hi - lo), y } : { x: thumb.x + thumb.width / 2, y };
|
|
156
|
+
return { kind: 'drag', from, to: { x: lo + position * (hi - lo), y }, durationMs: 300, holdMs: 0 };
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// The DOM has no accessibility actions (react-native-web drops accessibilityActions): callbacks.
|
|
160
|
+
accessibilityAction: async (target, name) => value.jsAccessibilityAction(target, name, 'web'),
|
|
161
|
+
|
|
162
|
+
// Touch events with several touch points; the host rejects them on viewports without touch.
|
|
163
|
+
async pinch(node, scale, angle, speed, slop) {
|
|
164
|
+
const rect = await ready(node);
|
|
165
|
+
let gestures: FingerPath[][];
|
|
166
|
+
try {
|
|
167
|
+
gestures = pinchGestures(rect, scale, angle, speed, ctx.viewport(), slop);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw new QuiescoError('ACTION_NOT_SUPPORTED', e instanceof Error ? e.message : String(e), { action: 'pinch', platform: 'web' });
|
|
170
|
+
}
|
|
171
|
+
return touches(gestures);
|
|
172
|
+
},
|
|
173
|
+
rotate: async (node, radians, speed, slop) => touches([rotateGesture(await ready(node), radians, speed, slop)]),
|
|
174
|
+
async multiTouch(node, fingers) {
|
|
175
|
+
if (node !== null) await ready(node);
|
|
176
|
+
return touches([fingers]);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function touches(gestures: FingerPath[][]): HostInput {
|
|
182
|
+
return { kind: 'touches', fingers: Math.max(...gestures.map((g) => g.length)), frames: framesForSequence(gestures) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A square descendant narrower than half the slider: the thumb. */
|
|
186
|
+
function findThumb(node: HostNode, rect: Rect): Rect | null {
|
|
187
|
+
for (const c of node.children) {
|
|
188
|
+
const r = c.measure();
|
|
189
|
+
if (r !== null && r.width > 4 && Math.abs(r.width - r.height) < 1 && r.width < rect.width / 2) return r;
|
|
190
|
+
const deeper = findThumb(c, rect);
|
|
191
|
+
if (deeper !== null) return deeper;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|