Skip to content

Commit 6f181c4

Browse files
committed
feat(limrun): screen recording through the provider's server-side recorder
The Limrun runtime declared screen recording unavailable although the SDK exposes startRecording and stopRecording on both instance clients. Bind a recording runtime that asks the live session to record, maps the two export presets onto Limrun's 5..10 quality scale, downloads the finished MP4 to the output path on stop, and refuses --fps and --hide-touches. Reattach and cleanup after a daemon restart stay unavailable: the recording lives and dies with the instance.
1 parent 6dd57d9 commit 6f181c4

8 files changed

Lines changed: 411 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
- Added (limrun): `record start` and `record stop` on Limrun iOS and Android direct sessions. The
6+
runtime declared recording unavailable although the Limrun SDK exposes a server-side recorder;
7+
start now asks the instance to record (`--quality medium` maps to Limrun quality 5, `high` to 8)
8+
and stop downloads the finished MP4 to the output path. The capture is always the whole simulator
9+
or emulator screen; `--fps` and `--hide-touches` are refused, and a recording cannot be reattached
10+
after a daemon restart.
511
- Added (limrun): `longpress` on Limrun iOS direct sessions. The interactor refused it as
612
unsupported although the SDK exposes the HID primitives; it now holds one touch as a
713
`performActions` batch of `touchDown`, `wait`, `touchUp`, defaulting to the 800 ms the Android

‎packages/provider-limrun/src/app-log-runtime.fixtures.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function limrunOwnerOptions(
3232
ownsDevice: () => true,
3333
hasLiveSession: () => true,
3434
getInteractor: () => undefined,
35+
getDeviceSession: () => undefined,
3536
openCurrent: async () => undefined,
3637
reconnect: async () => ({ status: 'missing' }),
3738
listApps: async () => [],

‎packages/provider-limrun/src/app-log-runtime.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import {
3737
type LimrunAppDeploymentRuntimeOptions,
3838
} from './deployment-runtime.ts';
3939
import { createLimrunRequestOperationDrain } from './request-cancellation.ts';
40+
import {
41+
createLimrunScreenRecordingOperations,
42+
type LimrunScreenRecordingSession,
43+
} from './recording-runtime.ts';
4044
import {
4145
deploymentOptions,
4246
limrunAppLogFacts,
@@ -59,6 +63,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit<
5963
runtimeInstance: string;
6064
ownsDevice(device: DeviceInfo): boolean;
6165
getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined;
66+
getDeviceSession(device: DeviceInfo): LimrunScreenRecordingSession | undefined;
6267
resolveAppReference?(device: DeviceInfo, app: string): string;
6368
openCurrent(device: DeviceInfo): Promise<LimrunAppLogReader | undefined>;
6469
hasLiveSession(device: DeviceInfo): boolean;
@@ -301,6 +306,13 @@ function bindLimrunAppLogs(
301306
signal,
302307
deploymentOperationDrain,
303308
),
309+
...createLimrunScreenRecordingOperations({
310+
host: options.host,
311+
device,
312+
owner,
313+
signal,
314+
getDeviceSession: options.getDeviceSession,
315+
}),
304316
} satisfies DeviceBinding<PlatformRuntimeOperations>['operations'];
305317
return Object.freeze({
306318
device,

‎packages/provider-limrun/src/facts-runtime.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,15 @@ test('limrunLifecycleFacts gates port reverse to a live Android session', () =>
7474
hint: 'Limrun port reverse requires an active Android Limrun session.',
7575
});
7676
});
77+
78+
test('screen recording rides the provider recorder on a live session and closes with it', () => {
79+
const live = limrunAppLogFacts(limrunOwnerOptions(), device).operations;
80+
expect(live.screenRecordingStart).toEqual({ available: true });
81+
expect(live.screenRecordingReattach).toEqual({ available: true });
82+
expect(live.screenRecordingCleanup).toEqual({ available: true });
83+
84+
const recovery = limrunAppLogRecoveryFacts(limrunOwnerOptions(), device).operations;
85+
expect(recovery.screenRecordingStart).toEqual(liveSessionUnavailable);
86+
expect(recovery.screenRecordingReattach).toEqual(liveSessionUnavailable);
87+
expect(recovery.screenRecordingCleanup).toEqual(liveSessionUnavailable);
88+
});

