@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
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
- # Temporary Holding Version
1
+ # @quiesco/agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }