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
49 changes: 46 additions & 3 deletions packages/platform-android/src/__tests__/app-lifecycle-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ test('openAndroidApp ensures Android reverse before localhost deep link launch',
booted: true,
};
const calls: Array<
{ kind: 'exec'; args: readonly string[] } | { kind: 'reverse'; local: string; remote: string }
| { kind: 'exec'; args: readonly string[] }
| { kind: 'reverse'; local: string; remote: string; ownerId?: string }
> = [];

await withAndroidAdbProvider(
Expand All @@ -182,7 +183,12 @@ test('openAndroidApp ensures Android reverse before localhost deep link launch',
},
reverse: {
ensure: async (mapping) => {
calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote });
calls.push({
kind: 'reverse',
local: mapping.local,
remote: mapping.remote,
ownerId: mapping.ownerId,
});
},
remove: async () => {},
removeAllOwned: async () => {},
Expand All @@ -193,7 +199,7 @@ test('openAndroidApp ensures Android reverse before localhost deep link launch',
);

assert.deepEqual(calls, [
{ kind: 'reverse', local: 'tcp:8083', remote: 'tcp:8083' },
{ kind: 'reverse', local: 'tcp:8083', remote: 'tcp:8083', ownerId: 'localhost-url' },
{
kind: 'exec',
args: [
Expand All @@ -210,6 +216,43 @@ test('openAndroidApp ensures Android reverse before localhost deep link launch',
]);
});

test('openAndroidApp keeps the typed reason of a refused localhost reverse', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
const launches: (readonly string[])[] = [];

await assert.rejects(
() =>
withAndroidAdbProvider(
{
exec: async (args) => {
launches.push(args);
return { stdout: '', stderr: '', exitCode: 0 };
},
reverse: {
ensure: async () => {
throw new AppError('COMMAND_FAILED', 'already mapped', {
reason: 'android_port_reverse_rebind_refused',
});
},
remove: async () => {},
removeAllOwned: async () => {},
},
},
{ serial: 'emulator-5554' },
async () => await openAndroidApp(device, 'exp://127.0.0.1:8081'),
),
(error: unknown) =>
error instanceof AppError && error.details?.reason === 'android_port_reverse_rebind_refused',
);
assert.deepEqual(launches, []);
});

test('openAndroidApp ensures Android reverse before localhost app-bound deep link launch', async () => {
const device: DeviceInfo = {
platform: 'android',
Expand Down
118 changes: 118 additions & 0 deletions packages/platform-android/src/adb-port-reverse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,121 @@ test('a provider-owned reverse implementation is reused as-is when already manag
const second = createAndroidPortReverseManager({ exec: async () => ok(), reverse: first });
expect(second).toBe(first);
});

test('no-rebind refuses a device mapping another client owns with a typed reason', async () => {
bindAndroidAdbHostStub();
const calls: (readonly string[])[] = [];
const manager = createAndroidPortReverseManager(
async (args) => {
calls.push(args);
if (args.includes('--no-rebind')) {
return { exitCode: 1, stdout: '', stderr: 'adb: error: cannot rebind existing socket' };
}
return ok(args[1] === '--list' ? 'owner-host tcp:8081 tcp:8081\n' : '');
},
{ noRebind: true },
);

await expect(
manager.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'metro' }),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: {
reason: 'android_port_reverse_rebind_refused',
existing: { local: 'tcp:8081', remote: 'tcp:8081' },
},
});
await manager.removeAllOwned('metro');

expect(calls).toEqual([
['reverse', '--no-rebind', 'tcp:8081', 'tcp:8081'],
['reverse', '--list'],
]);
});

test('no-rebind does not re-point a mapping the same provider created', async () => {
bindAndroidAdbHostStub();
const calls: (readonly string[])[] = [];
const device = new Map<string, string>();
const manager = createAndroidPortReverseManager(
async (args) => {
calls.push(args);
if (args[1] === '--list') {
return ok([...device].map(([local, remote]) => `host-1 ${local} ${remote}\n`).join(''));
}
const [local, remote] = args.slice(-2) as [string, string];
if (args.includes('--no-rebind') && device.has(local)) {
return { exitCode: 1, stdout: '', stderr: 'adb: error: cannot rebind existing socket' };
}
device.set(local, remote);
return ok();
},
{ noRebind: true },
);

await manager.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'metro' });
await expect(
manager.ensure({ local: 'tcp:8081', remote: 'tcp:9090', ownerId: 'metro' }),
).rejects.toMatchObject({
details: {
reason: 'android_port_reverse_rebind_refused',
existing: { local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'metro' },
},
});

expect(device.get('tcp:8081')).toBe('tcp:8081');
expect(calls.filter((args) => args[1] !== '--list')).toEqual([
['reverse', '--no-rebind', 'tcp:8081', 'tcp:8081'],
['reverse', '--no-rebind', 'tcp:8081', 'tcp:9090'],
]);
});

test('concurrent ensures of one endpoint reach the device once', async () => {
bindAndroidAdbHostStub();
const binds: (readonly string[])[] = [];
const device = new Set<string>();
const manager = createAndroidPortReverseManager(
async (args) => {
if (args[1] === '--list')
return ok([...device].map((local) => `host-1 ${local} ${local}\n`).join(''));
binds.push(args);
const local = args.at(-2) as string;
if (device.has(local)) {
return { exitCode: 1, stdout: '', stderr: 'adb: error: cannot rebind existing socket' };
}
device.add(local);
await new Promise((resolve) => setTimeout(resolve, 5));
return ok();
},
{ noRebind: true },
);
const mapping = { local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'metro' } as const;

await Promise.all([manager.ensure(mapping), manager.ensure(mapping)]);

expect(binds).toEqual([['reverse', '--no-rebind', 'tcp:8081', 'tcp:8081']]);
});

test('no-rebind reports an adb failure when the device lists no mapping for the endpoint', async () => {
bindAndroidAdbHostStub();
const manager = createAndroidPortReverseManager(
async (args) =>
args.includes('--no-rebind')
? { exitCode: 1, stdout: '', stderr: 'error: device offline' }
: ok(),
{ noRebind: true },
);

const failure = await manager
.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'metro' })
.then(
() => undefined,
(error: unknown) => error,
);

expect(failure).toMatchObject({
code: 'COMMAND_FAILED',
details: { adbFailure: 'device_offline' },
});
expect(failure).not.toMatchObject({ details: { reason: 'android_port_reverse_rebind_refused' } });
});
Loading
Loading