‎packages/provider-limrun/src/facts-runtime.ts‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,6 @@ const elementTextUnavailable = Object.freeze({
4747
reason: 'unsupported-provider-mode',
4848
hint: 'Limrun-owned devices read element text from the captured tree only.',
4949
} as const);
50-
const recordingUnavailable = Object.freeze({
51-
available: false,
52-
reason: 'unsupported-provider-mode',
53-
hint: 'Limrun does not expose an exact-owner screen-recording runtime.',
54-
} as const);
5550
const headlessUnavailable = Object.freeze({
5651
available: false,
5752
reason: 'unsupported-provider-mode',
@@ -176,9 +171,9 @@ export function limrunAppLogFacts(
176171
...deployment,
177172
appState: isAndroid ? available : iosAppStateUnavailable,
178173
networkDump: available,
179-
screenRecordingStart: recordingUnavailable,
180-
screenRecordingReattach: recordingUnavailable,
181-
screenRecordingCleanup: recordingUnavailable,
174+
screenRecordingStart: available,
175+
screenRecordingReattach: available,
176+
screenRecordingCleanup: available,
182177
...snapshotRuntimeOperationFacts({
183178
capture: available,
184179
customActions: customSnapshotFact,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { expect, test, vi } from 'vitest';
2+
import type { ScreenRecordingStartInput } from '@agent-device/contracts/screen-recording-runtime';
3+
import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime';
4+
import { createLimrunScreenRecordingOperations } from './recording-runtime.ts';
5+
import { limrunIosSimulator } from './app-log-runtime.fixtures.ts';
6+
7+
const owner = providerRuntimeOwner('limrun', 'default');
8+
9+
function recorder(options: { finalize?: () => Promise<Record<string, never>> } = {}) {
10+
const calls: string[] = [];
11+
const session = {
12+
startRecording: vi.fn(async (start?: { quality?: number }) => {
13+
calls.push(`start:${start?.quality ?? 'default'}`);
14+
}),
15+
stopRecording: vi.fn(async ({ outPath }: { outPath: string }) => {
16+
calls.push(`stop:${outPath}`);
17+
return 'https://limrun.example/recording.mp4';
18+
}),
19+
};
20+
const host = {
21+
screenRecording: {
22+
outputs: {
23+
prepare: vi.fn(async () => {
24+
calls.push('prepare');
25+
}),
26+
copy: vi.fn(async () => {}),
27+
remove: vi.fn(async () => {
28+
calls.push('remove');
29+
return 'removed' as const;
30+
}),
31+
},
32+
finalize: {
33+
sniff: vi.fn(async () => {}),
34+
complete: vi.fn(async () => {
35+
calls.push('finalize');
36+
return await (options.finalize ?? (async () => ({})))();
37+
}),
38+
},
39+
},
40+
};
41+
const operations = createLimrunScreenRecordingOperations({
42+
host,
43+
device: limrunIosSimulator,
44+
owner,
45+
signal: new AbortController().signal,
46+
getDeviceSession: () => session,
47+
});
48+
return { operations, session, host, calls };
49+
}
50+
51+
function input(overrides: Partial<ScreenRecordingStartInput> = {}): ScreenRecordingStartInput {
52+
return {
53+
sessionId: 'lp-verify',
54+
outputPath: '/tmp/limrun-recording.mp4',
55+
scope: 'app',
56+
showTouches: false,
57+
hideTouchesRequested: false,
58+
recordOnlySession: false,
59+
fence: { token: 'fence', generation: 1 },
60+
...overrides,
61+
};
62+
}
63+
64+
test('start prepares the output, then asks the instance to record at the medium preset', async () => {
65+
const { operations, calls } = recorder();
66+
67+
const started = await operations.screenRecordingStart(input());
68+
69+
expect(calls).toEqual(['prepare', 'start:5']);
70+
expect(started.envelope.descriptor.body).toMatchObject({
71+
backend: 'limrun-recorder',
72+
outputPath: '/tmp/limrun-recording.mp4',
73+
});
74+
expect(started.pendingHandle.transfer().inspect()).toMatchObject({
75+
backend: 'limrun-recorder',
76+
outPath: '/tmp/limrun-recording.mp4',
77+
scope: 'app',
78+
showTouches: false,
79+
});
80+
});
81+
82+
test('the high preset maps onto the low end of the legacy high band', async () => {
83+
const { operations, session } = recorder();
84+
85+
await operations.screenRecordingStart(input({ exportQuality: 'high' }));
86+
87+
expect(session.startRecording).toHaveBeenCalledWith({ quality: 8 });
88+
});
89+
90+
test('stop downloads the MP4 to the output path, finalizes it, and confirms the recorder', async () => {
91+
const { operations, calls } = recorder();
92+
const started = await operations.screenRecordingStart(input());
93+
94+
const outcome = await started.pendingHandle.transfer().finish();
95+
96+
expect(calls).toEqual(['prepare', 'start:5', 'stop:/tmp/limrun-recording.mp4', 'finalize']);
97+
expect(outcome).toMatchObject({
98+
status: 'completed',
99+
result: {
100+
backend: 'limrun-recorder',
101+
outPath: '/tmp/limrun-recording.mp4',
102+
stopObservation: { recorder: 'confirmed' },
103+
},
104+
});
105+
});
106+
107+
test.each([
108+
[{ fps: 30 }, 'Limrun recordings do not support --fps'],
109+
[{ hideTouchesRequested: true }, 'Limrun recordings do not support --hide-touches'],
110+
])(
111+
'refuses option %# before the output is touched or the instance records',
112+
async (override, message) => {
113+
const { operations, session, host } = recorder();
114+
115+
await expect(operations.screenRecordingStart(input(override))).rejects.toThrow(message);
116+
117+
expect(host.screenRecording.outputs.prepare).not.toHaveBeenCalled();
118+
expect(session.startRecording).not.toHaveBeenCalled();
119+
},
120+
);
121+
122+
test('a device without a live provider session is refused before the output is prepared', async () => {
123+
const prepare = vi.fn(async () => {});
124+
const operations = createLimrunScreenRecordingOperations({
125+
host: {
126+
screenRecording: {
127+
outputs: { prepare, copy: async () => {}, remove: async () => 'removed' as const },
128+
finalize: { sniff: async () => {}, complete: async () => ({}) },
129+
},
130+
},
131+
device: limrunIosSimulator,
132+
owner,
133+
signal: new AbortController().signal,
134+
getDeviceSession: () => undefined,
135+
});
136+
137+
await expect(operations.screenRecordingStart(input())).rejects.toMatchObject({
138+
code: 'UNSUPPORTED_OPERATION',
139+
});
140+
expect(prepare).not.toHaveBeenCalled();
141+
});
142+
143+
test('a finalizer failure is not a completion, and cleanup stops the instance recorder once', async () => {
144+
const { operations, session, calls } = recorder({
145+
finalize: async () => {
146+
throw new Error('export is not a playable MP4');
147+
},
148+
});
149+
const started = await operations.screenRecordingStart(input());
150+
const handle = started.pendingHandle.transfer();
151+
152+
await expect(handle.finish()).rejects.toThrow('export is not a playable MP4');
153+
await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' });
154+
155+
expect(session.stopRecording).toHaveBeenCalledOnce();
156+
expect(calls.at(-1)).toBe('remove');
157+
});
158+
159+
test('recordings are neither reattachable nor cleanable after a daemon restart', async () => {
160+
const { operations } = recorder();
161+
const envelope = (await operations.screenRecordingStart(input())).envelope;
162+
163+
await expect(operations.screenRecordingReattach({ envelope })).resolves.toMatchObject({
164+
status: 'unreattachable',
165+
reason: 'transport-not-reattachable',
166+
});
167+
await expect(operations.screenRecordingCleanup({ envelope })).resolves.toMatchObject({
168+
status: 'cleanup-pending',
169+
reason: 'manual-recovery-required',
170+
});
171+
});

0 commit comments

Comments
 (0)