From 9629e18888874a5a2fd4db99590a622060038700 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:19:30 +0000 Subject: [PATCH 1/3] refactor(platforms): sink the shared src/platforms root files into their substrate homes (#2082 W3) The shared files left directly under src/platforms move onto a declared owner: provisioning mechanics (install-source family, toolchain probing, boot-failure classification, app-resolution caching) form @agent-device/provision-kit above capture-kit; host mechanics resolve to host-kit's seams; kernel takes the pure numeric helpers; contracts keeps vocabulary only. Settings parsing, command-attempt rendering, and the unsupported-interactor factory stay with their families rather than pooling in a substrate package: android and apple settings each own their parsing, and the unsupported-interactor factory lives in root core with a vega-local copy. A platforms-root-shape rule rejects any new shared file or directory appearing directly under src/platforms, and the provision-kit direction gates (no platform imports in, no capture-kit importer) are planted red. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --- .../0019-request-bound-platform-runtime.md | 8 ++- package.json | 3 +- packages/capture-kit/package.json | 4 ++ packages/kernel/package.json | 4 ++ .../kernel/src/numeric.ts | 0 packages/provision-kit/package.json | 44 ++++++++++++++++ .../src}/app-resolution-cache.test.ts | 2 +- .../src}/app-resolution-cache.ts | 0 .../src}/boot-diagnostics.test.ts | 2 +- .../provision-kit/src}/boot-diagnostics.ts | 1 + .../src}/install-artifact-archive-context.ts | 0 .../src}/install-source-archive.ts | 0 .../src}/install-source-download.test.ts | 6 +-- .../src}/install-source-download.ts | 0 .../install-source-network-transport.test.ts | 2 +- .../src}/install-source-network-transport.ts | 0 .../src}/install-source-network.ts | 0 .../provision-kit/src}/install-source.ts | 0 .../provision-kit/src/tmp-dir.fixtures.ts | 9 ++++ .../provision-kit/src}/toolchain-probe.ts | 0 packages/provision-kit/tsconfig.json | 12 +++++ pnpm-lock.yaml | 21 ++++++++ scripts/layering/check.ts | 4 ++ scripts/layering/model.ts | 1 + scripts/layering/package-boundaries.test.ts | 25 +++++++++ .../layering/platform-package-policy.test.ts | 51 ++++++++++++++++++- scripts/layering/platform-package-policy.ts | 22 +++++++- src/__tests__/eager-closure-budgets.ts | 10 ++++ .../interactors}/unsupported-interactor.ts | 0 src/core/interactors/web.ts | 2 +- .../__tests__/install-source.test.ts | 9 ++-- .../android/app-deployment-resolution.ts | 5 +- src/platforms/android/doctor.ts | 2 +- src/platforms/android/emulator-lifecycle.ts | 3 +- src/platforms/android/install-artifact.ts | 5 +- src/platforms/android/perf-frame-parser.ts | 6 +-- src/platforms/android/perf-frame.ts | 6 +-- src/platforms/android/perf-native-report.ts | 2 +- src/platforms/android/settings-parsing.ts | 35 +++++++++++++ src/platforms/android/settings.ts | 8 +-- src/platforms/appearance.ts | 11 ---- src/platforms/apple/core/app-resolution.ts | 2 +- src/platforms/apple/core/app-settings.ts | 6 +-- src/platforms/apple/core/install-artifact.ts | 7 ++- src/platforms/apple/core/perf-frame.ts | 2 +- src/platforms/apple/core/perf-time-profile.ts | 2 +- src/platforms/apple/core/runner-host.ts | 2 +- src/platforms/apple/core/settings-parsing.ts | 39 ++++++++++++++ src/platforms/apple/core/simulator.ts | 2 +- src/platforms/apple/doctor.ts | 2 +- src/platforms/apple/os/macos/apps.ts | 4 +- src/platforms/command-attempts.ts | 16 ------ src/platforms/harmonyos/doctor.ts | 2 +- src/platforms/setting-state.ts | 8 --- src/platforms/vega/doctor.ts | 2 +- src/platforms/vega/interactor.ts | 2 +- src/platforms/vega/unsupported-interactor.ts | 39 ++++++++++++++ src/sdk/install-source.ts | 2 +- 58 files changed, 378 insertions(+), 86 deletions(-) rename src/platforms/perf-utils.ts => packages/kernel/src/numeric.ts (100%) create mode 100644 packages/provision-kit/package.json rename {src/platforms/__tests__ => packages/provision-kit/src}/app-resolution-cache.test.ts (97%) rename {src/platforms => packages/provision-kit/src}/app-resolution-cache.ts (100%) rename {src/platforms/__tests__ => packages/provision-kit/src}/boot-diagnostics.test.ts (99%) rename {src/platforms => packages/provision-kit/src}/boot-diagnostics.ts (99%) rename {src/platforms => packages/provision-kit/src}/install-artifact-archive-context.ts (100%) rename {src/platforms => packages/provision-kit/src}/install-source-archive.ts (100%) rename {src/platforms/__tests__ => packages/provision-kit/src}/install-source-download.test.ts (96%) rename {src/platforms => packages/provision-kit/src}/install-source-download.ts (100%) rename {src/platforms/__tests__ => packages/provision-kit/src}/install-source-network-transport.test.ts (93%) rename {src/platforms => packages/provision-kit/src}/install-source-network-transport.ts (100%) rename {src/platforms => packages/provision-kit/src}/install-source-network.ts (100%) rename {src/platforms => packages/provision-kit/src}/install-source.ts (100%) create mode 100644 packages/provision-kit/src/tmp-dir.fixtures.ts rename {src/platforms => packages/provision-kit/src}/toolchain-probe.ts (100%) create mode 100644 packages/provision-kit/tsconfig.json rename src/{platforms => core/interactors}/unsupported-interactor.ts (100%) create mode 100644 src/platforms/android/settings-parsing.ts delete mode 100644 src/platforms/appearance.ts create mode 100644 src/platforms/apple/core/settings-parsing.ts delete mode 100644 src/platforms/command-attempts.ts delete mode 100644 src/platforms/setting-state.ts create mode 100644 src/platforms/vega/unsupported-interactor.ts 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/capture-kit/package.json b/packages/capture-kit/package.json index 063685d2c1..c5a1fb259e 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -50,6 +50,10 @@ "types": "./src/snapshot-desktop-projection.ts", "default": "./src/snapshot-desktop-projection.ts" }, + "./snapshot-desktop-projection": { + "types": "./src/snapshot-desktop-projection.ts", + "default": "./src/snapshot-desktop-projection.ts" + }, "./snapshot-occlusion": { "types": "./src/snapshot-occlusion.ts", "default": "./src/snapshot-occlusion.ts" 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..ce760f32a9 --- /dev/null +++ b/packages/provision-kit/src/tmp-dir.fixtures.ts @@ -0,0 +1,9 @@ +// Package tests cannot reach root test helpers (R11); fixture copy of tmp-dir.ts. +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..68a94472c7 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -470,6 +470,31 @@ 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', + ); + // Provisioning subpaths; any further subpath widens this key list and fails + // the assertion, same as the capture-kit pin above. + 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..47d56e1384 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -33,6 +33,25 @@ 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/'; + +/** + * The `src/platforms` root holds ONLY the six family directories plus the + * shared `__tests__` directory; shared code belongs in a substrate package. + */ +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 +283,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 +292,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/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/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/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/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'; From cb7c3915f7dc0aa8f7a0f86bac22d69f677b13ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:29:42 +0000 Subject: [PATCH 2/3] test: cover the family-owned parsers and the unsupported-interactor factories The settings parsers and the unsupported-interactor factory arrived without owning tests, so their branches rode on callers. Each now has one: the appearance/state parsers over every accepted spelling and their rejections, the attempt summarizer over its arg join and stderr budget, and both interactor factories over the whole operation surface and the per-instance label. Also drops the duplicate ./snapshot-desktop-projection export key that a rebase left in capture-kit's manifest, where JSON silently keeps the last, and the root-shape docblock the violation message already states. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --- packages/capture-kit/package.json | 4 -- scripts/layering/platform-package-policy.ts | 4 -- .../__tests__/unsupported-interactor.test.ts | 39 ++++++++++++ .../__tests__/settings-parsing.test.ts | 51 +++++++++++++++ .../core/__tests__/settings-parsing.test.ts | 51 +++++++++++++++ .../__tests__/unsupported-interactor.test.ts | 63 +++++++++++++++++++ 6 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 src/core/interactors/__tests__/unsupported-interactor.test.ts create mode 100644 src/platforms/android/__tests__/settings-parsing.test.ts create mode 100644 src/platforms/apple/core/__tests__/settings-parsing.test.ts create mode 100644 src/platforms/vega/__tests__/unsupported-interactor.test.ts diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index c5a1fb259e..063685d2c1 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -50,10 +50,6 @@ "types": "./src/snapshot-desktop-projection.ts", "default": "./src/snapshot-desktop-projection.ts" }, - "./snapshot-desktop-projection": { - "types": "./src/snapshot-desktop-projection.ts", - "default": "./src/snapshot-desktop-projection.ts" - }, "./snapshot-occlusion": { "types": "./src/snapshot-occlusion.ts", "default": "./src/snapshot-occlusion.ts" diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index 47d56e1384..9caf01cbc3 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -34,10 +34,6 @@ const RAW_PROCESS_SPECIFIERS = new Set(['child_process', 'node:child_process']); // temporary exception). export const APPLE_RUNNER_SUBTREE = 'packages/platform-apple/src/runner/'; -/** - * The `src/platforms` root holds ONLY the six family directories plus the - * shared `__tests__` directory; shared code belongs in a substrate package. - */ export function checkPlatformsRootShape(files: readonly string[]): LayeringViolation[] { const allowedChild = new RegExp( `^src/platforms/(?:${[...CANONICAL_PLATFORM_FAMILIES, '__tests__'].join('|')})/`, 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/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/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/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()); +}); From 60347c3014d84c876d4a74fed2324b7c67259bef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:17:28 +0000 Subject: [PATCH 3/3] docs: keep tool directives only in the touched files Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --- packages/provision-kit/src/tmp-dir.fixtures.ts | 1 - scripts/layering/package-boundaries.test.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/provision-kit/src/tmp-dir.fixtures.ts b/packages/provision-kit/src/tmp-dir.fixtures.ts index ce760f32a9..67812892d4 100644 --- a/packages/provision-kit/src/tmp-dir.fixtures.ts +++ b/packages/provision-kit/src/tmp-dir.fixtures.ts @@ -1,4 +1,3 @@ -// Package tests cannot reach root test helpers (R11); fixture copy of tmp-dir.ts. import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 68a94472c7..9ff43b863f 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -479,8 +479,6 @@ test('the real tree parses, declares, and passes R11', () => { true, 'provision-kit stays a private implementation package', ); - // Provisioning subpaths; any further subpath widens this key list and fails - // the assertion, same as the capture-kit pin above. assert.deepEqual([...provisionKitPackage.exportTargets.keys()].sort(), [ '@agent-device/provision-kit/app-resolution-cache', '@agent-device/provision-kit/boot-diagnostics',