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');