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
4 changes: 4 additions & 0 deletions packages/host-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"types": "./src/archive.ts",
"default": "./src/archive.ts"
},
"./archive-fixtures": {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"types": "./src/archive.fixtures.ts",
"default": "./src/archive.fixtures.ts"
},
"./audio-probe-fixtures": {
"types": "./src/audio-probe.fixtures.ts",
"default": "./src/audio-probe.fixtures.ts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { promises as fs } from 'node:fs';
import path from 'node:path';
import { crc32, gzipSync } from 'node:zlib';
import * as tar from 'tar-stream';
import { runCmdSync } from './exec.ts';
import { mkdtempForTest } from './tmp-dir.fixtures.ts';
import { mkdtempForTest } from './internal/tmp-dir.fixtures.ts';

export async function createArchiveWorkspace(): Promise<{
archivePath: string;
Expand All @@ -27,21 +26,30 @@ export async function createTruncatedTgz(archivePath: string): Promise<void> {
await fs.writeFile(archivePath, gzipSync(headerOnly));
}

/** Two stored entries whose second carries the encrypted general-purpose flag bit. */
export async function createZipWithEncryptedSecondEntry(archivePath: string): Promise<void> {
const staging = path.join(path.dirname(archivePath), 'zip-input');
await fs.mkdir(staging);
await fs.writeFile(path.join(staging, 'first.txt'), 'first');
await fs.writeFile(path.join(staging, 'second.txt'), 'second');
runCmdSync('zip', ['-q', archivePath, 'first.txt'], { cwd: staging });
runCmdSync('zip', ['-q', '-P', 'secret', archivePath, 'second.txt'], { cwd: staging });
await writeZipFixture(archivePath, [
{ name: 'first.txt', mode: ZIP_FILE_MODE, data: 'first' },
{ name: 'second.txt', mode: ZIP_FILE_MODE, data: 'second', encrypted: true },
]);
}

const ZIP_FILE_MODE = 0o100644;

/** One stored zip entry; `mode` is the full Unix `st_mode`, file-type bits included. */
export type ZipFixtureEntry = { name: string; mode: number; data?: string };
export type ZipFixtureEntry = {
name: string;
mode: number;
data?: string;
/** Declares the entry encrypted, which no archive reader may then extract. */
encrypted?: boolean;
};

/**
* Writes a stored (uncompressed) zip whose names and Unix attributes are exactly as given, which
* `zip` itself refuses to produce for links, special files, and escaping names.
* `zip` itself refuses to produce for links, special files, and escaping names. An entry declared
* `encrypted` carries the traditional encryption flag with a padded 12-byte encryption header and
* a zero CRC, the shape readers must refuse before extracting anything.
*/
export async function writeZipFixture(
archivePath: string,
Expand All @@ -52,24 +60,29 @@ export async function writeZipFixture(
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name, 'utf8');
const data = Buffer.from(entry.data ?? '', 'utf8');
const checksum = crc32(data);
const payload = Buffer.from(entry.data ?? '', 'utf8');
// A traditionally encrypted stored entry carries a 12-byte encryption header before the
// ciphertext, and readers validate that against the declared sizes, so the record declares
// it and the data section pads it; the payload itself never decrypts to anything.
const data = entry.encrypted ? Buffer.concat([Buffer.alloc(12), payload]) : payload;
const checksum = entry.encrypted ? 0 : crc32(payload);
const flags = 0x0800 | (entry.encrypted ? 0x0001 : 0);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(flags, 6);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt32LE(payload.length, 22);
local.writeUInt16LE(name.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE((3 << 8) | 20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(flags, 8);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt32LE(payload.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE((entry.mode << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { extractTarArchive } from './archive-extraction-tar.ts';
import { ArchiveBudget } from './archive-safety.ts';
import { createArchiveWorkspace, createTruncatedTgz } from './archive-extraction.fixtures.ts';
import { createArchiveWorkspace, createTruncatedTgz } from '../archive.fixtures.ts';

test('tar inspection rejects declared bytes before draining an over-budget entry', async () => {
const workspace = await createArchiveWorkspace();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { extractZipArchive } from './archive-extraction-zip.ts';
import { ArchiveBudget } from './archive-safety.ts';
import {
createArchiveWorkspace,
createZipWithEncryptedSecondEntry,
} from './archive-extraction.fixtures.ts';
import { createArchiveWorkspace, createZipWithEncryptedSecondEntry } from '../archive.fixtures.ts';

test('zip inspection stops at the entry budget before inspecting later entries', async () => {
const workspace = await createArchiveWorkspace();
Expand Down
2 changes: 1 addition & 1 deletion packages/host-kit/src/internal/archive-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
writeZipFixture,
type TarFixtureEntry,
type ZipFixtureEntry,
} from './archive-extraction.fixtures.ts';
} from '../archive.fixtures.ts';
import { mkdtempForTest } from './tmp-dir.fixtures.ts';

const S_IFREG = 0o100000;
Expand Down
53 changes: 35 additions & 18 deletions packages/platform-android/src/__tests__/emulator-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,22 @@ function bootPropFailure(stdout: string, stderr: string) {
};
}

/** A poll that keeps missing like a real booting device does, and actually burns wall-clock time. */
/** A poll that keeps missing like a real booting device does and burns the given clock time. */
function slowBootPropFailure(delayMs: number) {
return async () => {
await new Promise((resolve) => setTimeout(resolve, delayMs));
return { stdout: '', stderr: '', exitCode: 1 };
};
}

async function expectBootFailure(
/**
* Starts the wait and resolves to the typed failure it must produce. The caller drives any timers
* between starting and awaiting when the test runs on a fake clock.
*/
function startedBootFailure(
timeoutMs: number,
): Promise<{ code: string; message: string; details: Record<string, unknown> }> {
return await waitForAndroidBoot('emulator-5554', timeoutMs).then(
return waitForAndroidBoot('emulator-5554', timeoutMs).then(
() => {
throw new Error('expected waitForAndroidBoot to reject');
},
Expand All @@ -133,6 +137,12 @@ async function expectBootFailure(
);
}

async function expectBootFailure(
timeoutMs: number,
): Promise<{ code: string; message: string; details: Record<string, unknown> }> {
return await startedBootFailure(timeoutMs);
}

test('a boot poll against an offline device fails as an adb transport loss without retrying', async () => {
mockRunCmd.mockImplementation(bootPropFailure('', 'error: device offline'));

Expand All @@ -149,21 +159,28 @@ test('a boot poll against an offline device fails as an adb transport loss witho
});

test('a still-booting device past its deadline reports an Android boot timeout', async () => {
// 80 ms per poll with a 50 ms retry cadence crosses the 150 ms deadline inside the second
// poll, so the failure is a device that ran out of budget, not three fast poll misses.
mockRunCmd.mockImplementation(slowBootPropFailure(80));

const failure = await expectBootFailure(150);

assert.equal(failure.code, 'COMMAND_FAILED');
assert.equal(failure.message, 'Android device did not finish booting in time');
assert.equal(failure.details.reason, 'ANDROID_BOOT_TIMEOUT');
assert.equal(failure.details.timeoutMs, 150);
assert.equal(Number(failure.details.elapsedMs) >= 150, true);
assert.equal(mockRunCmd.mock.calls.length, 2);
// The classifier sees BOOT_COMMAND_FAILED from the still-booting throw; the caller must
// normalize it to the timeout, not trust the raw classification.
assert.notEqual(failure.details.reason, 'BOOT_COMMAND_FAILED');
// On a fake clock: each poll burns 80 ms, and the 150 ms deadline is crossed inside the
// second poll, so the failure is a device that ran out of budget, not a run of fast poll
// misses. Fake timers keep the 80 ms/150 ms geometry exact instead of racing the host.
vi.useFakeTimers();
try {
mockRunCmd.mockImplementation(slowBootPropFailure(80));

const started = startedBootFailure(150);
await vi.runAllTimersAsync();
const failure = await started;

assert.equal(failure.code, 'COMMAND_FAILED');
assert.equal(failure.message, 'Android device did not finish booting in time');
// The classifier sees BOOT_COMMAND_FAILED from the still-booting throw; the caller must
// normalize it to the timeout rather than trust the raw classification.
assert.equal(failure.details.reason, 'ANDROID_BOOT_TIMEOUT');
assert.equal(failure.details.timeoutMs, 150);
assert.equal(Number(failure.details.elapsedMs) >= 150, true);
assert.equal(mockRunCmd.mock.calls.length, 2);
} finally {
vi.useRealTimers();
}
});

test('a TOOL_MISSING adb inside the boot loop keeps its error code and hint', async () => {
Expand Down
26 changes: 14 additions & 12 deletions packages/platform-apple/src/core/__tests__/install-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { runCmdSync } from '@agent-device/host-kit/command';
import { writeZipFixture } from '@agent-device/host-kit/archive-fixtures';
import { AppError } from '@agent-device/kernel/errors';
import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts';
import { prepareIosInstallArtifact } from '../install-artifact.ts';
Expand Down Expand Up @@ -99,27 +99,29 @@ test('prepareIosInstallArtifact keeps a non-dotted hint a name-only match', asyn

type PayloadBundleFixture = { name: string; bundleId?: string };

const ZIP_DIR_MODE = 0o040000 | 0o755;
const ZIP_FILE_MODE = 0o100644;

/**
* Builds a real zip-format IPA carrying the given `Payload/*.app` bundles and runs `run` against
* it. Info.plist reads go through a fake tool provider keyed by the bundle's own directory, so
* it, written through host-kit's own zip fixture so the test needs no `zip` binary on the host.
* Info.plist reads go through a fake tool provider keyed by the bundle's own directory, so
* each payload reports an identity of its own without plist tooling on the host.
*/
async function withMultiPayloadIpa(
bundles: PayloadBundleFixture[],
run: (ipaPath: string) => Promise<void>,
): Promise<void> {
const root = await mkdtempForTest('agent-device-ios-multi-payload-');
const staging = path.join(root, 'staging');
for (const bundle of bundles) {
const appDir = path.join(staging, 'Payload', `${bundle.name}.app`);
await fs.mkdir(appDir, { recursive: true });
await fs.writeFile(
path.join(appDir, 'Info.plist'),
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>',
);
}
const ipaPath = path.join(root, 'multi.ipa');
runCmdSync('zip', ['-qr', ipaPath, '.'], { cwd: staging });
await writeZipFixture(ipaPath, [
{ name: 'Payload/', mode: ZIP_DIR_MODE },
...bundles.map(({ name }) => ({
name: `Payload/${name}.app/Info.plist`,
mode: ZIP_FILE_MODE,
data: '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict/></plist>',
})),
]);

const provider = createLocalAppleToolProvider({
plist: {
Expand Down
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 { AppError } from '@agent-device/kernel/errors';
import { copyCoreDeviceRunnerFile } from '../physical-device-files.ts';
import { IOS_DEVICE } from '../../__tests__/device-fixtures.ts';
import { withFakeAppleTool, type FakeAppleToolScript } from '../../__tests__/fake-apple-tool.ts';
Expand Down Expand Up @@ -83,7 +84,16 @@ test('copyCoreDeviceRunnerFile refuses to start once the deadline is spent', asy
async () => await copyCoreDeviceRunnerFile(IOS_DEVICE, '/tmp/shot.png', '/out/shot.png', 0),
{ device: IOS_DEVICE },
),
/iOS runner file copy timed out after 0ms/,
(error: unknown) => {
// The message alone says only "timed out"; the typed step is what tells the caller the
// spent deadline was the file copy's, not another phase of the physical-device flow.
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.match(error.message, /iOS runner file copy timed out after 0ms/);
assert.equal(error.details?.step, 'runner file copy');
assert.equal(error.details?.timeoutMs, 0);
return true;
},
);
assert.deepEqual(calls, []);
});
Expand Down
Loading
Loading