diff --git a/.fallowrc.json b/.fallowrc.json index c61ec0d63..3c95238b5 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -32,28 +32,30 @@ "apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan", "scripts/write-xcuitest-cache-metadata.mjs" ], - "ignoreDependencies": ["@theme"], + "ignoreDependencies": [ + "@theme" + ], "ignoreExports": [ { "file": "src/__tests__/test-utils/index.ts", - "exports": ["*"] + "exports": [ + "*" + ] }, { "comment": "Authoritative supported-command list; sole consumer is the conformance oracle (scripts/maestro-conformance, an ignored tooling tree).", "file": "src/compat/maestro/program-ir-command-parser.ts", - "exports": ["SUPPORTED_MAESTRO_COMMAND_NAMES"] + "exports": [ + "SUPPORTED_MAESTRO_COMMAND_NAMES" + ] }, { "file": "src/__tests__/test-utils/device-fixtures.ts", - "exports": ["LINUX_DEVICE", "ANDROID_TV_DEVICE", "TVOS_SIMULATOR"] - }, - { - "file": "src/__tests__/test-utils/mocked-binaries.ts", - "exports": ["withMockedXcrun"] - }, - { - "file": "src/daemon/app-log.ts", - "exports": ["readRecentAndroidLogcatForPackage"] + "exports": [ + "LINUX_DEVICE", + "ANDROID_TV_DEVICE", + "TVOS_SIMULATOR" + ] }, { "file": "src/platforms/android/app-lifecycle.ts", @@ -66,19 +68,28 @@ }, { "file": "src/platforms/android/index.ts", - "exports": ["installAndroidInstallablePath"] + "exports": [ + "installAndroidInstallablePath" + ] }, { "file": "src/platforms/apple/core/apps.ts", - "exports": ["listSimulatorApps", "uninstallIosApp"] + "exports": [ + "listSimulatorApps", + "uninstallIosApp" + ] }, { "file": "src/platforms/apple/core/runner/runner-contract.ts", - "exports": ["resolveRunnerEarlyExitHint"] + "exports": [ + "resolveRunnerEarlyExitHint" + ] }, { "file": "src/platforms/apple/core/runner/runner-session.ts", - "exports": ["stopAllIosRunnerSessions"] + "exports": [ + "stopAllIosRunnerSessions" + ] }, { "file": "src/platforms/apple/core/runner/runner-xctestrun.ts", @@ -92,48 +103,102 @@ }, { "file": "src/cloud-webdriver.ts", - "exports": ["CLOUD_WEBDRIVER_PROVIDERS"] + "exports": [ + "CLOUD_WEBDRIVER_PROVIDERS" + ] }, { "file": "src/daemon/handlers/lease.ts", - "exports": ["handleLeaseCommands"] + "exports": [ + "handleLeaseCommands" + ] }, { "file": "src/daemon/handlers/session.ts", - "exports": ["handleSessionCommands"] + "exports": [ + "handleSessionCommands" + ] }, { "file": "src/daemon/handlers/snapshot.ts", - "exports": ["handleSnapshotCommands"] + "exports": [ + "handleSnapshotCommands" + ] }, { "file": "src/daemon/handlers/react-native.ts", - "exports": ["handleReactNativeCommands"] + "exports": [ + "handleReactNativeCommands" + ] }, { "file": "src/daemon/handlers/record-trace.ts", - "exports": ["handleRecordTraceCommands"] + "exports": [ + "handleRecordTraceCommands" + ] }, { "file": "src/daemon/handlers/find.ts", - "exports": ["handleFindCommands"] + "exports": [ + "handleFindCommands" + ] }, { "file": "src/daemon/handlers/interaction.ts", - "exports": ["handleInteractionCommands"] + "exports": [ + "handleInteractionCommands" + ] + }, + { + "comment": "Published package surface (see package.json#exports); consumed by SDK users, not internally.", + "file": "src/sdk/*.ts", + "exports": [ + "*" + ] + }, + { + "comment": "Tool config default exports, loaded by the tool rather than imported.", + "file": "{tsdown.config.ts,website/rspress.config.ts,test/skillgym/skillgym.config.ts,test/skillgym/suites/*.ts}", + "exports": [ + "default" + ] + }, + { + "comment": "Type-level `AssertTrue<...>` registry-totality guards. Exported only so `noUnusedLocals` keeps them alive; a consumer would defeat the point.", + "file": "{src/core/command-descriptor/registry.ts,src/core/interactors/register-builtins.ts,src/core/platform-descriptor/registry.ts,src/daemon/request-platform-providers.ts}", + "exports": [ + "CommandOwnerFileClaimsAreComplete", + "BuiltinPluginsCoverAllPlatforms", + "PlatformDescriptorsAreTotal", + "GatedKeysAreResolverKeys" + ] } ], - "usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"], + "usedClassMembers": [ + "name", + "listActiveLeases", + "delete", + "values", + "elapsedMs", + "isExpired" + ], "rules": { - "unused-types": "off", + "unused-types": "warn", "duplicate-exports": "off" }, "production": { "dupes": true }, - "publicPackages": ["agent-device"], + "publicPackages": [ + "agent-device" + ], "audit": { "deadCodeBaseline": "fallow-baselines/dead-code.json", "healthBaseline": "fallow-baselines/health.json" + }, + "includeEntryExports": true, + "ignoreExportsUsedInFile": { + "type": true, + "interface": true } } diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 50edbe388..219d5e1c7 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -144,7 +144,7 @@ export function topFolder(file: string): string { return match ? match[1]! : '(root)'; } -export function targetDagZone(file: string): string { +function targetDagZone(file: string): string { if (file.startsWith('src/daemon/client/')) return 'daemon-client'; if (file.startsWith('src/daemon/')) return 'daemon-server'; return topFolder(file); diff --git a/scripts/package-apple-runner-source.mjs b/scripts/package-apple-runner-source.mjs index 42bb499c8..64a09e4e8 100644 --- a/scripts/package-apple-runner-source.mjs +++ b/scripts/package-apple-runner-source.mjs @@ -17,7 +17,7 @@ const LEGACY_OUTPUT_DIRS = [ const SKIPPED_DIR_NAMES = new Set(['.build', '.swiftpm', 'xcuserdata']); const SKIPPED_ROOT_FILES = new Set(['README.md', 'RUNNER_PROTOCOL.md']); -export function packageAppleRunnerSource(options = {}) { +function packageAppleRunnerSource(options = {}) { const root = path.resolve(options.root ?? process.cwd()); const sourceRoot = path.join(root, SOURCE_DIR); const outputRoot = path.join(root, OUTPUT_DIR); @@ -40,7 +40,7 @@ export function packageAppleRunnerSource(options = {}) { return summary; } -export function stripRunnerUnitTestBlocks(source, filePath = '') { +function stripRunnerUnitTestBlocks(source, filePath = '') { const lines = source.match(/[^\n]*\n|[^\n]+/g) ?? []; const state = { output: [], diff --git a/src/__tests__/test-utils/android-snapshot-helper.ts b/src/__tests__/test-utils/android-snapshot-helper.ts index 8bb036dfb..b4e9c0ab1 100644 --- a/src/__tests__/test-utils/android-snapshot-helper.ts +++ b/src/__tests__/test-utils/android-snapshot-helper.ts @@ -50,7 +50,7 @@ export function createAndroidSnapshotHelperExecutor(options: { }; } -export function isAndroidSnapshotHelperCapture(args: readonly string[]): boolean { +function isAndroidSnapshotHelperCapture(args: readonly string[]): boolean { return args[0] === 'shell' && args[1] === 'am' && args[2] === 'instrument'; } diff --git a/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts b/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts index 0adb03ae0..5e4e55d60 100644 --- a/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts +++ b/src/__tests__/test-utils/android-ui-hierarchy-fixtures.ts @@ -23,7 +23,22 @@ import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; * tracks depth and parent via its own recursion, never `node.depth`), so they * are not populated here. */ -export function rawFixtureToAndroidTree(rawNodes: RawSnapshotNode[]): AndroidUiHierarchy { +function rawFixtureToTreeNode(raw: RawSnapshotNode): AndroidUiHierarchy { + return { + type: raw.type ?? null, + label: raw.label ?? null, + value: raw.value ?? null, + identifier: raw.identifier ?? null, + packageName: raw.bundleId ?? null, + rect: raw.rect, + hittable: raw.hittable, + visibleToUser: raw.visibleToUser, + depth: 0, + children: [], + }; +} + +function rawFixtureToAndroidTree(rawNodes: RawSnapshotNode[]): AndroidUiHierarchy { const root: AndroidUiHierarchy = { type: null, label: null, @@ -33,21 +48,9 @@ export function rawFixtureToAndroidTree(rawNodes: RawSnapshotNode[]): AndroidUiH depth: -1, children: [], }; - const byIndex = new Map(); - for (const raw of rawNodes) { - byIndex.set(raw.index, { - type: raw.type ?? null, - label: raw.label ?? null, - value: raw.value ?? null, - identifier: raw.identifier ?? null, - packageName: raw.bundleId ?? null, - rect: raw.rect, - hittable: raw.hittable, - visibleToUser: raw.visibleToUser, - depth: 0, - children: [], - }); - } + const byIndex = new Map( + rawNodes.map((raw) => [raw.index, rawFixtureToTreeNode(raw)]), + ); for (const raw of rawNodes) { const node = byIndex.get(raw.index); if (!node) continue; diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index b9125744b..3edf8a31d 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -20,16 +20,10 @@ export { export { makeSnapshotState } from './snapshot-builders.ts'; -export { - rawFixtureToAndroidTree, - walkNonRawAndroidFixture, -} from './android-ui-hierarchy-fixtures.ts'; - export { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, androidSnapshotHelperOutput, createAndroidSnapshotHelperExecutor, - isAndroidSnapshotHelperCapture, } from './android-snapshot-helper.ts'; export { makeSessionStore } from './store-factory.ts'; diff --git a/src/__tests__/test-utils/mocked-binaries.ts b/src/__tests__/test-utils/mocked-binaries.ts index cd87c5d0a..ee4779bd8 100644 --- a/src/__tests__/test-utils/mocked-binaries.ts +++ b/src/__tests__/test-utils/mocked-binaries.ts @@ -37,39 +37,6 @@ export async function withMockedAdb( } } -/** - * Creates a temporary stub `xcrun` binary that logs all args to a file, - * prepends it to PATH, and cleans up after the callback finishes. - */ -export async function withMockedXcrun( - tempPrefix: string, - run: (argsLogPath: string) => Promise, -): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tempPrefix)); - const xcrunPath = path.join(tmpDir, 'xcrun'); - const argsLogPath = path.join(tmpDir, 'args.log'); - await fs.writeFile( - xcrunPath, - '#!/bin/sh\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(xcrunPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - - try { - await run(argsLogPath); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} - /** * Like {@link withMockedAdb}, but with a caller-provided stub `adb` script so * tests can shape per-subcommand responses instead of only recording args. diff --git a/src/backend.ts b/src/backend.ts index f204da3d2..54d9784fd 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -20,11 +20,7 @@ import type { TvRemoteButton } from './contracts/tv-remote.ts'; import type { GesturePlan } from './contracts/gesture-plan-types.ts'; import type { RecordingExportQuality } from './core/recording-export-quality.ts'; import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts'; -import type { - SnapshotCaptureAnalysis, - SnapshotCaptureAnnotations, - SnapshotCaptureFreshness, -} from './snapshot-capture-annotations.ts'; +import type { SnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts'; import type { ScreenshotResultData } from './utils/screenshot-result.ts'; // The backend's public leaf platform (approach b): backends distinguish iOS from @@ -67,10 +63,6 @@ export type BackendSnapshotOptions = SnapshotOptions & { outPath?: string; }; -export type BackendSnapshotAnalysis = SnapshotCaptureAnalysis; - -export type BackendSnapshotFreshness = SnapshotCaptureFreshness; - export type BackendReadTextResult = { text: string; }; diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 71a468d23..a126185ee 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -54,7 +54,7 @@ export type { BatchRunResult } from '../core/batch.ts'; import type { TargetShutdownResult } from '../target-shutdown-contract.ts'; export type { TargetShutdownResult } from '../target-shutdown-contract.ts'; import type { PerfAction, PerfArea, PerfKind, PerfSubject } from '../contracts/perf.ts'; -import type { AlertAction, AlertInfo } from '../alert-contract.ts'; +import type { AlertAction } from '../alert-contract.ts'; import type { DebugSymbolsOptions, DebugSymbolsResult } from '../contracts/debug-symbols.ts'; import type { JsonObject } from '../contracts/json.ts'; import type { @@ -64,22 +64,15 @@ import type { import type { CommandResult } from '../core/command-descriptor/command-result.ts'; import type { AgentArtifactsResult, CloudProviderSessionResult } from '../cloud-artifacts.ts'; -export type { FindLocator } from '../selectors/find.ts'; -export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts'; +export type { MetroBridgeScope } from './client-companion-tunnel-contract.ts'; export type { AppsFilter } from '../contracts/app-inventory.ts'; -export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from '../alert-contract.ts'; +export type { AlertAction } from '../alert-contract.ts'; export type { DebugSymbolsOptions, DebugSymbolsResult } from '../contracts/debug-symbols.ts'; export type { AppleOS } from '../kernel/device.ts'; -export type { BootCommandResult, ShutdownCommandResult } from '../contracts/device.ts'; -export type { ViewportCommandResult } from '../contracts/viewport.ts'; export type { - AppSwitcherCommandResult, - BackCommandResult, - HomeCommandResult, OrientationCommandResult, /** @deprecated Renamed to `OrientationCommandResult`. Retained until the next major. */ RotateCommandResult, - TvRemoteCommandResult, } from '../contracts/navigation.ts'; export type { ClipboardCommandResult } from '../contracts/clipboard.ts'; export type { AppStateCommandResult } from '../contracts/app-state.ts'; @@ -90,18 +83,9 @@ export type { PushCommandResult } from '../contracts/push.ts'; export type { TriggerAppEventCommandResult } from '../contracts/app-events.ts'; export type { DoctorCommandResult } from '../contracts/doctor.ts'; export type { DiffSnapshotCommandResult } from '../contracts/diff.ts'; -export type { - RecordingCommandResult, - RecordingStartCommandResult, - RecordingStopCommandResult, - TraceCommandResult, -} from '../contracts/recording.ts'; -export type { - ReplayCommandResult, - ReplaySuiteResult, - ReplaySuiteTestResult, -} from '../contracts/replay.ts'; -export type { JsonObject, JsonPrimitive, JsonValue } from '../contracts/json.ts'; +export type { RecordingCommandResult, TraceCommandResult } from '../contracts/recording.ts'; +export type { ReplayCommandResult, ReplaySuiteResult } from '../contracts/replay.ts'; +export type { JsonObject } from '../contracts/json.ts'; export type AgentDeviceDaemonTransport = ( req: Omit, @@ -574,20 +558,6 @@ export type AlertCommandOptions = DeviceCommandBaseOptions & { timeoutMs?: number; }; -export type AlertCommandResult = DaemonResponseData & { - kind?: 'alertStatus' | 'alertHandled' | 'alertWait'; - action?: AlertCommandOptions['action']; - alert?: AlertInfo | null; - handled?: boolean; - button?: string; - waitedMs?: number; - timedOut?: boolean; - platform?: AlertInfo['platform']; - accepted?: boolean; - dismissed?: boolean; - items?: string[]; -}; - export type AppStateCommandOptions = DeviceCommandBaseOptions; export type BackCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'back'>; diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 5ed25283a..6eec5081f 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -14,7 +14,6 @@ export const SPECIAL_CLI_COMMANDS = { help: 'help', } as const; -export type PublicCommandName = DescriptorCommandNameForCatalogGroup<'public'>; export type InternalCommandName = DescriptorCommandNameForCatalogGroup<'internal'>; export type LocalCliCommandName = DescriptorCommandNameForCatalogGroup<'local-cli'>; export type SpecialCliCommandName = diff --git a/src/commands/interaction/runtime/__tests__/test-utils/index.ts b/src/commands/interaction/runtime/__tests__/test-utils/index.ts index be8776f83..fcd5d247b 100644 --- a/src/commands/interaction/runtime/__tests__/test-utils/index.ts +++ b/src/commands/interaction/runtime/__tests__/test-utils/index.ts @@ -282,36 +282,6 @@ export function nonTouchableGroupSnapshot(): SnapshotState { ]); } -export function snapshotWithOffscreenContent(): SnapshotState { - return makeSnapshotState([ - { - index: 0, - depth: 0, - type: 'Application', - label: 'Example', - rect: { x: 0, y: 0, width: 100, height: 100 }, - }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'Button', - label: 'Visible', - rect: { x: 10, y: 10, width: 20, height: 20 }, - hittable: true, - }, - { - index: 2, - depth: 1, - parentIndex: 0, - type: 'Button', - label: 'Offscreen', - rect: { x: 10, y: 900, width: 20, height: 20 }, - hittable: true, - }, - ]); -} - export function createInteractionDevice( snapshot: SnapshotState, overrides: Partial< diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts b/src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts index 91357d7be..52ca71d87 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts +++ b/src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts @@ -2,8 +2,6 @@ import type { DaemonRequest } from '../../../daemon/types.ts'; import type { SnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts'; import type { CreateDaemonMaestroRuntimeOperationsOptions } from '../daemon-runtime-port.ts'; -export const IOS_FRAME = { x: 0, y: 0, width: 402, height: 874 } as const; - export function makeSnapshot( nodes: Array & { ref?: string }>, ): SnapshotState { diff --git a/src/compat/maestro/__tests__/runtime-target-fixtures.ts b/src/compat/maestro/__tests__/runtime-target-fixtures.ts index 8c075217e..f10d9f4d8 100644 --- a/src/compat/maestro/__tests__/runtime-target-fixtures.ts +++ b/src/compat/maestro/__tests__/runtime-target-fixtures.ts @@ -1,7 +1,5 @@ import type { SnapshotNode, SnapshotState } from '../../../kernel/snapshot.ts'; -export const IOS_TAB_FRAME = { referenceWidth: 402, referenceHeight: 874 } as const; - export type SnapshotNodeFixture = Omit & { ref?: string }; export function makeSnapshot(nodes: SnapshotNodeFixture[]): SnapshotState { diff --git a/src/contracts/diff.ts b/src/contracts/diff.ts index 76b97dca4..7c1e6740f 100644 --- a/src/contracts/diff.ts +++ b/src/contracts/diff.ts @@ -13,5 +13,3 @@ export type DiffSnapshotCommandResult = { lines: SnapshotDiffLine[]; warnings?: string[]; }; - -export type DiffCommandResult = DiffSnapshotCommandResult | DiffScreenshotCommandResult; diff --git a/src/contracts/perf.ts b/src/contracts/perf.ts index 453e0d43c..a2decb345 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -21,7 +21,6 @@ export type PerfArea = (typeof PERF_AREA_VALUES)[number]; export type PerfAction = (typeof PERF_ACTION_VALUES)[number]; export type PerfSubject = (typeof PERF_SUBJECT_VALUES)[number]; export type PerfKind = (typeof PERF_KIND_VALUES)[number]; -export type PerfMemoryKind = (typeof PERF_MEMORY_KIND_VALUES)[number]; export const PERF_AREA_ERROR_MESSAGE = 'perf area must be metrics, frames, memory, cpu, or trace'; export const PERF_ACTION_ERROR_MESSAGE = diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index baf789656..7b5a6491a 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1264,6 +1264,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ * makes this resolve to `false` and fail the `AssertTrue` constraint. */ type AssertTrue = T; +/** Exported only so `noUnusedLocals` keeps the guard alive. */ export type CommandOwnerFileClaimsAreComplete = AssertTrue< 'ownerFiles' extends keyof (typeof RAW_COMMAND_DESCRIPTORS)[number] ? true : false >; diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index a57360678..b50a2c14d 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -124,6 +124,7 @@ type CoveredPlatform = (typeof BUILTIN_PLATFORM_PLUGINS)[number]['platforms'][nu * sketch, but type-level so it cannot be satisfied vacuously by a runtime map.) */ type AssertTrue = T; +/** Exported only so `noUnusedLocals` keeps the guard alive. */ export type BuiltinPluginsCoverAllPlatforms = AssertTrue< [Platform] extends [CoveredPlatform] ? true : false >; diff --git a/src/core/platform-descriptor/registry.ts b/src/core/platform-descriptor/registry.ts index 21913e1d7..4c3f94c58 100644 --- a/src/core/platform-descriptor/registry.ts +++ b/src/core/platform-descriptor/registry.ts @@ -34,6 +34,7 @@ type CoveredPlatform = (typeof platformDescriptors)[number]['platform']; * value-level coverage (same order) is asserted by the parity test. */ type AssertTrue = T; +/** Exported only so `noUnusedLocals` keeps the guard alive. */ export type PlatformDescriptorsAreTotal = AssertTrue< [Platform] extends [CoveredPlatform] ? true : false >; diff --git a/src/daemon/__tests__/artifact-materialization.test.ts b/src/daemon/__tests__/artifact-materialization.test.ts deleted file mode 100644 index ce370cc1d..000000000 --- a/src/daemon/__tests__/artifact-materialization.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { AppError } from '../../kernel/errors.ts'; -import { runCmdSync } from '../../utils/exec.ts'; -import { cleanupMaterializedArtifact, materializeArtifact } from '../artifact-materialization.ts'; - -async function startHttpServer(handler: http.RequestListener): Promise<{ - server: http.Server; - baseUrl: string; -}> { - const server = http.createServer(handler); - await new Promise((resolve, reject) => { - server.listen(0, '127.0.0.1', () => resolve()); - server.on('error', reject); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Failed to determine test server address'); - } - - return { - server, - baseUrl: `http://127.0.0.1:${address.port}`, - }; -} - -async function withHttpServer( - handler: http.RequestListener, - run: (baseUrl: string) => Promise, -): Promise { - const { server, baseUrl } = await startHttpServer(handler); - - try { - await run(baseUrl); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } -} - -function writeIosInfoPlist(appDir: string, params: { bundleId: string; appName: string }): void { - const plist = ` - - - - CFBundleIdentifier - ${params.bundleId} - CFBundleDisplayName - ${params.appName} - - -`; - fs.writeFileSync(path.join(appDir, 'Info.plist'), plist, 'utf8'); -} - -test('materializeArtifact downloads Android artifacts with caller headers into request-scoped temp storage', async () => { - let seenAuthorization = ''; - let result: Awaited> | undefined; - - await withHttpServer( - (req, res) => { - seenAuthorization = String(req.headers.authorization ?? ''); - res.statusCode = 200; - res.setHeader('content-disposition', 'attachment; filename="Demo.apk"'); - res.end('apk-binary'); - }, - async (baseUrl) => { - result = await materializeArtifact({ - platform: 'android', - url: `${baseUrl}/download`, - headers: { authorization: 'Bearer ephemeral-token' }, - requestId: 'req/123', - }); - }, - ); - - assert.equal(seenAuthorization, 'Bearer ephemeral-token'); - assert.ok(result); - assert.equal(result.installablePath, result.archivePath); - assert.match(result.archivePath, /agent-device-artifact-req-123-/); - assert.equal(path.basename(result.archivePath), 'Demo.apk'); - assert.equal(result.detected.appName, 'Demo'); - assert.equal(fs.readFileSync(result.archivePath, 'utf8'), 'apk-binary'); - - cleanupMaterializedArtifact(result); - assert.equal(fs.existsSync(path.dirname(result.archivePath)), false); -}); - -test('materializeArtifact extracts iOS app bundle tar archives and returns the installable .app path', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-materialize-ios-')); - const appDir = path.join(tempRoot, 'Demo.app'); - const archivePath = path.join(tempRoot, 'Demo.tar'); - let result: Awaited> | undefined; - - try { - fs.mkdirSync(appDir, { recursive: true }); - writeIosInfoPlist(appDir, { bundleId: 'com.example.demo', appName: 'Demo App' }); - fs.writeFileSync(path.join(appDir, 'payload.txt'), 'demo', 'utf8'); - runCmdSync('tar', ['cf', archivePath, '-C', tempRoot, 'Demo.app']); - - await withHttpServer( - (_req, res) => { - res.statusCode = 200; - res.setHeader('content-disposition', 'attachment; filename="Demo.tar"'); - res.end(fs.readFileSync(archivePath)); - }, - async (baseUrl) => { - result = await materializeArtifact({ - platform: 'ios', - url: `${baseUrl}/artifact`, - requestId: 'ios-materialize', - }); - }, - ); - - assert.ok(result); - assert.equal(path.basename(result.archivePath), 'Demo.tar'); - assert.equal(path.basename(result.installablePath), 'Demo.app'); - assert.equal(fs.readFileSync(path.join(result.installablePath, 'payload.txt'), 'utf8'), 'demo'); - assert.equal(result.detected.appName, 'Demo App'); - assert.equal(result.detected.bundleId, 'com.example.demo'); - - cleanupMaterializedArtifact(result); - assert.equal(fs.existsSync(path.dirname(result.archivePath)), false); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -}); - -test('materializeArtifact rejects iOS tar archives containing symlinks', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-materialize-bad-ios-')); - const appDir = path.join(tempRoot, 'Bad.app'); - const archivePath = path.join(tempRoot, 'Bad.tar'); - - try { - fs.mkdirSync(appDir, { recursive: true }); - fs.writeFileSync(path.join(tempRoot, 'payload.txt'), 'payload', 'utf8'); - fs.symlinkSync('../payload.txt', path.join(appDir, 'linked.txt')); - runCmdSync('tar', ['cf', archivePath, '-C', tempRoot, 'Bad.app']); - - await withHttpServer( - (_req, res) => { - res.statusCode = 200; - res.setHeader('content-disposition', 'attachment; filename="Bad.tar"'); - res.end(fs.readFileSync(archivePath)); - }, - async (baseUrl) => { - await assert.rejects( - () => - materializeArtifact({ - platform: 'ios', - url: `${baseUrl}/artifact`, - requestId: 'bad-ios-materialize', - }), - /cannot contain symlinks or hard links/i, - ); - }, - ); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -}); - -test('materializeArtifact rejects cross-origin redirects when custom headers are provided', async () => { - const target = await startHttpServer((_targetReq, targetRes) => { - targetRes.statusCode = 200; - targetRes.end('apk-binary'); - }); - const redirect = await startHttpServer((_req, redirectRes) => { - redirectRes.statusCode = 302; - redirectRes.setHeader('location', `${target.baseUrl}/download`); - redirectRes.end(); - }); - - try { - await assert.rejects( - () => - materializeArtifact({ - platform: 'android', - url: `${redirect.baseUrl}/redirect`, - headers: { authorization: 'Bearer ephemeral-token' }, - requestId: 'cross-origin-redirect', - }), - /redirect changed origin while custom headers were provided/i, - ); - } finally { - await new Promise((resolve) => redirect.server.close(() => resolve())); - await new Promise((resolve) => target.server.close(() => resolve())); - } -}); - -test('materializeArtifact infers APK type for opaque Android downloads', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-materialize-android-apk-')); - const manifestPath = path.join(tempRoot, 'AndroidManifest.xml'); - const zipPath = path.join(tempRoot, 'opaque.zip'); - let result: Awaited> | undefined; - - try { - fs.writeFileSync(manifestPath, '', 'utf8'); - runCmdSync('zip', ['-q', zipPath, 'AndroidManifest.xml'], { cwd: tempRoot }); - - await withHttpServer( - (_req, res) => { - res.statusCode = 200; - res.setHeader('content-type', 'application/octet-stream'); - res.end(fs.readFileSync(zipPath)); - }, - async (baseUrl) => { - result = await materializeArtifact({ - platform: 'android', - url: `${baseUrl}/artifact`, - requestId: 'android-opaque-apk', - }); - }, - ); - - assert.ok(result); - assert.equal(path.extname(result.archivePath), '.apk'); - assert.equal(result.installablePath, result.archivePath); - } finally { - if (result) cleanupMaterializedArtifact(result); - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -}); - -test('materializeArtifact infers AAB type for opaque Android downloads', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-materialize-android-aab-')); - const bundleConfigPath = path.join(tempRoot, 'BundleConfig.pb'); - const baseDir = path.join(tempRoot, 'base', 'manifest'); - const zipPath = path.join(tempRoot, 'opaque-aab.zip'); - let result: Awaited> | undefined; - - try { - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(bundleConfigPath, 'bundle-config', 'utf8'); - fs.writeFileSync(path.join(baseDir, 'AndroidManifest.xml'), '', 'utf8'); - runCmdSync('zip', ['-qr', zipPath, 'BundleConfig.pb', 'base'], { cwd: tempRoot }); - - await withHttpServer( - (_req, res) => { - res.statusCode = 200; - res.end(fs.readFileSync(zipPath)); - }, - async (baseUrl) => { - result = await materializeArtifact({ - platform: 'android', - url: `${baseUrl}/artifact`, - requestId: 'android-opaque-aab', - }); - }, - ); - - assert.ok(result); - assert.equal(path.extname(result.archivePath), '.aab'); - assert.equal(result.installablePath, result.archivePath); - } finally { - if (result) cleanupMaterializedArtifact(result); - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -}); - -test('materializeArtifact truncates large HTTP error bodies', async () => { - const hugeBody = 'x'.repeat(10_000); - - await withHttpServer( - (_req, res) => { - res.statusCode = 502; - res.end(hugeBody); - }, - async (baseUrl) => { - await assert.rejects( - () => - materializeArtifact({ - platform: 'android', - url: `${baseUrl}/artifact`, - requestId: 'huge-error-body', - }), - (error) => { - assert.ok(error instanceof AppError); - assert.equal(error.message, 'Failed to download artifact'); - assert.equal(error.details?.statusCode, 502); - const body = error.details?.body; - assert.equal(typeof body, 'string'); - assert.ok(typeof body === 'string'); - assert.ok(body.endsWith('...')); - assert.ok(body.length <= 4096 + '...'.length); - return true; - }, - ); - }, - ); -}); diff --git a/src/daemon/android-snapshot-freshness.ts b/src/daemon/android-snapshot-freshness.ts index 19685b7e3..2f5ecf7c7 100644 --- a/src/daemon/android-snapshot-freshness.ts +++ b/src/daemon/android-snapshot-freshness.ts @@ -16,13 +16,6 @@ const ANDROID_COMPARISON_BASELINE_MAX_AGE_MS = 5_000; export const ANDROID_FRESHNESS_RETRY_DEADLINE_MS = 1_500; export const ANDROID_FRESHNESS_RETRY_DELAYS_MS = [250, 400, 600] as const; -export type AndroidFreshnessCaptureMeta = { - action: string; - retryCount: number; - staleAfterRetries: boolean; - reason?: 'empty-interactive' | 'sharp-drop' | 'stuck-route'; -}; - export function markAndroidSnapshotFreshness( session: SessionState, action: string, diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 6a39fb53f..620e2a2c5 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -38,8 +38,7 @@ registerBuiltinPlatformPlugins(); export type { AppLogResult } from './app-log-process.ts'; export type { AppLogState } from './app-log-process.ts'; export type { AppLogFailure } from './app-log-process.ts'; -export { readRecentAndroidLogcatForPackage } from './app-log-android.ts'; -export { runAppLogDoctor, type AppLogDoctorResult } from './app-log-doctor.ts'; +export { runAppLogDoctor } from './app-log-doctor.ts'; export type SessionNetworkCapture = { backend: LogBackend; diff --git a/src/daemon/artifact-archive.ts b/src/daemon/artifact-archive.ts index 31d8ccd30..ab2108530 100644 --- a/src/daemon/artifact-archive.ts +++ b/src/daemon/artifact-archive.ts @@ -21,7 +21,7 @@ export async function extractTarInstallableArtifact(params: { return installablePath; } -export async function resolveTarArchiveRootName(params: { +async function resolveTarArchiveRootName(params: { archivePath: string; platform: 'ios' | 'android'; expectedRootName?: string; @@ -74,15 +74,6 @@ export async function resolveTarArchiveRootName(params: { return rootName; } -export async function readZipEntries(archivePath: string): Promise { - const result = await runCmd('unzip', ['-Z1', archivePath], { allowFailure: true }); - if (result.exitCode !== 0) return null; - return result.stdout - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); -} - function resolveArchiveRootName(entries: string[], platform: 'ios' | 'android'): string { const roots = new Set(); for (const entry of entries) { diff --git a/src/daemon/artifact-download.ts b/src/daemon/artifact-download.ts index 1a8898f2d..62a4323c7 100644 --- a/src/daemon/artifact-download.ts +++ b/src/daemon/artifact-download.ts @@ -1,6 +1,4 @@ import fs from 'node:fs'; -import http, { type IncomingMessage } from 'node:http'; -import https from 'node:https'; import os from 'node:os'; import path from 'node:path'; import { once } from 'node:events'; @@ -9,10 +7,8 @@ import { pipeline } from 'node:stream/promises'; import { AppError } from '../kernel/errors.ts'; const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB -const MAX_ERROR_BODY_CHARS = 4096; const TEMP_PREFIX = 'agent-device-artifact-'; const REQUEST_IDLE_TIMEOUT_MS = 60_000; -const MAX_REDIRECTS = 5; export function sanitizeArtifactFilename(raw: string): string { const trimmed = raw.trim(); @@ -119,230 +115,9 @@ async function removePartialFile(output: fs.WriteStream, destPath: string): Prom await fs.promises.rm(destPath, { force: true }).catch(() => {}); } -export async function downloadArtifactToTempDir(params: { - url: string; - headers?: Record; - requestId?: string; - tempDir: string; -}): Promise<{ archivePath: string }> { - const final = await requestArtifact(new URL(params.url), params.headers, params.requestId, 0); - const filename = resolveInitialArtifactFilename({ - contentDisposition: final.response.headers['content-disposition'], - url: final.url, - }); - const archivePath = path.join(params.tempDir, filename); - validateArtifactContentLength(final.response.headers['content-length']); - - try { - await streamReadableToFile(final.response, archivePath); - return { archivePath }; - } catch (error) { - final.response.destroy(); - throw error; - } -} - function sanitizeRequestId(raw: string | undefined): string { const trimmed = raw?.trim(); if (!trimmed) return 'request'; const normalized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); return normalized.length > 0 ? normalized.slice(0, 48) : 'request'; } - -async function requestArtifact( - url: URL, - headers: Record | undefined, - requestId: string | undefined, - redirectCount: number, -): Promise<{ response: IncomingMessage; url: URL }> { - if (redirectCount > MAX_REDIRECTS) { - throw new AppError('COMMAND_FAILED', 'Artifact download exceeded redirect limit', { - requestId, - url: url.toString(), - maxRedirects: MAX_REDIRECTS, - }); - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new AppError('INVALID_ARGS', `Unsupported artifact URL protocol: ${url.protocol}`); - } - - const transport = url.protocol === 'https:' ? https : http; - return await new Promise((resolve, reject) => { - let settled = false; - const settle = (error?: Error, response?: IncomingMessage, finalUrl?: URL) => { - if (settled) return; - settled = true; - clearTimeout(timeoutHandle); - if (error) { - reject(error); - return; - } - if (!response || !finalUrl) { - reject( - new AppError('COMMAND_FAILED', 'Artifact download failed without a response', { - requestId, - url: url.toString(), - }), - ); - return; - } - resolve({ response, url: finalUrl }); - }; - - const request = transport.request( - { - protocol: url.protocol, - host: url.hostname, - port: url.port, - method: 'GET', - path: url.pathname + url.search, - headers, - }, - async (response) => { - const statusCode = response.statusCode ?? 500; - const location = response.headers.location; - if (location && [301, 302, 303, 307, 308].includes(statusCode)) { - response.resume(); - try { - const redirectedUrl = new URL(location, url); - const redirectedHeaders = resolveRedirectHeaders( - url, - redirectedUrl, - headers, - requestId, - ); - const redirected = await requestArtifact( - redirectedUrl, - redirectedHeaders, - requestId, - redirectCount + 1, - ); - settle(undefined, redirected.response, redirected.url); - } catch (error) { - settle(error instanceof Error ? error : new Error(String(error))); - } - return; - } - - if (statusCode >= 400) { - let body = ''; - response.setEncoding('utf8'); - response.on('data', (chunk) => { - if (body.length >= MAX_ERROR_BODY_CHARS) return; - const remaining = MAX_ERROR_BODY_CHARS - body.length; - body += chunk.slice(0, remaining); - }); - response.on('end', () => { - settle( - new AppError('COMMAND_FAILED', 'Failed to download artifact', { - requestId, - url: url.toString(), - statusCode, - body: body.length === MAX_ERROR_BODY_CHARS ? `${body}...` : body, - }), - ); - }); - return; - } - - settle(undefined, response, url); - }, - ); - - const timeoutHandle = setTimeout(() => { - request.destroy( - new AppError('COMMAND_FAILED', 'Artifact request timed out waiting for response', { - requestId, - url: url.toString(), - timeoutMs: REQUEST_IDLE_TIMEOUT_MS, - }), - ); - }, REQUEST_IDLE_TIMEOUT_MS); - - request.on('error', (error) => { - if (error instanceof AppError) { - settle(error); - return; - } - settle( - new AppError( - 'COMMAND_FAILED', - 'Failed to download artifact', - { - requestId, - url: url.toString(), - timeoutMs: REQUEST_IDLE_TIMEOUT_MS, - }, - error instanceof Error ? error : undefined, - ), - ); - }); - request.end(); - }); -} - -function resolveRedirectHeaders( - currentUrl: URL, - redirectedUrl: URL, - headers: Record | undefined, - requestId: string | undefined, -): Record | undefined { - if (!headers || Object.keys(headers).length === 0) return headers; - if (currentUrl.origin === redirectedUrl.origin) return headers; - throw new AppError( - 'COMMAND_FAILED', - 'Artifact download redirect changed origin while custom headers were provided', - { - requestId, - from: currentUrl.toString(), - to: redirectedUrl.toString(), - }, - ); -} - -function resolveInitialArtifactFilename(params: { - contentDisposition: string | string[] | undefined; - url: URL; -}): string { - const contentDisposition = Array.isArray(params.contentDisposition) - ? params.contentDisposition[0] - : params.contentDisposition; - - const fromDisposition = parseContentDispositionFilename(contentDisposition); - if (fromDisposition) return sanitizeArtifactFilename(fromDisposition); - - const pathname = decodeURIComponentSafe(params.url.pathname); - const fromPath = path.basename(pathname); - if (fromPath && fromPath !== '/' && fromPath !== '.') { - return sanitizeArtifactFilename(fromPath); - } - - return 'artifact'; -} - -function parseContentDispositionFilename(header: string | undefined): string | undefined { - if (!header) return undefined; - - const encodedMatch = header.match(/filename\*\s*=\s*([^;]+)/i); - if (encodedMatch) { - const rawValue = encodedMatch[1] - ?.trim() - .replace(/^UTF-8''/i, '') - .replace(/^"(.*)"$/, '$1'); - const decoded = decodeURIComponentSafe(rawValue ?? ''); - if (decoded) return decoded; - } - - const plainMatch = header.match(/filename\s*=\s*([^;]+)/i); - if (!plainMatch) return undefined; - const rawValue = plainMatch[1]?.trim().replace(/^"(.*)"$/, '$1'); - return rawValue || undefined; -} - -function decodeURIComponentSafe(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} diff --git a/src/daemon/artifact-materialization.ts b/src/daemon/artifact-materialization.ts deleted file mode 100644 index 3518863ca..000000000 --- a/src/daemon/artifact-materialization.ts +++ /dev/null @@ -1,224 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { - extractTarInstallableArtifact, - readZipEntries, - resolveTarArchiveRootName, -} from './artifact-archive.ts'; -import { createArtifactTempDir, downloadArtifactToTempDir } from './artifact-download.ts'; -import { readInfoPlistString } from '../platforms/apple/core/plist.ts'; -import { AppError } from '../kernel/errors.ts'; - -export type MaterializeArtifactParams = { - platform: 'ios' | 'android'; - url: string; - headers?: Record; - requestId?: string; -}; - -export type MaterializedArtifact = { - archivePath: string; - installablePath: string; - detected: { - packageName?: string; - bundleId?: string; - appName?: string; - }; -}; - -export function cleanupMaterializedArtifact(result: MaterializedArtifact): void { - fs.rmSync(path.dirname(result.archivePath), { recursive: true, force: true }); -} - -export async function materializeArtifact( - params: MaterializeArtifactParams, -): Promise { - const tempDir = createArtifactTempDir(params.requestId); - - try { - const download = await downloadArtifactToTempDir({ - url: params.url, - headers: params.headers, - requestId: params.requestId, - tempDir, - }); - - return await resolveMaterializedArtifact({ - archivePath: download.archivePath, - tempDir, - platform: params.platform, - }); - } catch (error) { - fs.rmSync(tempDir, { recursive: true, force: true }); - throw error; - } -} - -async function resolveMaterializedArtifact(params: { - archivePath: string; - tempDir: string; - platform: 'ios' | 'android'; -}): Promise { - if (params.platform === 'android') { - return await resolveAndroidArtifact(params.archivePath); - } - return await resolveIosArtifact(params.archivePath, params.tempDir); -} - -async function resolveAndroidArtifact(archivePath: string): Promise { - const kind = await detectAndroidArtifactKind(archivePath); - const normalizedArchivePath = normalizeArchiveExtension( - archivePath, - kind === 'aab' ? '.aab' : '.apk', - ); - return { - archivePath: normalizedArchivePath, - installablePath: normalizedArchivePath, - detected: detectAndroidMetadata(normalizedArchivePath), - }; -} - -async function resolveIosArtifact( - archivePath: string, - tempDir: string, -): Promise { - const kind = await detectIosArtifactKind(archivePath); - if (kind === 'app-tar') { - const normalizedArchivePath = normalizeArchiveExtension(archivePath, '.tar', [ - '.tar', - '.tgz', - '.tar.gz', - ]); - const installablePath = await extractTarInstallableArtifact({ - archivePath: normalizedArchivePath, - tempDir, - platform: 'ios', - }); - return { - archivePath: normalizedArchivePath, - installablePath, - detected: await detectIosAppMetadata(installablePath), - }; - } - - const normalizedArchivePath = normalizeArchiveExtension(archivePath, '.ipa'); - return { - archivePath: normalizedArchivePath, - installablePath: normalizedArchivePath, - detected: await detectIosIpaMetadata(normalizedArchivePath), - }; -} - -async function detectAndroidArtifactKind(archivePath: string): Promise<'apk' | 'aab'> { - const extension = path.extname(archivePath).toLowerCase(); - if (extension === '.apk') return 'apk'; - if (extension === '.aab') return 'aab'; - - const entries = await readZipEntries(archivePath); - if (entries?.includes('BundleConfig.pb')) return 'aab'; - if (entries?.includes('AndroidManifest.xml')) return 'apk'; - - throw new AppError( - 'INVALID_ARGS', - `Android artifact URLs must resolve to .apk or .aab files, got "${path.basename(archivePath)}"`, - ); -} - -async function detectIosArtifactKind(archivePath: string): Promise<'app-tar' | 'ipa'> { - if (isTarLikePath(archivePath)) return 'app-tar'; - if (path.extname(archivePath).toLowerCase() === '.ipa') return 'ipa'; - - try { - await resolveTarArchiveRootName({ archivePath, platform: 'ios' }); - return 'app-tar'; - } catch { - const entries = await readZipEntries(archivePath); - if (entries?.some((entry) => /^Payload\/[^/]+\.app(\/|$)/.test(entry))) { - return 'ipa'; - } - } - - throw new AppError( - 'INVALID_ARGS', - `iOS artifact URLs must resolve to .ipa or app bundle tar archives, got "${path.basename(archivePath)}"`, - ); -} - -function detectAndroidMetadata(archivePath: string): MaterializedArtifact['detected'] { - const appName = readBaseNameIfMeaningful(archivePath); - return appName ? { appName } : {}; -} - -async function detectIosIpaMetadata( - archivePath: string, -): Promise { - const entries = await readZipEntries(archivePath); - const appEntry = entries?.find((entry) => /^Payload\/[^/]+\.app(\/|$)/.test(entry)); - if (!appEntry) { - const appName = readBaseNameIfMeaningful(archivePath); - return appName ? { appName } : {}; - } - - const appName = appEntry - .replace(/^Payload\//, '') - .split('/')[0] - ?.replace(/\.app$/i, ''); - return appName ? { appName } : {}; -} - -async function detectIosAppMetadata( - installablePath: string, -): Promise { - const bundleId = await readIosPlistValue(installablePath, 'CFBundleIdentifier'); - const displayName = await readIosPlistValue(installablePath, 'CFBundleDisplayName'); - const bundleName = await readIosPlistValue(installablePath, 'CFBundleName'); - const appName = displayName ?? bundleName ?? readBaseNameIfMeaningful(installablePath, '.app'); - - return { - ...(bundleId ? { bundleId } : {}), - ...(appName ? { appName } : {}), - }; -} - -async function readIosPlistValue(appBundlePath: string, key: string): Promise { - const infoPlistPath = path.join(appBundlePath, 'Info.plist'); - return await readInfoPlistString(infoPlistPath, key); -} - -function readBaseNameIfMeaningful(filePath: string, suffix?: string): string | undefined { - const basename = suffix - ? path.basename(filePath, suffix) - : path.basename(filePath, path.extname(filePath)); - return basename && basename !== 'artifact' ? basename : undefined; -} - -function normalizeArchiveExtension( - archivePath: string, - desiredExtension: string, - acceptedExtensions: string[] = [desiredExtension], -): string { - const loweredPath = archivePath.toLowerCase(); - if (acceptedExtensions.some((extension) => loweredPath.endsWith(extension))) { - return archivePath; - } - const normalizedPath = `${archivePath}${desiredExtension}`; - try { - fs.renameSync(archivePath, normalizedPath); - return normalizedPath; - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - `Failed to normalize artifact path to ${desiredExtension}`, - { - from: archivePath, - to: normalizedPath, - }, - error instanceof Error ? error : undefined, - ); - } -} - -function isTarLikePath(filePath: string): boolean { - const lowered = filePath.toLowerCase(); - return lowered.endsWith('.tar') || lowered.endsWith('.tgz') || lowered.endsWith('.tar.gz'); -} diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index a8da2059f..2dabac8fb 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -1,7 +1,5 @@ import { isMacOs } from '../../../kernel/device.ts'; import { expect, vi, beforeEach } from 'vitest'; -export { test } from 'vitest'; -export { expect, vi }; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -101,19 +99,9 @@ vi.mock('../session-deploy.ts', async (importOriginal) => { import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; -import { - retainMaterializedPaths, - cleanupRetainedMaterializedPathsForSession, -} from '../../materialized-path-registry.ts'; -import { LeaseRegistry } from '../../lease-registry.ts'; +import { cleanupRetainedMaterializedPathsForSession } from '../../materialized-path-registry.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; -import { AppError } from '../../../kernel/errors.ts'; -import { - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, -} from '../../app-log-ios.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; @@ -141,27 +129,11 @@ import { } from '../../../platforms/apple/core/apps.ts'; import { runAppLogDoctor, startAppLog, stopAppLog } from '../../app-log.ts'; import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts'; -import { clearRequestCanceled, markRequestCanceled } from '../../../request/cancel.ts'; - -export { - fs, - os, - path, - buildSnapshotSignatures, - retainMaterializedPaths, - LeaseRegistry, - SessionStore, - AppError, - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED, - IOS_DEVICE_CONSOLE_CAPTURE_UNSUPPORTED_NOTE, - clearRequestCanceled, - markRequestCanceled, -}; export const mockDispatch = vi.mocked(dispatchCommand); export const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); export const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -export const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); +const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); export const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); export const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); export const mockNotifyIosRunnerAppRelaunched = vi.mocked(notifyIosRunnerAppRelaunched); @@ -185,12 +157,12 @@ export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( ); export const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted); export const mockStartAppLog = vi.mocked(startAppLog); -export const mockStopAppLog = vi.mocked(stopAppLog); +const mockStopAppLog = vi.mocked(stopAppLog); export const mockRunAppLogDoctor = vi.mocked(runAppLogDoctor); -export const mockDefaultInstallOpsIos = vi.mocked(defaultInstallOps.ios); -export const mockDefaultInstallOpsAndroid = vi.mocked(defaultInstallOps.android); -export const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); -export const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android); +const mockDefaultInstallOpsIos = vi.mocked(defaultInstallOps.ios); +const mockDefaultInstallOpsAndroid = vi.mocked(defaultInstallOps.android); +const mockDefaultReinstallOpsIos = vi.mocked(defaultReinstallOps.ios); +const mockDefaultReinstallOpsAndroid = vi.mocked(defaultReinstallOps.android); beforeEach(() => { vi.useRealTimers(); diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index 7498608e5..985b79274 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -81,6 +81,7 @@ export type PlatformGatedProviderResolverKey = // Compile-time: every gated key is a real resolver key (so the facet can never name a // resolver the daemon does not compose). type AssertTrue = T; +/** Exported only so `noUnusedLocals` keeps the guard alive. */ export type GatedKeysAreResolverKeys = AssertTrue< [PlatformGatedProviderResolverKey] extends [keyof PlatformProviderResolvers] ? true : false >; diff --git a/src/mcp/__tests__/output-schema-validator.ts b/src/mcp/__tests__/output-schema-validator.ts index 8b3fcb588..a5fd7dc0c 100644 --- a/src/mcp/__tests__/output-schema-validator.ts +++ b/src/mcp/__tests__/output-schema-validator.ts @@ -16,11 +16,6 @@ export function validateAgainstSchema(value: unknown, schema: JsonSchema): strin return collectErrors(value, schema, '$'); } -/** Convenience predicate for the common "does it validate at all" assertion. */ -export function matchesSchema(value: unknown, schema: JsonSchema): boolean { - return collectErrors(value, schema, '$').length === 0; -} - function collectErrors(value: unknown, schema: JsonSchema, path: string): string[] { if (schema.oneOf) return oneOfErrors(value, schema.oneOf, path); diff --git a/src/metro/metro.ts b/src/metro/metro.ts index 3bee21cf1..8cd6f0d73 100644 --- a/src/metro/metro.ts +++ b/src/metro/metro.ts @@ -87,8 +87,6 @@ export type MetroTunnelResponseMessage = | MetroTunnelWebSocketFrameMessage | MetroTunnelWebSocketCloseMessage; -export type MetroTunnelMessage = MetroTunnelRequestMessage | MetroTunnelResponseMessage; - export type StopMetroTunnelOptions = { projectRoot: string; profileKey?: string; diff --git a/src/utils/string-enum.ts b/src/utils/string-enum.ts index 3936c44eb..9d1b7d3ec 100644 --- a/src/utils/string-enum.ts +++ b/src/utils/string-enum.ts @@ -4,7 +4,7 @@ import { AppError } from '../kernel/errors.ts'; * Membership guard for an `as const` string tuple (the single source of truth for * a string-literal union). Narrows `value` to the tuple's element union. */ -export function isStringMember( +function isStringMember( values: T, value: string, ): value is T[number] { diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 810328209..867061c4f 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -9,7 +9,10 @@ import type { AndroidAdbProvider, } from '../../../src/platforms/android/adb-executor.ts'; import type { DeviceInventoryRequest } from '../../../src/core/dispatch-resolve.ts'; -import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/index.ts'; +import { + ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + androidSnapshotHelperOutput, +} from '../../../src/__tests__/test-utils/index.ts'; import { runCmd } from '../../../src/utils/exec.ts'; import { validPng } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; @@ -460,21 +463,7 @@ function androidCaptureAdbResult( return undefined; } -export function androidSnapshotHelperOutput(xml: string): string { - return [ - 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', - 'INSTRUMENTATION_STATUS: helperApiVersion=1', - 'INSTRUMENTATION_STATUS: outputFormat=uiautomator-xml', - 'INSTRUMENTATION_STATUS: chunkIndex=0', - 'INSTRUMENTATION_STATUS: chunkCount=1', - `INSTRUMENTATION_STATUS: payloadBase64=${Buffer.from(xml, 'utf8').toString('base64')}`, - 'INSTRUMENTATION_STATUS_CODE: 1', - 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', - 'INSTRUMENTATION_RESULT: helperApiVersion=1', - 'INSTRUMENTATION_RESULT: ok=true', - 'INSTRUMENTATION_CODE: 0', - ].join('\n'); -} +export { androidSnapshotHelperOutput }; function androidMultiTouchHelperOutput(): string { return [