diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index aa61662c..b99ab587 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -2,6 +2,7 @@ import { test, onTestFinished } from 'vitest'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import http from 'node:http'; +import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -107,6 +108,44 @@ test('materializeInstallablePath rejects archive extraction when disabled', asyn } }); +test.sequential('materializeInstallablePath extracts zip archives without ditto', async () => { + const unzipPath = findExecutableInPath('unzip'); + assert.ok(unzipPath, 'unzip must be available for portable zip extraction'); + + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-install-source-unzip-')); + const archivePath = path.join(tempRoot, 'bundle.zip'); + const binDir = path.join(tempRoot, 'bin'); + const payloadDir = path.join(tempRoot, 'payload'); + const apkPath = path.join(payloadDir, 'Sample.apk'); + const previousPath = process.env.PATH; + + try { + await fs.mkdir(binDir); + await fs.symlink(unzipPath, path.join(binDir, 'unzip')); + await fs.mkdir(payloadDir); + await fs.writeFile(apkPath, 'placeholder apk', 'utf8'); + execFileSync('zip', ['-qr', archivePath, 'payload'], { cwd: tempRoot }); + + process.env.PATH = binDir; + const result = await materializeInstallablePath({ + source: { kind: 'path', path: archivePath }, + isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'), + installableLabel: 'Android installable (.apk or .aab)', + allowArchiveExtraction: true, + }); + + try { + assert.equal(path.basename(result.installablePath), 'Sample.apk'); + assert.equal(await fs.readFile(result.installablePath, 'utf8'), 'placeholder apk'); + } finally { + await result.cleanup(); + } + } finally { + process.env.PATH = previousPath; + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + test('prepareIosInstallArtifact rejects untrusted URL sources', async () => { await assert.rejects( async () => @@ -160,3 +199,20 @@ test('prepareAndroidInstallArtifact resolves package identity for direct APK URL await result.cleanup(); } }); + +function findExecutableInPath(command: string): string | undefined { + const pathValue = process.env.PATH; + if (!pathValue) return undefined; + for (const directory of pathValue.split(path.delimiter)) { + if (!directory) continue; + const candidate = path.join(directory, command); + try { + if (!fsSync.statSync(candidate).isFile()) continue; + fsSync.accessSync(candidate, fsSync.constants.X_OK); + return candidate; + } catch { + // Keep scanning PATH. + } + } + return undefined; +} diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index f5b62a01..23c864c3 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -332,11 +333,22 @@ test('installAndroidApp installs .apk via adb install -r', async () => { test('installAndroidApp resolves packageName and launchTarget from nested archive artifacts', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-install-archive-')); const adbPath = path.join(tmpDir, 'adb'); - const dittoPath = path.join(tmpDir, 'ditto'); const argsLogPath = path.join(tmpDir, 'args.log'); const installMarkerPath = path.join(tmpDir, 'installed.marker'); const archivePath = path.join(tmpDir, 'Sample.zip'); - await fs.writeFile(archivePath, 'placeholder', 'utf8'); + const manifestDir = path.join(tmpDir, 'manifest'); + const nestedDir = path.join(tmpDir, 'nested'); + await fs.mkdir(manifestDir); + await fs.mkdir(nestedDir); + await fs.writeFile( + path.join(manifestDir, 'AndroidManifest.xml'), + '', + 'utf8', + ); + execFileSync('zip', ['-qr', path.join(nestedDir, 'Sample.apk'), 'AndroidManifest.xml'], { + cwd: manifestDir, + }); + execFileSync('zip', ['-qr', archivePath, 'nested'], { cwd: tmpDir }); await fs.writeFile( adbPath, @@ -359,23 +371,6 @@ test('installAndroidApp resolves packageName and launchTarget from nested archiv 'utf8', ); await fs.chmod(adbPath, 0o755); - await fs.writeFile( - dittoPath, - [ - '#!/bin/sh', - 'mkdir -p "$4/nested/apk"', - 'cat > "$4/nested/apk/AndroidManifest.xml" <<\'XML\'', - '', - 'XML', - '(cd "$4/nested/apk" && zip -qr ../Sample.apk AndroidManifest.xml)', - 'rm -rf "$4/nested/apk"', - 'exit 0', - '', - ].join('\n'), - 'utf8', - ); - await fs.chmod(dittoPath, 0o755); - const previousPath = process.env.PATH; const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; diff --git a/src/platforms/install-source.ts b/src/platforms/install-source.ts index dc2b1c0e..1d258d61 100644 --- a/src/platforms/install-source.ts +++ b/src/platforms/install-source.ts @@ -428,7 +428,7 @@ async function extractArchive( const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-archive-')); try { if (archivePath.toLowerCase().endsWith('.zip')) { - await runCmd('ditto', ['-x', '-k', archivePath, tempDir]); + await extractZipArchive(archivePath, tempDir); } else if ( archivePath.toLowerCase().endsWith('.tar.gz') || archivePath.toLowerCase().endsWith('.tgz') @@ -449,6 +449,10 @@ async function extractArchive( } } +async function extractZipArchive(archivePath: string, outputPath: string): Promise { + await runCmd('unzip', ['-q', archivePath, '-d', outputPath]); +} + function isArchivePath(candidatePath: string): boolean { const lower = candidatePath.toLowerCase(); return INTERNAL_ARCHIVE_EXTENSIONS.some((extension) => lower.endsWith(extension)); diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 94f45a99..ea898e0b 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -1180,7 +1180,7 @@ exit 1 test('installIosApp on iOS physical device accepts .ipa and installs extracted .app payload', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-ipa-test-')); const xcrunPath = path.join(tmpDir, 'xcrun'); - const dittoPath = path.join(tmpDir, 'ditto'); + const unzipPath = path.join(tmpDir, 'unzip'); const argsLogPath = path.join(tmpDir, 'args.log'); const ipaPath = path.join(tmpDir, 'Sample.ipa'); await fs.writeFile(ipaPath, 'placeholder', 'utf8'); @@ -1191,8 +1191,8 @@ test('installIosApp on iOS physical device accepts .ipa and installs extracted . 'utf8', ); await fs.chmod(xcrunPath, 0o755); - await fs.writeFile(dittoPath, '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nexit 0\n', 'utf8'); - await fs.chmod(dittoPath, 0o755); + await fs.writeFile(unzipPath, '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nexit 0\n', 'utf8'); + await fs.chmod(unzipPath, 0o755); const previousPath = process.env.PATH; const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; @@ -1230,7 +1230,7 @@ test('installIosApp on iOS physical device accepts .ipa and installs extracted . test('installIosApp returns bundleId and launchTarget for nested archive sources', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-archive-test-')); const xcrunPath = path.join(tmpDir, 'xcrun'); - const dittoPath = path.join(tmpDir, 'ditto'); + const unzipPath = path.join(tmpDir, 'unzip'); const plutilPath = path.join(tmpDir, 'plutil'); const argsLogPath = path.join(tmpDir, 'args.log'); const archivePath = path.join(tmpDir, 'Sample.zip'); @@ -1243,10 +1243,10 @@ test('installIosApp returns bundleId and launchTarget for nested archive sources ); await fs.chmod(xcrunPath, 0o755); await fs.writeFile( - dittoPath, + unzipPath, [ '#!/bin/sh', - 'src="$3"', + 'src="$2"', 'out="$4"', 'case "$src" in', ' *.zip)', @@ -1264,7 +1264,7 @@ test('installIosApp returns bundleId and launchTarget for nested archive sources ].join('\n'), 'utf8', ); - await fs.chmod(dittoPath, 0o755); + await fs.chmod(unzipPath, 0o755); await fs.writeFile( plutilPath, [ @@ -1318,7 +1318,7 @@ test('installIosApp on iOS physical device resolves multi-app .ipa using bundle path.join(os.tmpdir(), 'agent-device-ios-install-ipa-multi-test-'), ); const xcrunPath = path.join(tmpDir, 'xcrun'); - const dittoPath = path.join(tmpDir, 'ditto'); + const unzipPath = path.join(tmpDir, 'unzip'); const plutilPath = path.join(tmpDir, 'plutil'); const argsLogPath = path.join(tmpDir, 'args.log'); const ipaPath = path.join(tmpDir, 'Sample.ipa'); @@ -1331,11 +1331,11 @@ test('installIosApp on iOS physical device resolves multi-app .ipa using bundle ); await fs.chmod(xcrunPath, 0o755); await fs.writeFile( - dittoPath, + unzipPath, '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nmkdir -p "$4/Payload/Companion.app"\nexit 0\n', 'utf8', ); - await fs.chmod(dittoPath, 0o755); + await fs.chmod(unzipPath, 0o755); await fs.writeFile( plutilPath, [ @@ -1385,7 +1385,7 @@ test('installIosApp rejects multi-app .ipa when no hint is provided', async () = path.join(os.tmpdir(), 'agent-device-ios-install-ipa-multi-missing-hint-test-'), ); const xcrunPath = path.join(tmpDir, 'xcrun'); - const dittoPath = path.join(tmpDir, 'ditto'); + const unzipPath = path.join(tmpDir, 'unzip'); const plutilPath = path.join(tmpDir, 'plutil'); const ipaPath = path.join(tmpDir, 'Sample.ipa'); await fs.writeFile(ipaPath, 'placeholder', 'utf8'); @@ -1393,11 +1393,11 @@ test('installIosApp rejects multi-app .ipa when no hint is provided', async () = await fs.writeFile(xcrunPath, '#!/bin/sh\nexit 0\n', 'utf8'); await fs.chmod(xcrunPath, 0o755); await fs.writeFile( - dittoPath, + unzipPath, '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nmkdir -p "$4/Payload/Companion.app"\nexit 0\n', 'utf8', ); - await fs.chmod(dittoPath, 0o755); + await fs.chmod(unzipPath, 0o755); await fs.writeFile( plutilPath, [ @@ -1442,14 +1442,14 @@ test('installIosApp rejects invalid .ipa payloads without embedded .app', async path.join(os.tmpdir(), 'agent-device-ios-install-ipa-invalid-test-'), ); const xcrunPath = path.join(tmpDir, 'xcrun'); - const dittoPath = path.join(tmpDir, 'ditto'); + const unzipPath = path.join(tmpDir, 'unzip'); const ipaPath = path.join(tmpDir, 'Broken.ipa'); await fs.writeFile(ipaPath, 'placeholder', 'utf8'); await fs.writeFile(xcrunPath, '#!/bin/sh\nexit 0\n', 'utf8'); await fs.chmod(xcrunPath, 0o755); - await fs.writeFile(dittoPath, '#!/bin/sh\nmkdir -p "$4/NoPayload"\nexit 0\n', 'utf8'); - await fs.chmod(dittoPath, 0o755); + await fs.writeFile(unzipPath, '#!/bin/sh\nmkdir -p "$4/NoPayload"\nexit 0\n', 'utf8'); + await fs.chmod(unzipPath, 0o755); const previousPath = process.env.PATH; process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; diff --git a/src/platforms/ios/install-artifact.ts b/src/platforms/ios/install-artifact.ts index 8d82b2f3..771d2919 100644 --- a/src/platforms/ios/install-artifact.ts +++ b/src/platforms/ios/install-artifact.ts @@ -98,7 +98,7 @@ async function resolveIosInstallablePath( await fs.rm(tempDir, { recursive: true, force: true }); }; try { - await runCmd('ditto', ['-x', '-k', appPath, tempDir]); + await runCmd('unzip', ['-q', appPath, '-d', tempDir]); const payloadDir = path.join(tempDir, 'Payload'); const payloadEntries = await fs.readdir(payloadDir, { withFileTypes: true }).catch(() => { throw new AppError('INVALID_ARGS', 'Invalid IPA: missing Payload directory');