Skip to content

Commit 43110aa

Browse files
authored
fix: support portable zip install-source extraction (#401)
1 parent 24f398c commit 43110aa

File tree

5 files changed

+92
-37
lines changed

5 files changed

+92
-37
lines changed

src/platforms/__tests__/install-source.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test, onTestFinished } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { execFileSync } from 'node:child_process';
44
import http from 'node:http';
5+
import fsSync from 'node:fs';
56
import fs from 'node:fs/promises';
67
import os from 'node:os';
78
import path from 'node:path';
@@ -107,6 +108,44 @@ test('materializeInstallablePath rejects archive extraction when disabled', asyn
107108
}
108109
});
109110

111+
test.sequential('materializeInstallablePath extracts zip archives without ditto', async () => {
112+
const unzipPath = findExecutableInPath('unzip');
113+
assert.ok(unzipPath, 'unzip must be available for portable zip extraction');
114+
115+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-install-source-unzip-'));
116+
const archivePath = path.join(tempRoot, 'bundle.zip');
117+
const binDir = path.join(tempRoot, 'bin');
118+
const payloadDir = path.join(tempRoot, 'payload');
119+
const apkPath = path.join(payloadDir, 'Sample.apk');
120+
const previousPath = process.env.PATH;
121+
122+
try {
123+
await fs.mkdir(binDir);
124+
await fs.symlink(unzipPath, path.join(binDir, 'unzip'));
125+
await fs.mkdir(payloadDir);
126+
await fs.writeFile(apkPath, 'placeholder apk', 'utf8');
127+
execFileSync('zip', ['-qr', archivePath, 'payload'], { cwd: tempRoot });
128+
129+
process.env.PATH = binDir;
130+
const result = await materializeInstallablePath({
131+
source: { kind: 'path', path: archivePath },
132+
isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'),
133+
installableLabel: 'Android installable (.apk or .aab)',
134+
allowArchiveExtraction: true,
135+
});
136+
137+
try {
138+
assert.equal(path.basename(result.installablePath), 'Sample.apk');
139+
assert.equal(await fs.readFile(result.installablePath, 'utf8'), 'placeholder apk');
140+
} finally {
141+
await result.cleanup();
142+
}
143+
} finally {
144+
process.env.PATH = previousPath;
145+
await fs.rm(tempRoot, { recursive: true, force: true });
146+
}
147+
});
148+
110149
test('prepareIosInstallArtifact rejects untrusted URL sources', async () => {
111150
await assert.rejects(
112151
async () =>
@@ -160,3 +199,20 @@ test('prepareAndroidInstallArtifact resolves package identity for direct APK URL
160199
await result.cleanup();
161200
}
162201
});
202+
203+
function findExecutableInPath(command: string): string | undefined {
204+
const pathValue = process.env.PATH;
205+
if (!pathValue) return undefined;
206+
for (const directory of pathValue.split(path.delimiter)) {
207+
if (!directory) continue;
208+
const candidate = path.join(directory, command);
209+
try {
210+
if (!fsSync.statSync(candidate).isFile()) continue;
211+
fsSync.accessSync(candidate, fsSync.constants.X_OK);
212+
return candidate;
213+
} catch {
214+
// Keep scanning PATH.
215+
}
216+
}
217+
return undefined;
218+
}

src/platforms/android/__tests__/index.test.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import { execFileSync } from 'node:child_process';
34
import { promises as fs } from 'node:fs';
45
import os from 'node:os';
56
import path from 'node:path';
@@ -332,11 +333,22 @@ test('installAndroidApp installs .apk via adb install -r', async () => {
332333
test('installAndroidApp resolves packageName and launchTarget from nested archive artifacts', async () => {
333334
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-install-archive-'));
334335
const adbPath = path.join(tmpDir, 'adb');
335-
const dittoPath = path.join(tmpDir, 'ditto');
336336
const argsLogPath = path.join(tmpDir, 'args.log');
337337
const installMarkerPath = path.join(tmpDir, 'installed.marker');
338338
const archivePath = path.join(tmpDir, 'Sample.zip');
339-
await fs.writeFile(archivePath, 'placeholder', 'utf8');
339+
const manifestDir = path.join(tmpDir, 'manifest');
340+
const nestedDir = path.join(tmpDir, 'nested');
341+
await fs.mkdir(manifestDir);
342+
await fs.mkdir(nestedDir);
343+
await fs.writeFile(
344+
path.join(manifestDir, 'AndroidManifest.xml'),
345+
'<manifest package="com.example.archive" />',
346+
'utf8',
347+
);
348+
execFileSync('zip', ['-qr', path.join(nestedDir, 'Sample.apk'), 'AndroidManifest.xml'], {
349+
cwd: manifestDir,
350+
});
351+
execFileSync('zip', ['-qr', archivePath, 'nested'], { cwd: tmpDir });
340352

341353
await fs.writeFile(
342354
adbPath,
@@ -359,23 +371,6 @@ test('installAndroidApp resolves packageName and launchTarget from nested archiv
359371
'utf8',
360372
);
361373
await fs.chmod(adbPath, 0o755);
362-
await fs.writeFile(
363-
dittoPath,
364-
[
365-
'#!/bin/sh',
366-
'mkdir -p "$4/nested/apk"',
367-
'cat > "$4/nested/apk/AndroidManifest.xml" <<\'XML\'',
368-
'<manifest package="com.example.archive" />',
369-
'XML',
370-
'(cd "$4/nested/apk" && zip -qr ../Sample.apk AndroidManifest.xml)',
371-
'rm -rf "$4/nested/apk"',
372-
'exit 0',
373-
'',
374-
].join('\n'),
375-
'utf8',
376-
);
377-
await fs.chmod(dittoPath, 0o755);
378-
379374
const previousPath = process.env.PATH;
380375
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
381376
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;

src/platforms/install-source.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ async function extractArchive(
428428
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-archive-'));
429429
try {
430430
if (archivePath.toLowerCase().endsWith('.zip')) {
431-
await runCmd('ditto', ['-x', '-k', archivePath, tempDir]);
431+
await extractZipArchive(archivePath, tempDir);
432432
} else if (
433433
archivePath.toLowerCase().endsWith('.tar.gz') ||
434434
archivePath.toLowerCase().endsWith('.tgz')
@@ -449,6 +449,10 @@ async function extractArchive(
449449
}
450450
}
451451

452+
async function extractZipArchive(archivePath: string, outputPath: string): Promise<void> {
453+
await runCmd('unzip', ['-q', archivePath, '-d', outputPath]);
454+
}
455+
452456
function isArchivePath(candidatePath: string): boolean {
453457
const lower = candidatePath.toLowerCase();
454458
return INTERNAL_ARCHIVE_EXTENSIONS.some((extension) => lower.endsWith(extension));

src/platforms/ios/__tests__/index.test.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,7 @@ exit 1
11801180
test('installIosApp on iOS physical device accepts .ipa and installs extracted .app payload', async () => {
11811181
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-ipa-test-'));
11821182
const xcrunPath = path.join(tmpDir, 'xcrun');
1183-
const dittoPath = path.join(tmpDir, 'ditto');
1183+
const unzipPath = path.join(tmpDir, 'unzip');
11841184
const argsLogPath = path.join(tmpDir, 'args.log');
11851185
const ipaPath = path.join(tmpDir, 'Sample.ipa');
11861186
await fs.writeFile(ipaPath, 'placeholder', 'utf8');
@@ -1191,8 +1191,8 @@ test('installIosApp on iOS physical device accepts .ipa and installs extracted .
11911191
'utf8',
11921192
);
11931193
await fs.chmod(xcrunPath, 0o755);
1194-
await fs.writeFile(dittoPath, '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nexit 0\n', 'utf8');
1195-
await fs.chmod(dittoPath, 0o755);
1194+
await fs.writeFile(unzipPath, '#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nexit 0\n', 'utf8');
1195+
await fs.chmod(unzipPath, 0o755);
11961196

11971197
const previousPath = process.env.PATH;
11981198
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
@@ -1230,7 +1230,7 @@ test('installIosApp on iOS physical device accepts .ipa and installs extracted .
12301230
test('installIosApp returns bundleId and launchTarget for nested archive sources', async () => {
12311231
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-archive-test-'));
12321232
const xcrunPath = path.join(tmpDir, 'xcrun');
1233-
const dittoPath = path.join(tmpDir, 'ditto');
1233+
const unzipPath = path.join(tmpDir, 'unzip');
12341234
const plutilPath = path.join(tmpDir, 'plutil');
12351235
const argsLogPath = path.join(tmpDir, 'args.log');
12361236
const archivePath = path.join(tmpDir, 'Sample.zip');
@@ -1243,10 +1243,10 @@ test('installIosApp returns bundleId and launchTarget for nested archive sources
12431243
);
12441244
await fs.chmod(xcrunPath, 0o755);
12451245
await fs.writeFile(
1246-
dittoPath,
1246+
unzipPath,
12471247
[
12481248
'#!/bin/sh',
1249-
'src="$3"',
1249+
'src="$2"',
12501250
'out="$4"',
12511251
'case "$src" in',
12521252
' *.zip)',
@@ -1264,7 +1264,7 @@ test('installIosApp returns bundleId and launchTarget for nested archive sources
12641264
].join('\n'),
12651265
'utf8',
12661266
);
1267-
await fs.chmod(dittoPath, 0o755);
1267+
await fs.chmod(unzipPath, 0o755);
12681268
await fs.writeFile(
12691269
plutilPath,
12701270
[
@@ -1318,7 +1318,7 @@ test('installIosApp on iOS physical device resolves multi-app .ipa using bundle
13181318
path.join(os.tmpdir(), 'agent-device-ios-install-ipa-multi-test-'),
13191319
);
13201320
const xcrunPath = path.join(tmpDir, 'xcrun');
1321-
const dittoPath = path.join(tmpDir, 'ditto');
1321+
const unzipPath = path.join(tmpDir, 'unzip');
13221322
const plutilPath = path.join(tmpDir, 'plutil');
13231323
const argsLogPath = path.join(tmpDir, 'args.log');
13241324
const ipaPath = path.join(tmpDir, 'Sample.ipa');
@@ -1331,11 +1331,11 @@ test('installIosApp on iOS physical device resolves multi-app .ipa using bundle
13311331
);
13321332
await fs.chmod(xcrunPath, 0o755);
13331333
await fs.writeFile(
1334-
dittoPath,
1334+
unzipPath,
13351335
'#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nmkdir -p "$4/Payload/Companion.app"\nexit 0\n',
13361336
'utf8',
13371337
);
1338-
await fs.chmod(dittoPath, 0o755);
1338+
await fs.chmod(unzipPath, 0o755);
13391339
await fs.writeFile(
13401340
plutilPath,
13411341
[
@@ -1385,19 +1385,19 @@ test('installIosApp rejects multi-app .ipa when no hint is provided', async () =
13851385
path.join(os.tmpdir(), 'agent-device-ios-install-ipa-multi-missing-hint-test-'),
13861386
);
13871387
const xcrunPath = path.join(tmpDir, 'xcrun');
1388-
const dittoPath = path.join(tmpDir, 'ditto');
1388+
const unzipPath = path.join(tmpDir, 'unzip');
13891389
const plutilPath = path.join(tmpDir, 'plutil');
13901390
const ipaPath = path.join(tmpDir, 'Sample.ipa');
13911391
await fs.writeFile(ipaPath, 'placeholder', 'utf8');
13921392

13931393
await fs.writeFile(xcrunPath, '#!/bin/sh\nexit 0\n', 'utf8');
13941394
await fs.chmod(xcrunPath, 0o755);
13951395
await fs.writeFile(
1396-
dittoPath,
1396+
unzipPath,
13971397
'#!/bin/sh\nmkdir -p "$4/Payload/Sample.app"\nmkdir -p "$4/Payload/Companion.app"\nexit 0\n',
13981398
'utf8',
13991399
);
1400-
await fs.chmod(dittoPath, 0o755);
1400+
await fs.chmod(unzipPath, 0o755);
14011401
await fs.writeFile(
14021402
plutilPath,
14031403
[
@@ -1442,14 +1442,14 @@ test('installIosApp rejects invalid .ipa payloads without embedded .app', async
14421442
path.join(os.tmpdir(), 'agent-device-ios-install-ipa-invalid-test-'),
14431443
);
14441444
const xcrunPath = path.join(tmpDir, 'xcrun');
1445-
const dittoPath = path.join(tmpDir, 'ditto');
1445+
const unzipPath = path.join(tmpDir, 'unzip');
14461446
const ipaPath = path.join(tmpDir, 'Broken.ipa');
14471447
await fs.writeFile(ipaPath, 'placeholder', 'utf8');
14481448

14491449
await fs.writeFile(xcrunPath, '#!/bin/sh\nexit 0\n', 'utf8');
14501450
await fs.chmod(xcrunPath, 0o755);
1451-
await fs.writeFile(dittoPath, '#!/bin/sh\nmkdir -p "$4/NoPayload"\nexit 0\n', 'utf8');
1452-
await fs.chmod(dittoPath, 0o755);
1451+
await fs.writeFile(unzipPath, '#!/bin/sh\nmkdir -p "$4/NoPayload"\nexit 0\n', 'utf8');
1452+
await fs.chmod(unzipPath, 0o755);
14531453

14541454
const previousPath = process.env.PATH;
14551455
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;

src/platforms/ios/install-artifact.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async function resolveIosInstallablePath(
9898
await fs.rm(tempDir, { recursive: true, force: true });
9999
};
100100
try {
101-
await runCmd('ditto', ['-x', '-k', appPath, tempDir]);
101+
await runCmd('unzip', ['-q', appPath, '-d', tempDir]);
102102
const payloadDir = path.join(tempDir, 'Payload');
103103
const payloadEntries = await fs.readdir(payloadDir, { withFileTypes: true }).catch(() => {
104104
throw new AppError('INVALID_ARGS', 'Invalid IPA: missing Payload directory');

0 commit comments

Comments
 (0)