diff --git a/package.json b/package.json index f9fb6cd9..1ce7e8b4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "import": "./dist/src/index.js", "types": "./dist/src/index.d.ts" }, + "./artifacts": { + "import": "./dist/src/artifacts.js", + "types": "./dist/src/artifacts.d.ts" + }, "./metro": { "import": "./dist/src/metro.js", "types": "./dist/src/metro.d.ts" @@ -20,9 +24,21 @@ "import": "./dist/src/remote-config.js", "types": "./dist/src/remote-config.d.ts" }, + "./install-source": { + "import": "./dist/src/install-source.js", + "types": "./dist/src/install-source.d.ts" + }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" + }, + "./selectors": { + "import": "./dist/src/selectors.js", + "types": "./dist/src/selectors.d.ts" + }, + "./finders": { + "import": "./dist/src/finders.js", + "types": "./dist/src/finders.d.ts" } }, "engines": { diff --git a/rslib.config.ts b/rslib.config.ts index 73f18bdd..9be0f798 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -17,9 +17,13 @@ export default defineConfig({ source: { entry: { index: 'src/index.ts', + artifacts: 'src/artifacts.ts', metro: 'src/metro.ts', 'remote-config': 'src/remote-config.ts', + 'install-source': 'src/install-source.ts', contracts: 'src/contracts.ts', + selectors: 'src/selectors.ts', + finders: 'src/finders.ts', }, tsconfigPath: 'tsconfig.lib.json', }, diff --git a/src/__tests__/artifacts-public.test.ts b/src/__tests__/artifacts-public.test.ts new file mode 100644 index 00000000..95706ef9 --- /dev/null +++ b/src/__tests__/artifacts-public.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { resolveAndroidArchivePackageName } from '../artifacts.ts'; + +const resolver: (archivePath: string) => Promise = + resolveAndroidArchivePackageName; + +test('package subpath exports android archive package resolver', () => { + assert.equal(typeof resolver, 'function'); +}); diff --git a/src/__tests__/finders-public.test.ts b/src/__tests__/finders-public.test.ts new file mode 100644 index 00000000..becaa3fe --- /dev/null +++ b/src/__tests__/finders-public.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + findBestMatchesByLocator, + normalizeRole, + normalizeText, + parseFindArgs, +} from '../finders.ts'; +import type { SnapshotNode } from '../utils/snapshot.ts'; + +function makeNode(ref: string, label?: string): SnapshotNode { + return { + index: Number(ref.replace('e', '')) || 0, + ref, + type: 'XCUIElementTypeButton', + label, + }; +} + +test('public finders entrypoint re-exports pure helpers', () => { + const nodes: SnapshotNode[] = [makeNode('e1', 'Continue')]; + + const parsed = parseFindArgs(['label', 'Continue', 'click']); + const best = findBestMatchesByLocator(nodes, 'label', 'Continue'); + const requireRectLegacy = findBestMatchesByLocator(nodes, 'label', 'Continue', true); + const requireRectOptions = findBestMatchesByLocator(nodes, 'label', 'Continue', { + requireRect: true, + }); + + assert.equal(normalizeText(' Continue\nNow '), 'continue now'); + assert.equal(normalizeRole('XCUIElementTypeApplication.XCUIElementTypeButton'), 'button'); + assert.equal(parsed.action, 'click'); + assert.equal(best.matches.length, 1); + assert.equal(requireRectLegacy.matches.length, 0); + assert.equal(requireRectOptions.matches.length, 0); +}); diff --git a/src/__tests__/install-source-public.test.ts b/src/__tests__/install-source-public.test.ts new file mode 100644 index 00000000..8320a4d6 --- /dev/null +++ b/src/__tests__/install-source-public.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { + ARCHIVE_EXTENSIONS, + isTrustedInstallSourceUrl, + materializeInstallablePath, + validateDownloadSourceUrl, +} from '../install-source.ts'; + +test('public install-source entrypoint re-exports pure helpers', () => { + assert.deepEqual(ARCHIVE_EXTENSIONS, ['.zip', '.tar', '.tar.gz', '.tgz']); + assert.equal( + isTrustedInstallSourceUrl('https://api.github.com/repos/acme/app/actions/artifacts/1/zip'), + true, + ); + assert.equal(typeof materializeInstallablePath, 'function'); + assert.equal(typeof validateDownloadSourceUrl, 'function'); +}); diff --git a/src/__tests__/selectors-public.test.ts b/src/__tests__/selectors-public.test.ts new file mode 100644 index 00000000..832908b4 --- /dev/null +++ b/src/__tests__/selectors-public.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + findSelectorChainMatch, + formatSelectorFailure, + isNodeEditable, + isNodeVisible, + isSelectorToken, + parseSelectorChain, + resolveSelectorChain, + tryParseSelectorChain, + type Selector, + type SelectorChain, + type SelectorDiagnostics, + type SelectorResolution, +} from '../selectors.ts'; +import type { SnapshotNode } from '../utils/snapshot.ts'; + +const nodes: SnapshotNode[] = [ + { + ref: 'e1', + index: 0, + type: 'android.widget.Button', + label: 'Continue', + rect: { x: 0, y: 0, width: 120, height: 48 }, + enabled: true, + }, + { + ref: 'e2', + index: 1, + type: 'android.widget.EditText', + label: 'Email', + rect: { x: 0, y: 64, width: 200, height: 48 }, + enabled: true, + }, +]; + +test('public selector subpath exposes platform-aware matching helpers', () => { + const chain: SelectorChain = parseSelectorChain('role=button label="Continue" visible=true'); + const firstSelector: Selector = chain.selectors[0]; + assert.equal(firstSelector.raw, 'role=button label="Continue" visible=true'); + assert.equal(tryParseSelectorChain(chain.raw)?.raw, chain.raw); + assert.equal(isSelectorToken('visible=true'), true); + + const match = findSelectorChainMatch(nodes, chain, { + platform: 'android', + requireRect: true, + }); + assert.ok(match); + assert.equal(match.matches, 1); + + const resolved: SelectorResolution | null = resolveSelectorChain(nodes, chain, { + platform: 'android', + requireRect: true, + }); + assert.equal(resolved?.node.ref, 'e1'); + + assert.equal(isNodeVisible(nodes[0]), true); + assert.equal(isNodeEditable(nodes[1], 'android'), true); +}); + +test('public selector diagnostics format failures', () => { + const chain = parseSelectorChain('label=Missing'); + const diagnostics: SelectorDiagnostics[] = [{ selector: 'label=Missing', matches: 0 }]; + + assert.equal( + formatSelectorFailure(chain, diagnostics, { unique: false }), + 'Selector did not match (label=Missing -> 0)', + ); +}); diff --git a/src/artifacts.ts b/src/artifacts.ts new file mode 100644 index 00000000..34ed3c32 --- /dev/null +++ b/src/artifacts.ts @@ -0,0 +1 @@ +export { resolveAndroidArchivePackageName } from './platforms/android/manifest.ts'; diff --git a/src/finders.ts b/src/finders.ts new file mode 100644 index 00000000..b08a054f --- /dev/null +++ b/src/finders.ts @@ -0,0 +1,23 @@ +export type { FindLocator } from './utils/finders.ts'; +export type { SnapshotNode } from './utils/snapshot.ts'; +export { normalizeRole, normalizeText, parseFindArgs } from './utils/finders.ts'; + +import { + findBestMatchesByLocator as findBestMatchesByLocatorInternal, + type FindLocator, +} from './utils/finders.ts'; +import type { SnapshotNode } from './utils/snapshot.ts'; + +export type FindMatchOptions = { + requireRect?: boolean; +}; + +export function findBestMatchesByLocator( + nodes: SnapshotNode[], + locator: FindLocator, + query: string, + options?: boolean | FindMatchOptions, +) { + const matchOptions = typeof options === 'boolean' ? { requireRect: options } : options; + return findBestMatchesByLocatorInternal(nodes, locator, query, matchOptions); +} diff --git a/src/install-source.ts b/src/install-source.ts new file mode 100644 index 00000000..b464b411 --- /dev/null +++ b/src/install-source.ts @@ -0,0 +1,13 @@ +export { + ARCHIVE_EXTENSIONS, + isBlockedIpAddress, + isBlockedSourceHostname, + isTrustedInstallSourceUrl, + materializeInstallablePath, + validateDownloadSourceUrl, +} from './platforms/install-source.ts'; + +export type { + MaterializeInstallSource, + MaterializedInstallable, +} from './platforms/install-source.ts'; diff --git a/src/selectors.ts b/src/selectors.ts new file mode 100644 index 00000000..0169886e --- /dev/null +++ b/src/selectors.ts @@ -0,0 +1,18 @@ +export type { + Selector, + SelectorChain, + SelectorDiagnostics, + SelectorResolution, +} from './daemon/selectors.ts'; +export type { SnapshotNode } from './utils/snapshot.ts'; + +export { + findSelectorChainMatch, + formatSelectorFailure, + isNodeEditable, + isNodeVisible, + isSelectorToken, + parseSelectorChain, + resolveSelectorChain, + tryParseSelectorChain, +} from './daemon/selectors.ts'; diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 28750dc9..f8072113 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -23,6 +23,32 @@ Public subpath API exposed for Node consumers: - types: `RemoteConfigProfile`, `RemoteConfigProfileOptions`, `ResolvedRemoteConfigProfile` - `agent-device/contracts` - types: `SessionRuntimeHints`, `DaemonInstallSource`, `DaemonLockPolicy`, `DaemonRequestMeta`, `DaemonRequest`, `DaemonArtifact`, `DaemonResponseData`, `DaemonError`, `DaemonResponse` +- `agent-device/selectors` + - `parseSelectorChain(expression)` + - `tryParseSelectorChain(expression)` + - `resolveSelectorChain(nodes, chain, options)` + - `findSelectorChainMatch(nodes, chain, options)` + - `formatSelectorFailure(chain, diagnostics, options)` + - `isNodeVisible(node)` + - `isSelectorToken(token)` + - `isNodeEditable(node, platform)` + - types: `Selector`, `SelectorChain`, `SelectorDiagnostics`, `SelectorResolution`, `SnapshotNode` +- `agent-device/finders` + - `findBestMatchesByLocator(nodes, locator, query, requireRectOrOptions)` + - `normalizeRole(value)` + - `normalizeText(value)` + - `parseFindArgs(args)` + - types: `FindLocator`, `FindMatchOptions`, `SnapshotNode` +- `agent-device/install-source` + - `ARCHIVE_EXTENSIONS` + - `isBlockedIpAddress(address)` + - `isBlockedSourceHostname(hostname)` + - `isTrustedInstallSourceUrl(sourceUrl)` + - `materializeInstallablePath(options)` + - `validateDownloadSourceUrl(url)` + - types: `MaterializeInstallSource`, `MaterializedInstallable` +- `agent-device/artifacts` + - `resolveAndroidArchivePackageName(archivePath)` ## Basic usage @@ -189,3 +215,22 @@ await stopMetroTunnel({ ``` Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. + +## Selector helpers + +Use `agent-device/selectors` when a remote daemon or bridge needs to parse and match selector expressions without deep-importing daemon internals. Matching is platform-aware because role normalization and editability checks differ by backend. + +```ts +import { findSelectorChainMatch, parseSelectorChain } from 'agent-device/selectors'; + +const chain = parseSelectorChain('role=button label="Continue" visible=true'); + +const match = findSelectorChainMatch(snapshot.nodes, chain, { + platform: 'android', + requireRect: true, +}); + +if (!match) { + // Build a daemon-shaped error with formatSelectorFailure(...) if needed. +} +```