Skip to content

Commit fa0b33c

Browse files
author
Ark
committed
fix(remote): preserve HarmonyOS runtime and proxy device identity
Signed-off-by: Ark <[email protected]>
1 parent 4c3ad09 commit fa0b33c

14 files changed

Lines changed: 250 additions & 51 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expect, test } from 'vitest';
2+
import type { SessionAction } from '@agent-device/contracts/session';
3+
import { formatPortableActionLine } from '../script-formatting.ts';
4+
import { parseReplayScriptDetailed } from '../script.ts';
5+
6+
test.each(['ios', 'android', 'harmonyos'] as const)(
7+
'%s runtime survives open and runtime set script roundtrips',
8+
(platform) => {
9+
const runtime = { platform, metroHost: 'localhost', metroPort: 8081 };
10+
const actions: SessionAction[] = [
11+
{ ts: 0, command: 'runtime', positionals: ['set'], flags: runtime },
12+
{ ts: 1, command: 'open', positionals: ['Demo'], flags: {}, runtime },
13+
];
14+
const script = actions.map((action) => formatPortableActionLine(action)).join('\n');
15+
const parsed = parseReplayScriptDetailed(script);
16+
expect(parsed.actions[0]?.flags).toMatchObject(runtime);
17+
expect(parsed.actions[1]?.runtime).toMatchObject(runtime);
18+
},
19+
);

‎packages/ad-script/src/internal/script-utils.ts‎

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SessionAction } from '@agent-device/contracts/session';
2+
import { isSessionRuntimePlatform, type SessionRuntimeHints } from '@agent-device/kernel/contracts';
23
import { appendScreenshotScriptFlags } from '@agent-device/contracts/capture';
34
import { splitRefGenerationSuffix } from '@agent-device/kernel/snapshot';
45

