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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@
"@agent-device/xml": "workspace:*",
"@arethetypeswrong/cli": "^0.18.5",
"@chenglou/freerange": "^0.0.4",
"@limrun/api": "^0.64.1",
"@limrun/api": "^0.68.1",
"@nkzw/eslint-plugin": "^2.0.0",
"@nkzw/oxlint-config": "^1.3.0",
"@statelyai/graph": "2.4.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-limrun/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
}
},
"devDependencies": {
"@limrun/api": "^0.64.1"
"@limrun/api": "^0.68.1"
}
}
36 changes: 21 additions & 15 deletions packages/provider-limrun/src/device-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ const TEST_DEPENDENCIES = {
test('iOS device session exposes reusable capabilities without its raw client', async () => {
const pressKey = vi.fn(async () => undefined);
const readLogs = vi.fn(async () => 'line one\nline two\n');
const startRecording = vi.fn(async () => undefined);
const stopRecording = vi.fn(async () => 'https://ios.example/recording');
const startRecordingSession = vi.fn(async () => 'recording-id');
const stopRecordingSession = vi.fn(async () => 'https://ios.example/recording');
const simctlWait = vi.fn(async () => ({ code: 0, stdout: 'ok', stderr: '' }));
const simctl = vi.fn(() => commandExecution(simctlWait));
const session = createLimrunDeviceSession(
Expand All @@ -83,8 +83,8 @@ test('iOS device session exposes reusable capabilities without its raw client',
listApps: vi.fn(async () => IOS_APPS),
pressKey,
appLogTail: readLogs,
startRecording,
stopRecording,
startRecordingSession,
stopRecordingSession,
simctl,
}),
);
Expand All @@ -103,8 +103,10 @@ test('iOS device session exposes reusable capabilities without its raw client',
assert.deepEqual(session.viewport, { width: 402, height: 874 });
await session.pressKey('enter', ['command']);
assert.equal(await session.readLogs('com.example.ios', 200), 'line one\nline two\n');
await session.startRecording({ quality: 8 });
assert.deepEqual(await session.stopRecording(), { downloadUrl: 'https://ios.example/recording' });
await session.startRecording({ recordingId: 'recording-id', quality: 8 });
assert.deepEqual(await session.stopRecording('recording-id'), {
downloadUrl: 'https://ios.example/recording',
});
await session.downloadRecording({
downloadUrl: 'https://ios.example/recording',
outPath: '/tmp/ios-recording.mp4',
Expand All @@ -117,8 +119,10 @@ test('iOS device session exposes reusable capabilities without its raw client',

assert.deepEqual(pressKey.mock.calls[0], ['enter', ['command']]);
assert.deepEqual(readLogs.mock.calls[0], ['com.example.ios', 200]);
assert.deepEqual(startRecording.mock.calls[0], [{ quality: 8 }]);
assert.deepEqual(stopRecording.mock.calls[0], [{}]);
assert.deepEqual(startRecordingSession.mock.calls[0], [
{ recordingId: 'recording-id', quality: 8 },
]);
assert.deepEqual(stopRecordingSession.mock.calls[0], ['recording-id']);
assert.deepEqual(downloadFile.mock.calls.at(-1), [
{
url: 'https://ios.example/recording',
Expand Down Expand Up @@ -175,11 +179,11 @@ test('Android device session exposes semantic ADB capabilities without its raw c
},
};
const pressKey = vi.fn(async () => undefined);
const startRecording = vi.fn(async () => undefined);
const stopRecording = vi.fn(async () => 'https://android.example/recording');
const startRecordingSession = vi.fn(async () => 'recording-id');
const stopRecordingSession = vi.fn(async () => 'https://android.example/recording');
const sendAsset = vi.fn(async () => undefined);
const session = createLimrunDeviceSession(
androidSession(provider, { pressKey, startRecording, stopRecording, sendAsset }),
androidSession(provider, { pressKey, startRecordingSession, stopRecordingSession, sendAsset }),
);

assert.equal(session.platform, 'android');
Expand All @@ -195,8 +199,8 @@ test('Android device session exposes semantic ADB capabilities without its raw c
assert.equal(await session.readLogs(20), 'log line\n');
assert.equal(await session.installRemoteApp('https://assets.example/android.apk'), undefined);
await session.pressKey('KEYCODE_ENTER', ['shift']);
await session.startRecording({ quality: 7 });
assert.deepEqual(await session.stopRecording(), {
await session.startRecording({ recordingId: 'recording-id', quality: 7 });
assert.deepEqual(await session.stopRecording('recording-id'), {
downloadUrl: 'https://android.example/recording',
});
await session.downloadRecording({
Expand All @@ -207,8 +211,10 @@ test('Android device session exposes semantic ADB capabilities without its raw c

assert.deepEqual(sendAsset.mock.calls[0], ['https://assets.example/android.apk']);
assert.deepEqual(pressKey.mock.calls[0], ['KEYCODE_ENTER', ['shift']]);
assert.deepEqual(startRecording.mock.calls[0], [{ quality: 7 }]);
assert.deepEqual(stopRecording.mock.calls[0], [{}]);
assert.deepEqual(startRecordingSession.mock.calls[0], [
{ recordingId: 'recording-id', quality: 7 },
]);
assert.deepEqual(stopRecordingSession.mock.calls[0], ['recording-id']);
assert.deepEqual(downloadFile.mock.calls.at(-1), [
{
url: 'https://android.example/recording',
Expand Down
24 changes: 14 additions & 10 deletions packages/provider-limrun/src/device-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,19 @@ type LimrunDeviceSessionBase = {
readonly interactor: Interactor;
listApps(filter?: AppsFilter): Promise<LimrunInstalledApp[]>;
pressKey(key: string, modifiers?: string[]): Promise<void>;
startRecording(options?: { quality?: LimrunRecordingQuality }): Promise<void>;
/** Stops the instance recorder and answers where the finished file is served; nothing is downloaded. */
stopRecording(): Promise<{ downloadUrl: string }>;
startRecording(options: { recordingId: string; quality?: LimrunRecordingQuality }): Promise<void>;
/** Stops the identified recorder and answers where the finished file is served; nothing is downloaded. */
stopRecording(recordingId: string): Promise<{ downloadUrl: string }>;
/** Fetches a served recording to `outPath` within a fixed deadline. Retriable while the instance lives. */
downloadRecording(input: { downloadUrl: string; outPath: string }): Promise<void>;
};

type LimrunRecordingClient = {
startRecording(options?: { quality?: LimrunRecordingQuality }): Promise<void>;
stopRecording(saveTo: { localPath?: string }): Promise<string>;
startRecordingSession(options: {
recordingId: string;
quality?: LimrunRecordingQuality;
}): Promise<string>;
stopRecordingSession(recordingId: string): Promise<string>;
};

/**
Expand Down Expand Up @@ -170,12 +173,13 @@ function createRecordingOperations(
host: Pick<LimrunHostAdapter, 'downloadFile'>,
) {
return {
startRecording: async (options?: { quality?: LimrunRecordingQuality }) => {
await client.startRecording(options);
startRecording: async (options: { recordingId: string; quality?: LimrunRecordingQuality }) => {
await client.startRecordingSession(options);
},
// Without `localPath` the SDK only stops the recorder and returns the served URL; a download
// tied to the one-shot stop could not be retried after a dropped transfer.
stopRecording: async () => ({ downloadUrl: await client.stopRecording({}) }),
// Keep downloads separate from stopping so a dropped transfer can be retried.
stopRecording: async (recordingId: string) => ({
downloadUrl: await client.stopRecordingSession(recordingId),
}),
downloadRecording: async (input: { downloadUrl: string; outPath: string }) => {
await host.downloadFile({
url: input.downloadUrl,
Expand Down
40 changes: 39 additions & 1 deletion packages/provider-limrun/src/recording-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ test('the high preset maps onto the low end of the legacy high band', async () =

await operations.screenRecordingStart(input({ exportQuality: 'high' }));

expect(session.startRecording).toHaveBeenCalledWith({ quality: 8 });
expect(session.startRecording).toHaveBeenCalledWith({
recordingId: expect.any(String),
quality: 8,
});
});

test('stop asks the instance once, downloads the served MP4 to the output path, finalizes, and confirms the recorder', async () => {
Expand Down Expand Up @@ -258,3 +261,38 @@ test('the bound owner serves record start and stop through the live device sessi
'finalize',
]);
});

test('finishing or cleaning one recording leaves other recordings on the instance active', async () => {
const active = new Set(['external-recording']);
const calls: string[] = [];
const session: LimrunScreenRecordingSession = {
startRecording: async ({ recordingId }) => {
if (active.has(recordingId)) throw new Error('Recording ID already exists');
active.add(recordingId);
},
stopRecording: async (recordingId) => {
if (!active.delete(recordingId)) throw new Error('Recording does not exist');
return { downloadUrl: `${DOWNLOAD_URL}&recordingId=${recordingId}` };
},
downloadRecording: async () => {},
};
const operations = createLimrunScreenRecordingOperations({
host: fakeRecordingHost(calls),
device: limrunIosSimulator,
owner,
signal: new AbortController().signal,
getDeviceSession: () => session,
});
const first = (await operations.screenRecordingStart(input())).pendingHandle.transfer();
const second = (
await operations.screenRecordingStart(input({ outputPath: '/tmp/second-recording.mp4' }))
).pendingHandle.transfer();
expect(active.size).toBe(3);

await expect(first.finish()).resolves.toMatchObject({ status: 'completed' });
expect(active.size).toBe(2);
expect(active.has('external-recording')).toBe(true);

await expect(second.forceCleanup()).resolves.toEqual({ status: 'cleaned' });
expect([...active]).toEqual(['external-recording']);
});
5 changes: 4 additions & 1 deletion packages/provider-limrun/src/recording-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime';
Expand Down Expand Up @@ -67,6 +68,7 @@ async function startLimrunRecording(
{ deviceId: params.device.id },
);
}
const recordingId = randomUUID();
return await startTransportScreenRecording({
host: params.host,
device: params.device,
Expand All @@ -78,10 +80,11 @@ async function startLimrunRecording(
targetLabel: 'Limrun recording',
start: async () => {
await session.startRecording({
recordingId,
quality: LIMRUN_QUALITY_BY_EXPORT[input.exportQuality ?? 'medium'],
});
},
stop: async () => (await session.stopRecording()).downloadUrl,
stop: async () => (await session.stopRecording(recordingId)).downloadUrl,
collect: async (downloadUrl, outputPath) => {
await session.downloadRecording({ downloadUrl, outPath: outputPath });
},
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading