diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 5f128c7bd8..d3141f8370 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -153,8 +153,12 @@ selection, R11/R13 package enumeration, and the composite typecheck project list > screenshot density and pixel diffing, snapshot occlusion, mobile snapshot semantics, > quality verdicts and backend capability tables. Snapshot *behavior* is capture domain, not > contracts vocabulary, and host mechanics are host-kit's, not capture-kit's. -> - `@agent-device/provision-kit` owns provisioning mechanics — install-artifact acquisition -> (local paths, archives, guarded network downloads) and host toolchain probing. +> - `@agent-device/provision-kit` owns the provisioning domain — everything that gets a device +> and app ready to run: install-artifact acquisition (local paths, archives, guarded network +> downloads), host toolchain readiness probing, device boot-failure classification, and +> app-resolution caching. Platform packages may import provision-kit; provision-kit may not +> import a platform package or be imported by capture-kit (both directions planted red in the +> layering suite). > - The enforced direction is `kernel < contracts < host-kit < capture-kit < provision-kit < > platform/provider/daemon`. > - Contracts stays vocabulary, plan models, and pure classification with no process, diff --git a/package.json b/package.json index ebaa46113b..8fb221e518 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/host-kit packages/capture-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/host-kit packages/capture-kit packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -289,6 +289,7 @@ "@agent-device/platform-web": "workspace:*", "@agent-device/provider-limrun": "workspace:*", "@agent-device/provider-webdriver": "workspace:*", + "@agent-device/provision-kit": "workspace:*", "@agent-device/replay-test": "workspace:*", "@agent-device/selectors": "workspace:*", "@agent-device/xml": "workspace:*", diff --git a/packages/kernel/package.json b/packages/kernel/package.json index a3b741b2a3..e1ae4b3d4d 100644 --- a/packages/kernel/package.json +++ b/packages/kernel/package.json @@ -37,6 +37,10 @@ "types": "./src/location-coordinates.ts", "default": "./src/location-coordinates.ts" }, + "./numeric": { + "types": "./src/numeric.ts", + "default": "./src/numeric.ts" + }, "./record": { "types": "./src/record.ts", "default": "./src/record.ts" diff --git a/src/platforms/perf-utils.ts b/packages/kernel/src/numeric.ts similarity index 100% rename from src/platforms/perf-utils.ts rename to packages/kernel/src/numeric.ts diff --git a/packages/provision-kit/package.json b/packages/provision-kit/package.json new file mode 100644 index 0000000000..0211ebf404 --- /dev/null +++ b/packages/provision-kit/package.json @@ -0,0 +1,44 @@ +{ + "name": "@agent-device/provision-kit", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Private provisioning mechanics shared by platform runtimes and daemon orchestration: install-artifact acquisition (local paths, archives, guarded network downloads) and host toolchain probing. Internal workspace package bundled into the published agent-device artifact.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/host-kit": "workspace:*", + "@agent-device/kernel": "workspace:*", + "ipaddr.js": "^2.5.0", + "undici": "7.29.0" + }, + "exports": { + "./app-resolution-cache": { + "types": "./src/app-resolution-cache.ts", + "default": "./src/app-resolution-cache.ts" + }, + "./boot-diagnostics": { + "types": "./src/boot-diagnostics.ts", + "default": "./src/boot-diagnostics.ts" + }, + "./install-artifact-archive-context": { + "types": "./src/install-artifact-archive-context.ts", + "default": "./src/install-artifact-archive-context.ts" + }, + "./install-source": { + "types": "./src/install-source.ts", + "default": "./src/install-source.ts" + }, + "./install-source-network": { + "types": "./src/install-source-network.ts", + "default": "./src/install-source-network.ts" + }, + "./install-source-network-transport": { + "types": "./src/install-source-network-transport.ts", + "default": "./src/install-source-network-transport.ts" + }, + "./toolchain-probe": { + "types": "./src/toolchain-probe.ts", + "default": "./src/toolchain-probe.ts" + } + } +} diff --git a/src/platforms/__tests__/app-resolution-cache.test.ts b/packages/provision-kit/src/app-resolution-cache.test.ts similarity index 97% rename from src/platforms/__tests__/app-resolution-cache.test.ts rename to packages/provision-kit/src/app-resolution-cache.test.ts index 2e7c27d706..b5d7f4dc6a 100644 --- a/src/platforms/__tests__/app-resolution-cache.test.ts +++ b/packages/provision-kit/src/app-resolution-cache.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { createAppResolutionCache } from '../app-resolution-cache.ts'; +import { createAppResolutionCache } from './app-resolution-cache.ts'; test('app resolution cache returns values until the expiry boundary', () => { let nowMs = 1_000; diff --git a/src/platforms/app-resolution-cache.ts b/packages/provision-kit/src/app-resolution-cache.ts similarity index 100% rename from src/platforms/app-resolution-cache.ts rename to packages/provision-kit/src/app-resolution-cache.ts diff --git a/src/platforms/__tests__/boot-diagnostics.test.ts b/packages/provision-kit/src/boot-diagnostics.test.ts similarity index 99% rename from src/platforms/__tests__/boot-diagnostics.test.ts rename to packages/provision-kit/src/boot-diagnostics.test.ts index 67e80ebb3d..07601c10d7 100644 --- a/src/platforms/__tests__/boot-diagnostics.test.ts +++ b/packages/provision-kit/src/boot-diagnostics.test.ts @@ -4,7 +4,7 @@ import { bootFailureHint, classifyBootFailure, isInfrastructureBootFailureReason, -} from '../boot-diagnostics.ts'; +} from './boot-diagnostics.ts'; import { AppError } from '@agent-device/kernel/errors'; test('classifyBootFailure maps timeout errors', () => { diff --git a/src/platforms/boot-diagnostics.ts b/packages/provision-kit/src/boot-diagnostics.ts similarity index 99% rename from src/platforms/boot-diagnostics.ts rename to packages/provision-kit/src/boot-diagnostics.ts index 6fbbb87493..35df950d9d 100644 --- a/src/platforms/boot-diagnostics.ts +++ b/packages/provision-kit/src/boot-diagnostics.ts @@ -14,6 +14,7 @@ type BootDiagnosticContext = { phase?: 'boot' | 'connect' | 'transport'; }; +// fallow-ignore-next-line complexity export function classifyBootFailure(input: { error?: unknown; message?: string; diff --git a/src/platforms/install-artifact-archive-context.ts b/packages/provision-kit/src/install-artifact-archive-context.ts similarity index 100% rename from src/platforms/install-artifact-archive-context.ts rename to packages/provision-kit/src/install-artifact-archive-context.ts diff --git a/src/platforms/install-source-archive.ts b/packages/provision-kit/src/install-source-archive.ts similarity index 100% rename from src/platforms/install-source-archive.ts rename to packages/provision-kit/src/install-source-archive.ts diff --git a/src/platforms/__tests__/install-source-download.test.ts b/packages/provision-kit/src/install-source-download.test.ts similarity index 96% rename from src/platforms/__tests__/install-source-download.test.ts rename to packages/provision-kit/src/install-source-download.test.ts index 450b8c8737..5484ea0a42 100644 --- a/src/platforms/__tests__/install-source-download.test.ts +++ b/packages/provision-kit/src/install-source-download.test.ts @@ -3,9 +3,9 @@ import dns from 'node:dns/promises'; import fs from 'node:fs/promises'; import { Readable } from 'node:stream'; import { test, vi } from 'vitest'; -import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; -import { downloadInstallSource } from '../install-source-download.ts'; -import * as networkTransport from '../install-source-network-transport.ts'; +import { mkdtempForTest } from './tmp-dir.fixtures.ts'; +import { downloadInstallSource } from './install-source-download.ts'; +import * as networkTransport from './install-source-network-transport.ts'; test('download redirects revalidate destinations and strip sensitive cross-origin headers', async () => { const tempRoot = await mkdtempForTest('agent-device-download-redirect-'); diff --git a/src/platforms/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts similarity index 100% rename from src/platforms/install-source-download.ts rename to packages/provision-kit/src/install-source-download.ts diff --git a/src/platforms/__tests__/install-source-network-transport.test.ts b/packages/provision-kit/src/install-source-network-transport.test.ts similarity index 93% rename from src/platforms/__tests__/install-source-network-transport.test.ts rename to packages/provision-kit/src/install-source-network-transport.test.ts index 258d9f8d4e..6c8c78a506 100644 --- a/src/platforms/__tests__/install-source-network-transport.test.ts +++ b/packages/provision-kit/src/install-source-network-transport.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { matchesNoProxy, resolveProxyForUrl } from '../install-source-network-transport.ts'; +import { matchesNoProxy, resolveProxyForUrl } from './install-source-network-transport.ts'; test('lowercase proxy variables override uppercase even when empty', () => { assert.equal( diff --git a/src/platforms/install-source-network-transport.ts b/packages/provision-kit/src/install-source-network-transport.ts similarity index 100% rename from src/platforms/install-source-network-transport.ts rename to packages/provision-kit/src/install-source-network-transport.ts diff --git a/src/platforms/install-source-network.ts b/packages/provision-kit/src/install-source-network.ts similarity index 100% rename from src/platforms/install-source-network.ts rename to packages/provision-kit/src/install-source-network.ts diff --git a/src/platforms/install-source.ts b/packages/provision-kit/src/install-source.ts similarity index 100% rename from src/platforms/install-source.ts rename to packages/provision-kit/src/install-source.ts diff --git a/packages/provision-kit/src/tmp-dir.fixtures.ts b/packages/provision-kit/src/tmp-dir.fixtures.ts new file mode 100644 index 0000000000..67812892d4 --- /dev/null +++ b/packages/provision-kit/src/tmp-dir.fixtures.ts @@ -0,0 +1,8 @@ +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +// fallow-ignore-next-line code-duplication +export async function mkdtempForTest(prefix: string): Promise { + return fsPromises.mkdtemp(path.join(os.tmpdir(), prefix)); +} diff --git a/src/platforms/toolchain-probe.ts b/packages/provision-kit/src/toolchain-probe.ts similarity index 100% rename from src/platforms/toolchain-probe.ts rename to packages/provision-kit/src/toolchain-probe.ts diff --git a/packages/provision-kit/tsconfig.json b/packages/provision-kit/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/provision-kit/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 711fd4d7d1..d086575e72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@agent-device/provider-webdriver': specifier: workspace:* version: link:packages/provider-webdriver + '@agent-device/provision-kit': + specifier: workspace:* + version: link:packages/provision-kit '@agent-device/replay-test': specifier: workspace:* version: link:packages/replay-test @@ -348,6 +351,24 @@ importers: specifier: workspace:* version: link:../xml + packages/provision-kit: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/host-kit': + specifier: workspace:* + version: link:../host-kit + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + ipaddr.js: + specifier: ^2.5.0 + version: 2.5.0 + undici: + specifier: ^7.29.0 + version: 7.29.0 + packages/replay-test: dependencies: '@agent-device/contracts': diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index fcd9a0ffef..f9169c1707 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -82,6 +82,7 @@ import { } from './package-boundaries.ts'; import { checkPlatformPackagePolicy, + checkPlatformsRootShape, platformPackagePolicySummary, } from './platform-package-policy.ts'; import { @@ -552,6 +553,7 @@ export const LAYERING_RULE_IDS = [ 'bin-alias-fast-path', 'package-boundaries', 'platform-package-policy', + 'platforms-root-shape', ] as const; export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number]; @@ -585,6 +587,8 @@ export const LAYERING_RULES: Readonly> = { readTrackedPlatformPackageDeclarations(repoRoot), { untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) }, ), + 'platforms-root-shape': (context) => + checkPlatformsRootShape([...context.allTypeScriptSources.keys()]), }; export function main(): number { diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 4ca1aadc91..d45a4443fe 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -91,6 +91,7 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ 'kernel', 'host-kit', 'capture-kit', + 'provision-kit', 'platform-apple', 'platform-android', 'platform-harmonyos', diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 9d7a153fc1..9ff43b863f 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -470,6 +470,29 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/snapshot-quality-backend-capabilities', '@agent-device/capture-kit/snapshot-quality-verdict', ]); + + const provisionKitPackage = packages.find((pkg) => pkg.name === '@agent-device/provision-kit'); + assert.ok(provisionKitPackage, 'provision-kit package must exist'); + assert.equal( + JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/provision-kit/package.json'), 'utf8')) + .private, + true, + 'provision-kit stays a private implementation package', + ); + assert.deepEqual([...provisionKitPackage.exportTargets.keys()].sort(), [ + '@agent-device/provision-kit/app-resolution-cache', + '@agent-device/provision-kit/boot-diagnostics', + '@agent-device/provision-kit/install-artifact-archive-context', + '@agent-device/provision-kit/install-source', + '@agent-device/provision-kit/install-source-network', + '@agent-device/provision-kit/install-source-network-transport', + '@agent-device/provision-kit/toolchain-probe', + ]); + assert.deepEqual([...provisionKitPackage.workspaceDependencies].sort(), [ + '@agent-device/contracts', + '@agent-device/host-kit', + '@agent-device/kernel', + ]); assert.deepEqual([...captureKitPackage.workspaceDependencies].sort(), [ '@agent-device/contracts', '@agent-device/host-kit', diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index 8dcf85ad18..510edf0ce9 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test'; import { CANONICAL_PLATFORM_FAMILIES, checkPlatformPackagePolicy, + checkPlatformsRootShape, type PlatformPackageDeclaration, } from './platform-package-policy.ts'; import { classifyZone } from './model.ts'; @@ -385,7 +386,7 @@ test('platform packages may use capture-kit but no unrelated workspace implement ); assert.match( messages(sources).join('\n'), - /may import workspace code only from capture-kit, host-kit, contracts, kernel, or xml/, + /may import workspace code only from capture-kit, host-kit, provision-kit, contracts, kernel, or xml/, ); } }); @@ -490,3 +491,51 @@ test('Node resolves only each platform package root facade', () => { ); } }); + +test('the src/platforms root holds only family directories and __tests__', () => { + const clean = CANONICAL_PLATFORM_FAMILIES.map((family) => `src/platforms/${family}/doctor.ts`); + assert.deepEqual( + checkPlatformsRootShape([...clean, 'src/platforms/__tests__/install-source.test.ts']), + [], + ); +}); + +test('a new direct production file or sibling directory under src/platforms fails closed', () => { + const planted = [ + 'src/platforms/shared-helper.ts', + 'src/platforms/common/util.ts', + 'src/platforms/perf-utils.ts', + ]; + const found = checkPlatformsRootShape(planted); + assert.deepEqual( + found.map(({ file }) => file), + planted, + ); + for (const violation of found) { + assert.equal(violation.rule, 'platforms-root-shape'); + assert.match(violation.message, /substrate package/); + } +}); + +test('platform packages may import the provision-kit substrate', () => { + const sources = validSources(); + sources.set( + 'packages/platform-apple/src/install.ts', + "import { resolveInstallSource } from '@agent-device/provision-kit/install-source';", + ); + assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); +}); + +test('a provision-kit import of a concrete platform package fails closed', () => { + const sources = validSources(); + sources.set( + 'packages/provision-kit/src/backdoor.ts', + "import { runtimeModule } from '@agent-device/platform-android';", + ); + assert.match( + checkPlatformPackagePolicy(sources, declarations()) + .map(({ message }) => message) + .join('\n'), + /may import '@agent-device\/platform-android'/, + ); +}); diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index dca542aef8..9caf01cbc3 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -33,6 +33,21 @@ const RAW_PROCESS_SPECIFIERS = new Set(['child_process', 'node:child_process']); // narrows with it — the facet itself is durable Apple ownership, not a // temporary exception). export const APPLE_RUNNER_SUBTREE = 'packages/platform-apple/src/runner/'; + +export function checkPlatformsRootShape(files: readonly string[]): LayeringViolation[] { + const allowedChild = new RegExp( + `^src/platforms/(?:${[...CANONICAL_PLATFORM_FAMILIES, '__tests__'].join('|')})/`, + ); + return files + .filter((file) => file.startsWith('src/platforms/') && !allowedChild.test(file)) + .map((file) => ({ + rule: 'platforms-root-shape', + file, + line: 1, + message: + 'src/platforms may hold only the family directories and __tests__; shared code belongs in a substrate package', + })); +} const APPLE_RUNNER_FACADE = '@agent-device/platform-apple/runner'; const APPLE_RUNNER_CLIENT = '@agent-device/platform-apple/runner/client'; const APPLE_RUNNER_TEST_HOST = '@agent-device/platform-apple/runner/test-host'; @@ -264,6 +279,7 @@ function checkSource(file: string, source: string): LayeringViolation[] { site.spec !== '@agent-device/capture-kit' && !site.spec.startsWith('@agent-device/capture-kit/') && !site.spec.startsWith('@agent-device/host-kit/') && + !site.spec.startsWith('@agent-device/provision-kit/') && !site.spec.startsWith('@agent-device/kernel/') && site.spec !== '@agent-device/xml' && !isPackageOwnedFacadeTest(file, ownerFamily, site.spec) @@ -272,7 +288,7 @@ function checkSource(file: string, source: string): LayeringViolation[] { violation( file, site.line, - `platform-${ownerFamily} may import workspace code only from capture-kit, host-kit, contracts, kernel, or xml; found '${site.spec}'`, + `platform-${ownerFamily} may import workspace code only from capture-kit, host-kit, provision-kit, contracts, kernel, or xml; found '${site.spec}'`, ), ); } diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index e4fb350361..444761d4d5 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -145,6 +145,15 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/host-kit/src/retry.ts': 6, 'packages/host-kit/src/version.ts': 4, + // --- @agent-device/provision-kit --- + 'packages/provision-kit/src/app-resolution-cache.ts': 1, + 'packages/provision-kit/src/boot-diagnostics.ts': 4, + 'packages/provision-kit/src/install-artifact-archive-context.ts': 10, + 'packages/provision-kit/src/install-source.ts': 25, + 'packages/provision-kit/src/install-source-network.ts': 3, + 'packages/provision-kit/src/install-source-network-transport.ts': 1, + 'packages/provision-kit/src/toolchain-probe.ts': 8, + // --- @agent-device/contracts --- 'packages/contracts/src/alert-contract.ts': 1, 'packages/contracts/src/android-clipboard-support.ts': 1, @@ -259,6 +268,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/kernel/src/errors.ts': 2, // Added by #2041: keyed async lock moved from src/utils for the extracted IME lifecycle. 'packages/kernel/src/keyed-lock.ts': 1, + 'packages/kernel/src/numeric.ts': 1, 'packages/kernel/src/rect-center.ts': 2, 'packages/kernel/src/rect.ts': 1, 'packages/kernel/src/device-isolation.ts': 1, diff --git a/src/core/interactors/__tests__/unsupported-interactor.test.ts b/src/core/interactors/__tests__/unsupported-interactor.test.ts new file mode 100644 index 0000000000..0c474fd0fa --- /dev/null +++ b/src/core/interactors/__tests__/unsupported-interactor.test.ts @@ -0,0 +1,39 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; +import { createUnsupportedInteractor } from '../unsupported-interactor.ts'; + +test('every operation on the factory rejects, naming itself and the platform', async () => { + const interactor = createUnsupportedInteractor('web'); + const entries = Object.entries(interactor) as [string, () => Promise][]; + + assert.ok(entries.length > 0, 'the factory exposes operations'); + + const failures = await Promise.all( + entries.map(async ([operation, call]) => { + try { + await call(); + return `${operation}: resolved instead of rejecting`; + } catch (error) { + if (!(error instanceof AppError)) return `${operation}: threw a non-AppError`; + if (error.code !== 'UNSUPPORTED_OPERATION') return `${operation}: ${error.code}`; + if (error.message !== `${operation} is not supported on web`) { + return `${operation}: ${error.message}`; + } + return null; + } + }), + ); + + assert.deepEqual(failures.filter(Boolean), []); +}); + +test('a rejection carries no partial result the caller could mistake for success', async () => { + const interactor = createUnsupportedInteractor('Linux desktop'); + + await assert.rejects( + async () => await interactor.snapshot(), + (error: unknown) => + error instanceof AppError && error.message.endsWith('supported on Linux desktop'), + ); +}); diff --git a/src/platforms/unsupported-interactor.ts b/src/core/interactors/unsupported-interactor.ts similarity index 100% rename from src/platforms/unsupported-interactor.ts rename to src/core/interactors/unsupported-interactor.ts diff --git a/src/core/interactors/web.ts b/src/core/interactors/web.ts index 914da409e0..13843a9396 100644 --- a/src/core/interactors/web.ts +++ b/src/core/interactors/web.ts @@ -3,7 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { stripAtPrefix } from '../interaction-positionals.ts'; import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; import { resolveWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; -import { createUnsupportedInteractor } from '../../platforms/unsupported-interactor.ts'; +import { createUnsupportedInteractor } from './unsupported-interactor.ts'; export function createWebInteractor(provider: WebProvider = resolveWebProvider()): Interactor { const clickRef = provider.clickRef; diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index 2ed07aad6c..714e31c556 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -12,8 +12,11 @@ import { isTrustedInstallSourceUrl, materializeInstallablePath, validateDownloadSourceUrl, -} from '../install-source.ts'; -import { isBlockedIpAddress, isBlockedSourceHostname } from '../install-source-network.ts'; +} from '@agent-device/provision-kit/install-source'; +import { + isBlockedIpAddress, + isBlockedSourceHostname, +} from '@agent-device/provision-kit/install-source-network'; import * as androidManifest from '../android/manifest.ts'; import { prepareAndroidInstallArtifact } from '../android/install-artifact.ts'; import { prepareIosInstallArtifact } from '../apple/core/install-artifact.ts'; @@ -23,7 +26,7 @@ import { } from '../apple/core/tool-provider.ts'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; -import * as networkTransport from '../install-source-network-transport.ts'; +import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => { await assert.rejects( diff --git a/src/platforms/android/__tests__/settings-parsing.test.ts b/src/platforms/android/__tests__/settings-parsing.test.ts new file mode 100644 index 0000000000..15b1949df9 --- /dev/null +++ b/src/platforms/android/__tests__/settings-parsing.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + parseAppearanceAction, + parseSettingState, + summarizeCommandAttemptFailures, +} from '../settings-parsing.ts'; +import { assertThrowsAppError } from '../../../__tests__/test-utils/app-error.ts'; + +test('parseAppearanceAction accepts the three actions in any casing or padding', () => { + assert.equal(parseAppearanceAction('light'), 'light'); + assert.equal(parseAppearanceAction('DARK'), 'dark'); + assert.equal(parseAppearanceAction(' Toggle '), 'toggle'); +}); + +test('parseAppearanceAction rejects anything else and names the accepted set', () => { + assertThrowsAppError(() => parseAppearanceAction('bright'), { code: 'INVALID_ARGS' }); + assertThrowsAppError(() => parseAppearanceAction(''), { code: 'INVALID_ARGS' }); +}); + +test('parseSettingState reads every on and off spelling', () => { + for (const on of ['on', 'ON', 'true', '1']) assert.equal(parseSettingState(on), true); + for (const off of ['off', 'OFF', 'false', '0']) assert.equal(parseSettingState(off), false); +}); + +test('parseSettingState rejects a state that is neither', () => { + assertThrowsAppError(() => parseSettingState('maybe'), { code: 'INVALID_ARGS' }); + assertThrowsAppError(() => parseSettingState('2'), { code: 'INVALID_ARGS' }); +}); + +test('summarizeCommandAttemptFailures joins args and truncates stderr to its budget', () => { + const [summary] = summarizeCommandAttemptFailures([ + { args: ['shell', 'cmd', 'fingerprint'], stdout: 'out', stderr: 'x'.repeat(500), exitCode: 2 }, + ]); + + assert.equal(summary?.args, 'shell cmd fingerprint'); + assert.equal(summary?.exitCode, 2); + assert.equal(summary?.stderr.length, 400); +}); + +test('summarizeCommandAttemptFailures keeps every attempt in order', () => { + const summaries = summarizeCommandAttemptFailures([ + { args: ['first'], stdout: '', stderr: 'a', exitCode: 1 }, + { args: ['second'], stdout: '', stderr: 'b', exitCode: 9 }, + ]); + + assert.deepEqual( + summaries.map(({ args, exitCode }) => `${args}:${exitCode}`), + ['first:1', 'second:9'], + ); +}); diff --git a/src/platforms/android/app-deployment-resolution.ts b/src/platforms/android/app-deployment-resolution.ts index 92962ceb9b..06170ec7ae 100644 --- a/src/platforms/android/app-deployment-resolution.ts +++ b/src/platforms/android/app-deployment-resolution.ts @@ -1,6 +1,9 @@ import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; +import { + createAppResolutionCache, + type AppResolutionCacheScope, +} from '@agent-device/provision-kit/app-resolution-cache'; import { runAndroidAdb } from './adb.ts'; import { classifyAndroidAppTarget } from './open-target.ts'; diff --git a/src/platforms/android/doctor.ts b/src/platforms/android/doctor.ts index 7cd561bbb4..684c0cba1e 100644 --- a/src/platforms/android/doctor.ts +++ b/src/platforms/android/doctor.ts @@ -4,7 +4,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { normalizeError } from '@agent-device/kernel/errors'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; -import { commandFirstLine } from '../toolchain-probe.ts'; +import { commandFirstLine } from '@agent-device/provision-kit/toolchain-probe'; import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; import { isAndroidTestImeActive, diff --git a/src/platforms/android/emulator-lifecycle.ts b/src/platforms/android/emulator-lifecycle.ts index cb31c71a9d..278c72d685 100644 --- a/src/platforms/android/emulator-lifecycle.ts +++ b/src/platforms/android/emulator-lifecycle.ts @@ -5,8 +5,7 @@ import { type ExecResult, runCmdDetached, whichCmd } from '@agent-device/host-ki import { Deadline, retryWithPolicy, sleep } from '@agent-device/host-kit/retry'; import { runAndroidHostAdb } from './adb-executor.ts'; - -import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; +import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics'; import { ensureAndroidSdkPathConfigured } from './sdk.ts'; const ANDROID_BOOT_POLL_MS = 1_000; diff --git a/src/platforms/android/install-artifact.ts b/src/platforms/android/install-artifact.ts index 1b5fbb1141..87f0acd3e6 100644 --- a/src/platforms/android/install-artifact.ts +++ b/src/platforms/android/install-artifact.ts @@ -1,6 +1,9 @@ import path from 'node:path'; import type { LocalInstallSource } from '@agent-device/kernel/contracts'; -import { isTrustedInstallSourceUrl, materializeInstallablePath } from '../install-source.ts'; +import { + isTrustedInstallSourceUrl, + materializeInstallablePath, +} from '@agent-device/provision-kit/install-source'; import * as manifest from './manifest.ts'; export type PreparedAndroidInstallArtifact = { diff --git a/src/platforms/android/perf-frame-parser.ts b/src/platforms/android/perf-frame-parser.ts index 3017c37e94..56ebaa948c 100644 --- a/src/platforms/android/perf-frame-parser.ts +++ b/src/platforms/android/perf-frame-parser.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import { roundPercent } from '../perf-utils.ts'; +import { roundPercent } from '@agent-device/kernel/numeric'; import { parseNumericToken } from './perf-parsing.ts'; import { buildWorstFrameDropWindows, @@ -12,9 +12,7 @@ import { export type { AndroidFrameDropWindow } from './perf-frame-analysis.ts'; -export const ANDROID_FRAME_SAMPLE_METHOD = 'adb-shell-dumpsys-gfxinfo-framestats'; -export const ANDROID_FRAME_SAMPLE_DESCRIPTION = - 'Rendered-frame health from the current adb shell dumpsys gfxinfo framestats window. Dropped frames use Android gfxinfo janky-frame/frame-deadline data when available; this is not video recording FPS.'; +const ANDROID_FRAME_SAMPLE_METHOD = 'adb-shell-dumpsys-gfxinfo-framestats'; type AndroidFrameSummary = { droppedFramePercent: number; diff --git a/src/platforms/android/perf-frame.ts b/src/platforms/android/perf-frame.ts index 77c25156f6..d048b09d8c 100644 --- a/src/platforms/android/perf-frame.ts +++ b/src/platforms/android/perf-frame.ts @@ -3,11 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; import { parseAndroidFramePerfSample, type AndroidFramePerfSample } from './perf-frame-parser.ts'; -export { - ANDROID_FRAME_SAMPLE_DESCRIPTION, - ANDROID_FRAME_SAMPLE_METHOD, - type AndroidFramePerfSample, -} from './perf-frame-parser.ts'; +export type { AndroidFramePerfSample } from './perf-frame-parser.ts'; const ANDROID_FRAME_PERF_TIMEOUT_MS = 15_000; const ANDROID_FRAME_RESET_TIMEOUT_MS = 3_000; diff --git a/src/platforms/android/perf-native-report.ts b/src/platforms/android/perf-native-report.ts index 580f0a5431..8eb909149d 100644 --- a/src/platforms/android/perf-native-report.ts +++ b/src/platforms/android/perf-native-report.ts @@ -1,5 +1,5 @@ import { splitNonEmptyTrimmedLines } from '@agent-device/kernel/record'; -import { roundPercent } from '../perf-utils.ts'; +import { roundPercent } from '@agent-device/kernel/numeric'; const SIMPLEPERF_REPORT_ARTIFACT_ENTRY_LIMIT = 50; diff --git a/src/platforms/android/settings-parsing.ts b/src/platforms/android/settings-parsing.ts new file mode 100644 index 0000000000..ed5778687a --- /dev/null +++ b/src/platforms/android/settings-parsing.ts @@ -0,0 +1,35 @@ +import { AppError } from '@agent-device/kernel/errors'; + +export type AppearanceAction = 'light' | 'dark' | 'toggle'; + +export function parseAppearanceAction(state: string): AppearanceAction { + const normalized = state.trim().toLowerCase(); + if (normalized === 'light') return 'light'; + if (normalized === 'dark') return 'dark'; + if (normalized === 'toggle') return 'toggle'; + throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`); +} + +export function parseSettingState(state: string): boolean { + const normalized = state.toLowerCase(); + if (normalized === 'on' || normalized === 'true' || normalized === '1') return true; + if (normalized === 'off' || normalized === 'false' || normalized === '0') return false; + throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`); +} + +export type CommandAttemptFailure = { + args: string[]; + stdout: string; + stderr: string; + exitCode: number; +}; + +export function summarizeCommandAttemptFailures( + failures: CommandAttemptFailure[], +): Array<{ args: string; exitCode: number; stderr: string }> { + return failures.map((failure) => ({ + args: failure.args.join(' '), + exitCode: failure.exitCode, + stderr: failure.stderr.slice(0, 400), + })); +} diff --git a/src/platforms/android/settings.ts b/src/platforms/android/settings.ts index e62e63e9b1..29bee0dbaa 100644 --- a/src/platforms/android/settings.ts +++ b/src/platforms/android/settings.ts @@ -1,13 +1,13 @@ import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { requireLocationCoordinates } from '@agent-device/kernel/location-coordinates'; +import type { SettingOptions } from '@agent-device/contracts/settings'; import { + parseAppearanceAction, + parseSettingState, summarizeCommandAttemptFailures, type CommandAttemptFailure, -} from '../command-attempts.ts'; -import type { SettingOptions } from '@agent-device/contracts/settings'; -import { parseAppearanceAction } from '../appearance.ts'; -import { parseSettingState } from '../setting-state.ts'; +} from './settings-parsing.ts'; import { runAndroidAdb } from './adb.ts'; import { androidAdbResultError } from './adb-executor.ts'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; diff --git a/src/platforms/appearance.ts b/src/platforms/appearance.ts deleted file mode 100644 index 4363bc04df..0000000000 --- a/src/platforms/appearance.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; - -export type AppearanceAction = 'light' | 'dark' | 'toggle'; - -export function parseAppearanceAction(state: string): AppearanceAction { - const normalized = state.trim().toLowerCase(); - if (normalized === 'light') return 'light'; - if (normalized === 'dark') return 'dark'; - if (normalized === 'toggle') return 'toggle'; - throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`); -} diff --git a/src/platforms/apple/core/__tests__/settings-parsing.test.ts b/src/platforms/apple/core/__tests__/settings-parsing.test.ts new file mode 100644 index 0000000000..df9f147d9f --- /dev/null +++ b/src/platforms/apple/core/__tests__/settings-parsing.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + parseAppearanceAction, + parseSettingState, + summarizeCommandAttemptFailures, +} from '../settings-parsing.ts'; +import { assertThrowsAppError } from '../../../../__tests__/test-utils/app-error.ts'; + +test('parseAppearanceAction accepts the three actions in any casing or padding', () => { + assert.equal(parseAppearanceAction('light'), 'light'); + assert.equal(parseAppearanceAction('DARK'), 'dark'); + assert.equal(parseAppearanceAction(' Toggle '), 'toggle'); +}); + +test('parseAppearanceAction rejects anything else and names the accepted set', () => { + assertThrowsAppError(() => parseAppearanceAction('bright'), { code: 'INVALID_ARGS' }); + assertThrowsAppError(() => parseAppearanceAction(''), { code: 'INVALID_ARGS' }); +}); + +test('parseSettingState reads every on and off spelling', () => { + for (const on of ['on', 'ON', 'true', '1']) assert.equal(parseSettingState(on), true); + for (const off of ['off', 'OFF', 'false', '0']) assert.equal(parseSettingState(off), false); +}); + +test('parseSettingState rejects a state that is neither', () => { + assertThrowsAppError(() => parseSettingState('maybe'), { code: 'INVALID_ARGS' }); + assertThrowsAppError(() => parseSettingState('2'), { code: 'INVALID_ARGS' }); +}); + +test('summarizeCommandAttemptFailures joins args and truncates stderr to its budget', () => { + const [summary] = summarizeCommandAttemptFailures([ + { args: ['shell', 'cmd', 'fingerprint'], stdout: 'out', stderr: 'x'.repeat(500), exitCode: 2 }, + ]); + + assert.equal(summary?.args, 'shell cmd fingerprint'); + assert.equal(summary?.exitCode, 2); + assert.equal(summary?.stderr.length, 400); +}); + +test('summarizeCommandAttemptFailures keeps every attempt in order', () => { + const summaries = summarizeCommandAttemptFailures([ + { args: ['first'], stdout: '', stderr: 'a', exitCode: 1 }, + { args: ['second'], stdout: '', stderr: 'b', exitCode: 9 }, + ]); + + assert.deepEqual( + summaries.map(({ args, exitCode }) => `${args}:${exitCode}`), + ['first:1', 'second:9'], + ); +}); diff --git a/src/platforms/apple/core/app-resolution.ts b/src/platforms/apple/core/app-resolution.ts index 88f283eacf..a8df4a54dc 100644 --- a/src/platforms/apple/core/app-resolution.ts +++ b/src/platforms/apple/core/app-resolution.ts @@ -7,7 +7,7 @@ import type { AppsFilter } from '@agent-device/contracts/device'; import { createAppResolutionCache, type AppResolutionCacheScope, -} from '../../app-resolution-cache.ts'; +} from '@agent-device/provision-kit/app-resolution-cache'; import type { IosAppInfo } from './app-info.ts'; import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; import { listMacApps, resolveMacOsApp } from '../os/macos/apps.ts'; diff --git a/src/platforms/apple/core/app-settings.ts b/src/platforms/apple/core/app-settings.ts index 246c8ced70..e5a8db0fb6 100644 --- a/src/platforms/apple/core/app-settings.ts +++ b/src/platforms/apple/core/app-settings.ts @@ -11,12 +11,12 @@ import path from 'node:path'; import { resolveIosSimulatorDeviceSetPath } from '@agent-device/kernel/device-isolation'; import { requireExecSuccess } from '@agent-device/host-kit/command'; import { requireLocationCoordinates } from '@agent-device/kernel/location-coordinates'; -import { parseAppearanceAction } from '../../appearance.ts'; import { + parseAppearanceAction, + parseSettingState, summarizeCommandAttemptFailures, type CommandAttemptFailure, -} from '../../command-attempts.ts'; -import { parseSettingState } from '../../setting-state.ts'; +} from './settings-parsing.ts'; import { setMacOsAppearance } from '../os/macos/apps.ts'; import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/macos/helper.ts'; import { closeIosApp } from './app-launch.ts'; diff --git a/src/platforms/apple/core/install-artifact.ts b/src/platforms/apple/core/install-artifact.ts index 8380db8b46..5de599b0d5 100644 --- a/src/platforms/apple/core/install-artifact.ts +++ b/src/platforms/apple/core/install-artifact.ts @@ -11,8 +11,11 @@ import { installArtifactArchiveDepth, noteInstallArtifactArchiveDepth, withInstallArtifactArchiveScope, -} from '../../install-artifact-archive-context.ts'; -import { isTrustedInstallSourceUrl, materializeInstallablePath } from '../../install-source.ts'; +} from '@agent-device/provision-kit/install-artifact-archive-context'; +import { + isTrustedInstallSourceUrl, + materializeInstallablePath, +} from '@agent-device/provision-kit/install-source'; type InstallIosArtifactOptions = { appIdentifierHint?: string; diff --git a/src/platforms/apple/core/perf-frame.ts b/src/platforms/apple/core/perf-frame.ts index 5a5ae08856..b67686828b 100644 --- a/src/platforms/apple/core/perf-frame.ts +++ b/src/platforms/apple/core/perf-frame.ts @@ -1,5 +1,5 @@ import { uniqueStrings } from '@agent-device/kernel/collections'; -import { roundOneDecimal, roundPercent } from '../../perf-utils.ts'; +import { roundOneDecimal, roundPercent } from '@agent-device/kernel/numeric'; import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; import { findAllXmlNodes, diff --git a/src/platforms/apple/core/perf-time-profile.ts b/src/platforms/apple/core/perf-time-profile.ts index bd6c84d11d..ee34f145e3 100644 --- a/src/platforms/apple/core/perf-time-profile.ts +++ b/src/platforms/apple/core/perf-time-profile.ts @@ -1,5 +1,5 @@ import { parseXmlDocumentSync, type XmlNode } from '@agent-device/xml'; -import { roundPercent } from '../../perf-utils.ts'; +import { roundPercent } from '@agent-device/kernel/numeric'; import { findAllXmlNodes, indexXmlNodesById, resolveXmlReference } from './perf-xml.ts'; const APPLE_TIME_PROFILE_FUNCTION_LIMIT = 10; diff --git a/src/platforms/apple/core/runner-host.ts b/src/platforms/apple/core/runner-host.ts index 4cdc30b32f..2dd5a139cb 100644 --- a/src/platforms/apple/core/runner-host.ts +++ b/src/platforms/apple/core/runner-host.ts @@ -33,7 +33,7 @@ import { emitRequestProgress, } from '@agent-device/host-kit/request'; -import { bootFailureHint, classifyBootFailure } from '../../boot-diagnostics.ts'; +import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; import { visitXmlPlistEntries } from './plist-xml.ts'; import { getRunnerLeaseOwnerStateDir } from './runner-owner-state.ts'; diff --git a/src/platforms/apple/core/settings-parsing.ts b/src/platforms/apple/core/settings-parsing.ts new file mode 100644 index 0000000000..2f6eccff0b --- /dev/null +++ b/src/platforms/apple/core/settings-parsing.ts @@ -0,0 +1,39 @@ +import { AppError } from '@agent-device/kernel/errors'; + +export type AppearanceAction = 'light' | 'dark' | 'toggle'; + +// fallow-ignore-next-line code-duplication +export function parseAppearanceAction(state: string): AppearanceAction { + const normalized = state.trim().toLowerCase(); + if (normalized === 'light') return 'light'; + if (normalized === 'dark') return 'dark'; + if (normalized === 'toggle') return 'toggle'; + throw new AppError('INVALID_ARGS', `Invalid appearance state: ${state}. Use light|dark|toggle.`); +} + +// fallow-ignore-next-line code-duplication +export function parseSettingState(state: string): boolean { + const normalized = state.toLowerCase(); + if (normalized === 'on' || normalized === 'true' || normalized === '1') return true; + if (normalized === 'off' || normalized === 'false' || normalized === '0') return false; + throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`); +} + +// fallow-ignore-next-line code-duplication +export type CommandAttemptFailure = { + args: string[]; + stdout: string; + stderr: string; + exitCode: number; +}; + +// fallow-ignore-next-line code-duplication +export function summarizeCommandAttemptFailures( + failures: CommandAttemptFailure[], +): Array<{ args: string; exitCode: number; stderr: string }> { + return failures.map((failure) => ({ + args: failure.args.join(' '), + exitCode: failure.exitCode, + stderr: failure.stderr.slice(0, 400), + })); +} diff --git a/src/platforms/apple/core/simulator.ts b/src/platforms/apple/core/simulator.ts index 7a98e508ef..28a5c8e9ab 100644 --- a/src/platforms/apple/core/simulator.ts +++ b/src/platforms/apple/core/simulator.ts @@ -4,7 +4,7 @@ import { execFailureDetails, requireExecSuccess } from '@agent-device/host-kit/c import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; -import { bootFailureHint, classifyBootFailure } from '../../boot-diagnostics.ts'; +import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics'; import { IOS_BOOT_TIMEOUT_MS, diff --git a/src/platforms/apple/doctor.ts b/src/platforms/apple/doctor.ts index 1ffca2f1a4..0379c196e6 100644 --- a/src/platforms/apple/doctor.ts +++ b/src/platforms/apple/doctor.ts @@ -1,7 +1,7 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; -import { commandFirstLine } from '../toolchain-probe.ts'; +import { commandFirstLine } from '@agent-device/provision-kit/toolchain-probe'; import { hasCachedAppleRunnerArtifact, prewarmAppleRunnerCache } from './core/runner-client.ts'; type AppleToolchainProbe = { diff --git a/src/platforms/apple/os/macos/apps.ts b/src/platforms/apple/os/macos/apps.ts index 78809758d0..d40b38de07 100644 --- a/src/platforms/apple/os/macos/apps.ts +++ b/src/platforms/apple/os/macos/apps.ts @@ -3,11 +3,11 @@ import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { setTimeout as sleep } from 'node:timers/promises'; -import { parseAppearanceAction } from '../../../appearance.ts'; +import { parseAppearanceAction } from '../../core/settings-parsing.ts'; import { createAppResolutionCache, type AppResolutionCacheScope, -} from '../../../app-resolution-cache.ts'; +} from '@agent-device/provision-kit/app-resolution-cache'; import { quitMacOsApp } from './helper.ts'; import { resolveAppleToolProvider, type AppleMacOsHostProvider } from '../../core/tool-provider.ts'; import type { IosAppInfo } from '../../core/app-info.ts'; diff --git a/src/platforms/command-attempts.ts b/src/platforms/command-attempts.ts deleted file mode 100644 index 1d728889e4..0000000000 --- a/src/platforms/command-attempts.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type CommandAttemptFailure = { - args: string[]; - stdout: string; - stderr: string; - exitCode: number; -}; - -export function summarizeCommandAttemptFailures( - failures: CommandAttemptFailure[], -): Array<{ args: string; exitCode: number; stderr: string }> { - return failures.map((failure) => ({ - args: failure.args.join(' '), - exitCode: failure.exitCode, - stderr: failure.stderr.slice(0, 400), - })); -} diff --git a/src/platforms/harmonyos/doctor.ts b/src/platforms/harmonyos/doctor.ts index 336f5f3607..e8a34d494d 100644 --- a/src/platforms/harmonyos/doctor.ts +++ b/src/platforms/harmonyos/doctor.ts @@ -1,5 +1,5 @@ import type { DoctorCheck } from '@agent-device/contracts/observability'; -import { commandFirstLine } from '../toolchain-probe.ts'; +import { commandFirstLine } from '@agent-device/provision-kit/toolchain-probe'; export async function harmonyToolchainCheck(): Promise { const versionLine = await commandFirstLine('hdc', ['-v']); diff --git a/src/platforms/setting-state.ts b/src/platforms/setting-state.ts deleted file mode 100644 index a94e0caa55..0000000000 --- a/src/platforms/setting-state.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; - -export function parseSettingState(state: string): boolean { - const normalized = state.toLowerCase(); - if (normalized === 'on' || normalized === 'true' || normalized === '1') return true; - if (normalized === 'off' || normalized === 'false' || normalized === '0') return false; - throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`); -} diff --git a/src/platforms/vega/__tests__/unsupported-interactor.test.ts b/src/platforms/vega/__tests__/unsupported-interactor.test.ts new file mode 100644 index 0000000000..c085d8fade --- /dev/null +++ b/src/platforms/vega/__tests__/unsupported-interactor.test.ts @@ -0,0 +1,63 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { createUnsupportedInteractor } from '../unsupported-interactor.ts'; +import { assertRejectsAppError } from '../../../__tests__/test-utils/app-error.ts'; + +const OPERATIONS = [ + 'open', + 'openDevice', + 'close', + 'tap', + 'doubleTap', + 'longPress', + 'focus', + 'type', + 'fill', + 'scroll', + 'screenshot', + 'snapshot', + 'back', + 'home', + 'setOrientation', + 'appSwitcher', + 'tvRemote', + 'readClipboard', + 'writeClipboard', + 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', +] as const; + +test('every operation rejects as unsupported and names the platform', async () => { + const interactor = createUnsupportedInteractor('Vega OS'); + + for (const operation of OPERATIONS) { + const call = interactor[operation] as () => Promise; + await assertRejectsAppError(async () => await call(), { + code: 'UNSUPPORTED_OPERATION', + message: new RegExp(`^${operation} is not supported on Vega OS$`), + }); + } +}); + +test('the label is per-instance, so two platforms reject with their own wording', async () => { + const web = createUnsupportedInteractor('web').home as () => Promise; + const vega = createUnsupportedInteractor('Vega OS').home as () => Promise; + + await assertRejectsAppError(async () => await web(), { + code: 'UNSUPPORTED_OPERATION', + message: /not supported on web$/, + }); + await assertRejectsAppError(async () => await vega(), { + code: 'UNSUPPORTED_OPERATION', + message: /not supported on Vega OS$/, + }); +}); + +test('the factory covers the whole interactor surface', () => { + const interactor = createUnsupportedInteractor('Vega OS'); + + assert.deepEqual(Object.keys(interactor).sort(), [...OPERATIONS].sort()); +}); diff --git a/src/platforms/vega/doctor.ts b/src/platforms/vega/doctor.ts index d108cb5a11..b567587576 100644 --- a/src/platforms/vega/doctor.ts +++ b/src/platforms/vega/doctor.ts @@ -2,7 +2,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { DoctorCheck } from '@agent-device/contracts/observability'; import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics'; -import { firstOutputLine, TOOLCHAIN_TIMEOUT_MS } from '../toolchain-probe.ts'; +import { firstOutputLine, TOOLCHAIN_TIMEOUT_MS } from '@agent-device/provision-kit/toolchain-probe'; import { resolveVegaToolProvider } from './tool-provider.ts'; type VegaInventoryProbe = Readonly<{ diff --git a/src/platforms/vega/interactor.ts b/src/platforms/vega/interactor.ts index 4d73c9fc4c..9377861007 100644 --- a/src/platforms/vega/interactor.ts +++ b/src/platforms/vega/interactor.ts @@ -2,7 +2,7 @@ import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { createUnsupportedInteractor } from '../unsupported-interactor.ts'; +import { createUnsupportedInteractor } from './unsupported-interactor.ts'; import { closeVegaApp, openVegaApp, openVegaDevice } from './app-lifecycle.ts'; import { pressVegaTvRemote } from './input-actions.ts'; diff --git a/src/platforms/vega/unsupported-interactor.ts b/src/platforms/vega/unsupported-interactor.ts new file mode 100644 index 0000000000..f96e878cc3 --- /dev/null +++ b/src/platforms/vega/unsupported-interactor.ts @@ -0,0 +1,39 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; + +// fallow-ignore-next-line code-duplication +export function createUnsupportedInteractor(platformLabel: string): Interactor { + const unsupported = async (operation: string): Promise => { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `${operation} is not supported on ${platformLabel}`, + ); + }; + + return { + open: () => unsupported('open'), + openDevice: () => unsupported('openDevice'), + close: () => unsupported('close'), + tap: () => unsupported('tap'), + doubleTap: () => unsupported('doubleTap'), + longPress: () => unsupported('longPress'), + focus: () => unsupported('focus'), + type: () => unsupported('type'), + fill: () => unsupported('fill'), + scroll: () => unsupported('scroll'), + screenshot: () => unsupported('screenshot'), + snapshot: () => unsupported('snapshot'), + back: () => unsupported('back'), + home: () => unsupported('home'), + setOrientation: () => unsupported('setOrientation'), + appSwitcher: () => unsupported('appSwitcher'), + tvRemote: () => unsupported('tvRemote'), + readClipboard: () => unsupported('readClipboard'), + writeClipboard: () => unsupported('writeClipboard'), + setSetting: () => unsupported('setSetting'), + readAlert: () => unsupported('readAlert'), + awaitAlert: () => unsupported('awaitAlert'), + acceptAlert: () => unsupported('acceptAlert'), + dismissAlert: () => unsupported('dismissAlert'), + }; +} diff --git a/src/sdk/install-source.ts b/src/sdk/install-source.ts index 923eefad6c..ee5e00f476 100644 --- a/src/sdk/install-source.ts +++ b/src/sdk/install-source.ts @@ -2,6 +2,6 @@ export { ARCHIVE_EXTENSIONS, isTrustedInstallSourceUrl, validateDownloadSourceUrl, -} from '../platforms/install-source.ts'; +} from '@agent-device/provision-kit/install-source'; export type { LocalInstallSource as MaterializeInstallSource } from '@agent-device/kernel/contracts';