@@ -164,19 +165,10 @@ export function appendScriptSeriesFlags(
164165

165166
export function appendRuntimeHintFlags(
166167
parts: string[],
167-
flags:
168-
| Pick<SessionAction, 'flags'>['flags']
169-
| {
170-
platform?: 'ios' | 'android';
171-
metroHost?: string;
172-
metroPort?: number;
173-
bundleUrl?: string;
174-
launchUrl?: string;
175-
}
176-
| undefined,
168+
flags: Pick<SessionAction, 'flags'>['flags'] | SessionRuntimeHints | undefined,
177169
): void {
178170
if (!flags) return;
179-
if (flags.platform === 'ios' || flags.platform === 'android') {
171+
if (isSessionRuntimePlatform(flags.platform)) {
180172
parts.push('--platform', flags.platform);
181173
}
182174
if (typeof flags.metroHost === 'string' && flags.metroHost.length > 0) {
@@ -323,29 +315,17 @@ export function parseReplaySeriesFlags(
323315
// fallow-ignore-next-line complexity
324316
export function parseReplayRuntimeFlags(args: string[]): {
325317
positionals: string[];
326-
flags: {
327-
platform?: 'ios' | 'android';
328-
metroHost?: string;
329-
metroPort?: number;
330-
bundleUrl?: string;
331-
launchUrl?: string;
332-
};
318+
flags: SessionRuntimeHints;
333319
} {
334320
const positionals: string[] = [];
335-
const flags: {
336-
platform?: 'ios' | 'android';
337-
metroHost?: string;
338-
metroPort?: number;
339-
bundleUrl?: string;
340-
launchUrl?: string;
341-
} = {};
321+
const flags: SessionRuntimeHints = {};
342322

343323
for (let index = 0; index < args.length; index += 1) {
344324
const token = args[index]!;
345325
const nextArg = args[index + 1];
346326
if (token === '--platform' && nextArg !== undefined) {
347327
const platform = nextArg;
348-
if (platform === 'ios' || platform === 'android') {
328+
if (isSessionRuntimePlatform(platform)) {
349329
flags.platform = platform;
350330
}
351331
index += 1;

‎packages/kernel/src/contracts.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@ export type { AppErrorCode } from './errors.ts';
44
export { defaultHintForCode, normalizeError } from './errors.ts';
55
import type { PlatformSelector } from './device.ts';
66

7+
const SESSION_RUNTIME_PLATFORMS = ['ios', 'android', 'harmonyos'] as const;
8+
export type SessionRuntimePlatform = (typeof SESSION_RUNTIME_PLATFORMS)[number];
9+
10+
export function isSessionRuntimePlatform(value: unknown): value is SessionRuntimePlatform {
11+
return SESSION_RUNTIME_PLATFORMS.some((platform) => platform === value);
12+
}
13+
714
export type SessionRuntimeHints = {
8-
platform?: 'ios' | 'android' | 'harmonyos';
15+
platform?: SessionRuntimePlatform;
916
metroHost?: string;
1017
metroPort?: number;
1118
bundleUrl?: string;
@@ -44,7 +51,12 @@ export type LocalInstallSource = Extract<DaemonInstallSource, { kind: 'url' | 'p
4451

4552
const DAEMON_LOCK_POLICIES = ['reject', 'strip'] as const;
4653
export type DaemonLockPolicy = (typeof DAEMON_LOCK_POLICIES)[number];
47-
const LEASE_BACKENDS = ['ios-simulator', 'ios-instance', 'android-instance', 'harmonyos-instance'] as const;
54+
const LEASE_BACKENDS = [
55+
'ios-simulator',
56+
'ios-instance',
57+
'android-instance',
58+
'harmonyos-instance',
59+
] as const;
4860
export type LeaseBackend = (typeof LEASE_BACKENDS)[number];
4961
const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const;
5062
export type DaemonServerMode = (typeof DAEMON_SERVER_MODES)[number];
@@ -272,7 +284,7 @@ function optionalEnum<T extends string>(
272284
export const daemonRuntimeSchema = schema<SessionRuntimeHints>((input, path) => {
273285
const record = expectObject(input, path);
274286
return {
275-
platform: optionalEnum(record, 'platform', ['ios', 'android', 'harmonyos'] as const, path),
287+
platform: optionalEnum(record, 'platform', SESSION_RUNTIME_PLATFORMS, path),
276288
metroHost: optionalString(record, 'metroHost', path),
277289
metroPort: optionalInteger(record, 'metroPort', path),
278290
bundleUrl: optionalString(record, 'bundleUrl', path),
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { expect, test } from 'vitest';
2+
import fs from 'node:fs';
3+
import {
4+
connectionWorkspace,
5+
createTestClient,
6+
seedConnectionState,
7+
} from './remote-connection.fixtures.ts';
8+
import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts';
9+
import { disconnectCommand } from '../cli/commands/connection.ts';
10+
import { readRemoteConnectionState } from '../remote/remote-connection-state.ts';
11+
import { LeaseRegistry } from '../daemon/lease-registry.ts';
12+
13+
test.each(['apple', 'harmonyos', 'ios', 'android'] as const)(
14+
'stored Harmony runtime compatibility respects %s selection',
15+
async (platform) => {
16+
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-');
17+
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
18+
seedConnectionState({
19+
stateDir,
20+
state: {
21+
session: 'runtime-compat',
22+
remoteConfigPath,
23+
daemon: { baseUrl: 'https://daemon.example' },
24+
tenant: 'proxy',
25+
runId: 'compat-run',
26+
leaseId: 'compat-existing',
27+
leaseBackend: 'harmonyos-instance',
28+
runtime: { platform: 'harmonyos', launchUrl: 'demo://open' },
29+
},
30+
});
31+
const materialized = await materializeRemoteConnectionForCommand({
32+
command: 'snapshot',
33+
client: createTestClient(),
34+
flags: {
35+
json: true,
36+
help: false,
37+
version: false,
38+
stateDir,
39+
remoteConfig: remoteConfigPath,
40+
session: 'runtime-compat',
41+
platform,
42+
},
43+
});
44+
if (platform === 'apple' || platform === 'harmonyos') {
45+
expect(materialized.runtime?.platform).toBe('harmonyos');
46+
} else {
47+
expect(materialized.runtime).toBeUndefined();
48+
}
49+
},
50+
);
51+
52+
test('proxy HarmonyOS inventory materializes a scoped lease and closes that same lease', async () => {
53+
const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-proxy-');
54+
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
55+
seedConnectionState({
56+
stateDir,
57+
state: {
58+
session: 'harmony-proxy',
59+
remoteConfigPath,
60+
daemon: { baseUrl: 'https://daemon.example' },
61+
tenant: 'proxy',
62+
runId: 'harmony-run',
63+
leaseProvider: 'proxy',
64+
clientId: 'harmony-client',
65+
},
66+
});
67+
const registry = new LeaseRegistry();
68+
const allocated: string[] = [];
69+
const released: string[] = [];
70+
const client = createTestClient({
71+
listDevices: async () => [
72+
{
73+
platform: 'harmonyos',
74+
target: 'mobile',
75+
kind: 'emulator',
76+
id: '127.0.0.1:16001',
77+
name: 'Harmony Emulator',
78+
booted: true,
79+
identifiers: { serial: '127.0.0.1:16001' },
80+
harmonyos: { serial: '127.0.0.1:16001' },
81+
},
82+
],
83+
allocate: async (request) => {
84+
expect(request.leaseBackend).toBe('harmonyos-instance');
85+
expect(request.deviceKey).toBe('harmonyos:mobile:127.0.0.1:16001');
86+
const lease = registry.allocateLease({ ...request, tenantId: request.tenant });
87+
allocated.push(lease.leaseId);
88+
return lease;
89+
},
90+
heartbeat: async (request) => registry.heartbeatLease({ ...request, tenantId: request.tenant }),
91+
release: async (request) => {
92+
const result = registry.releaseLease({ ...request, tenantId: request.tenant });
93+
expect(result.released).toBe(true);
94+
released.push(request.leaseId);
95+
return result;
96+
},
97+
});
98+
const flags = {
99+
json: true,
100+
help: false,
101+
version: false,
102+
stateDir,
103+
remoteConfig: remoteConfigPath,
104+
session: 'harmony-proxy',
105+
};
106+
const materialized = await materializeRemoteConnectionForCommand({
107+
command: 'open',
108+
flags,
109+
client,
110+
});
111+
expect(materialized.flags.platform).toBe('harmonyos');
112+
expect(materialized.flags.serial).toBe('127.0.0.1:16001');
113+
expect(materialized.flags.leaseBackend).toBe('harmonyos-instance');
114+
expect(materialized.flags.leaseId).toBe(allocated[0]);
115+
expect(readRemoteConnectionState({ stateDir, session: flags.session })?.leaseId).toBe(
116+
allocated[0],
117+
);
118+
expect(() =>
119+
registry.assertLeaseAdmission({
120+
leaseId: allocated[0],
121+
tenantId: 'proxy',
122+
runId: 'harmony-run',
123+
leaseBackend: 'android-instance',
124+
}),
125+
).toThrow();
126+
await disconnectCommand({ positionals: [], flags, client });
127+
expect(released).toEqual(allocated);
128+
expect(readRemoteConnectionState({ stateDir, session: flags.session })).toBeNull();
129+
});

‎src/__tests__/remote-connection.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ import type { AgentDeviceClient } from '../agent-device-client.ts';
5050
test('HarmonyOS platform resolves to its proxy lease backend', () => {
5151
assert.equal(
5252
resolveRequestedLeaseBackend(
53-
forceConnectFlags({ stateDir: '/tmp/agent-device', remoteConfig: '/tmp/remote.json', platform: 'harmonyos' }),
53+
forceConnectFlags({
54+
stateDir: '/tmp/agent-device',
55+
remoteConfig: '/tmp/remote.json',
56+
platform: 'harmonyos',
57+
}),
5458
),
5559
'harmonyos-instance',
5660
);

‎src/cli/commands/connection-runtime.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
import { profileToCliFlags } from '../remote-config-flags.ts';
2929
import type { BatchStep } from '@agent-device/contracts/client';
3030
import { AppError } from '@agent-device/kernel/errors';
31-
import type { LeaseBackend, SessionRuntimeHints } from '@agent-device/kernel/contracts';
31+
import {
32+
isSessionRuntimePlatform,
33+
type LeaseBackend,
34+
type SessionRuntimeHints,
35+
} from '@agent-device/kernel/contracts';
3236
import type { CliFlags } from '@agent-device/contracts/command';
3337
import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts';
3438
import type { CloudProviderSessionResult } from '@agent-device/contracts/observability';
@@ -734,11 +738,7 @@ function isRuntimeCompatibleWithPlatform(
734738
runtime: SessionRuntimeHints,
735739
platform: CliFlags['platform'],
736740
): boolean {
737-
if (
738-
!runtime.platform ||
739-
!platform ||
740-
(platform !== 'ios' && platform !== 'android' && platform !== 'harmonyos')
741-
) {
741+
if (!runtime.platform || !platform || !isSessionRuntimePlatform(platform)) {
742742
return true;
743743
}
744744
return runtime.platform === platform;
@@ -892,7 +892,7 @@ function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void
892892
flags.udid = device.id;
893893
return;
894894
}
895-
if (device.platform === 'android') {
895+
if (device.platform === 'android' || device.platform === 'harmonyos') {
896896
flags.serial = device.id;
897897
}
898898
}

‎src/client/client-normalizers.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import assert from 'node:assert/strict';
22
import { expect, test } from 'vitest';
3-
import { normalizeDevice, normalizeOpenForegroundComposition } from './client-normalizers.ts';
3+
import {
4+
normalizeDevice,
5+
normalizeOpenForegroundComposition,
6+
normalizeRuntimeHints,
7+
} from './client-normalizers.ts';
8+
9+
test.each(['ios', 'android', 'harmonyos'])('runtime response preserves %s platform', (platform) => {
10+
expect(normalizeRuntimeHints({ platform, launchUrl: 'demo://open' })).toMatchObject({
11+
platform,
12+
launchUrl: 'demo://open',
13+
});
14+
});
15+
16+
test.each(['apple', 'unknown', 12])(
17+
'runtime response ignores non-runtime platform %s',
18+
(platform) => {
19+
expect(normalizeRuntimeHints({ platform })?.platform).toBeUndefined();
20+
},
21+
);
422

523
test('embedded daemon errors sanitize an untrusted cause before client exposure', () => {
624
const secret = 'adc_live_remote-secret';

‎src/client/client-normalizers.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
isSerialAddressablePlatform,
1818
type AppleOS,
1919
} from '@agent-device/kernel/device';
20-
import type { SessionRuntimeHints } from '@agent-device/kernel/contracts';
20+
import { isSessionRuntimePlatform, type SessionRuntimeHints } from '@agent-device/kernel/contracts';
2121
import { AppError, type DaemonError } from '@agent-device/kernel/errors';
2222
import { sanitizeErrorCause } from '@agent-device/kernel/redaction';
2323
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
@@ -200,7 +200,7 @@ export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | und
200200
const bundleUrl = readOptionalString(value, 'bundleUrl');
201201
const launchUrl = readOptionalString(value, 'launchUrl');
202202
return {
203-
platform: platform === 'ios' || platform === 'android' ? platform : undefined,
203+
platform: isSessionRuntimePlatform(platform) ? platform : undefined,
204204
metroHost,
205205
metroPort,
206206
bundleUrl,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect, test } from 'vitest';
2+
import { daemonRuntimeSchema } from '@agent-device/kernel/contracts';
3+
import { resolveEffectiveOpenRuntimeHints, toRuntimePlatform } from '../session-runtime.ts';
4+
5+
test.each(['ios', 'android', 'harmonyos'] as const)(
6+
'%s runtime survives wire admission and open normalization',
7+
(platform) => {
8+
const runtime = daemonRuntimeSchema.parse({ platform, launchUrl: 'demo://open' });
9+
expect(toRuntimePlatform(platform)).toBe(platform);
10+
const normalized = resolveEffectiveOpenRuntimeHints({
11+
req: { token: 'test', session: 'runtime-test', command: 'open', positionals: [], runtime },
12+
sessionName: 'runtime-test',
13+
platform,
14+
sessionStore: { getRuntimeHints: () => undefined },
15+
});
16+
expect(normalized).toMatchObject(runtime);
17+
expect(() =>
18+
resolveEffectiveOpenRuntimeHints({
19+
req: { token: 'test', session: 'runtime-test', command: 'open', positionals: [], runtime },
20+
sessionName: 'runtime-test',
21+
platform: platform === 'ios' ? 'android' : 'ios',
22+
sessionStore: { getRuntimeHints: () => undefined },
23+
}),
24+
).toThrow();
25+
},
26+
);
27+
28+
test('non-leaf apple selector is not a runtime platform', () => {
29+
expect(toRuntimePlatform('apple')).toBeUndefined();
30+
expect(() => daemonRuntimeSchema.parse({ platform: 'apple' })).toThrow();
31+
});

‎src/daemon/handlers/session-runtime-command.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ function setRuntimeCommand(params: {
178178
if (!platform) {
179179
return errorResponse(
180180
'INVALID_ARGS',
181-
'runtime set only supports iOS and Android sessions. Pass --platform ios|android or open an iOS/Android session first.',
181+
'runtime set only supports iOS, Android, and HarmonyOS sessions. Pass --platform ios|android|harmonyos or open a supported session first.',
182182
);
183183
}
184184
if (sessionLeaf !== undefined && sessionLeaf !== platform) {

0 commit comments

Comments
 (0)