Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/platforms/__tests__/install-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () =>
Expand Down Expand Up @@ -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;
}
33 changes: 14 additions & 19 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'),
'<manifest package="com.example.archive" />',
'utf8',
);
execFileSync('zip', ['-qr', path.join(nestedDir, 'Sample.apk'), 'AndroidManifest.xml'], {
cwd: manifestDir,
});
execFileSync('zip', ['-qr', archivePath, 'nested'], { cwd: tmpDir });

await fs.writeFile(
adbPath,
Expand All @@ -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\'',
'<manifest package="com.example.archive" />',
'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 ?? ''}`;
Expand Down
6 changes: 5 additions & 1 deletion src/platforms/install-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -449,6 +449,10 @@ async function extractArchive(
}
}

async function extractZipArchive(archivePath: string, outputPath: string): Promise<void> {
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));
Expand Down
32 changes: 16 additions & 16 deletions src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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)',
Expand All @@ -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,
[
Expand Down Expand Up @@ -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');
Expand All @@ -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,
[
Expand Down Expand Up @@ -1385,19 +1385,19 @@ 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');

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,
[
Expand Down Expand Up @@ -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 ?? ''}`;
Expand Down
2 changes: 1 addition & 1 deletion src/platforms/ios/install-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading