Skip to content

Commit 6026844

Browse files
amankansal-ltclaude
andcommitted
fix(testmu): look up the recorded session among the user's own
TestMu AI's session list filters by build name across the whole organization, so a build name another user's run shares could find their session. The lookup now also passes `username`, which the API matches against the session's owner, set to `LT_USERNAME`. The docs say that a fixed `build` and `sessionName` shared by overlapping runs is ambiguous, and that the default names avoid it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 20ad572 commit 6026844

4 files changed

Lines changed: 28 additions & 16 deletions

File tree

‎docs/integrations/testmu.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,14 +265,21 @@ TestMu AI records the whole session, from the lease to the end of the run,
265265
so the link is the same video for every attempt on that worker slot, and
266266
the video starts when the session did. When the attempt starts recording,
267267
the provider finds the session through TestMu AI's sessions API by its
268-
build and name and takes the session's start time as the video's, so the
268+
build and name, among the sessions of the `LT_USERNAME` user, taking the
269+
newest that matches, and takes the session's start time as the video's, so the
269270
report's timeline offsets land on the attempt within it; if TestMu AI
270271
reports no start time, the time the attempt started recording is used
271272
instead. When the attempt stops recording, the link is the video URL the
272273
API returns for the session. The runner never downloads the video. The
273274
provider reads the API at `TESTMU_API_ENDPOINT` when that is set, as
274275
agent-device does.
275276

277+
The default `build` (the run id) and `sessionName` (with the run id in it)
278+
give every session its own name. A fixed `build` and a fixed `sessionName`
279+
shared by runs that overlap name several sessions the same, so an attempt
280+
can link another run's video; give such runs their own `build`, or leave
281+
`sessionName` to its default.
282+
276283
## What the provider does
277284

278285
- Starts one agent-device daemon per run under `stateDir/<run id>` and

‎packages/testmu/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ Sessions run over TestMu AI's Appium hub, so agent-device's device settings,
7171
system alerts, recording, device logs, and port reverse are not available
7272
there. An attempt that records video links TestMu AI's recording of the
7373
whole session instead, found by the session's build and name and starting
74-
at the session's start time.
74+
at the session's start time. Runs that overlap and share a fixed `build`
75+
and `sessionName` can link each other's videos; the defaults never do.
7576

7677
Full documentation lives at [e2e.tester.army/docs/integrations/testmu](https://e2e.tester.army/docs/integrations/testmu).
7778

‎packages/testmu/src/sessions.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ export interface FoundSession {
5050
}
5151

5252
/**
53-
* The newest session named `sessionName` in `build`, reading the list a page
54-
* at a time. Every request is bounded by `REQUEST_TIMEOUT_MS` and `signal`,
53+
* The newest session named `sessionName` in `build` that the credentials'
54+
* user started, reading the list a page at a time. Every request is bounded by `REQUEST_TIMEOUT_MS` and `signal`,
5555
* and errors name the build and the session, never the credentials or a URL.
5656
*/
5757
export async function findSession(endpoint: string, credentials: TestmuCredentials, { build, sessionName }: SessionRef, signal: AbortSignal): Promise<FoundSession> {
@@ -60,6 +60,8 @@ export async function findSession(endpoint: string, credentials: TestmuCredentia
6060
for (let page = 0; page < MAX_PAGES; page += 1) {
6161
const url = new URL(`${endpoint}/sessions`);
6262
url.searchParams.set('build', build);
63+
// The build filter spans the whole organization; a build name another user's run shares would find their session.
64+
url.searchParams.set('username', credentials.username);
6365
url.searchParams.set('limit', String(PAGE_SIZE));
6466
url.searchParams.set('offset', String(page * PAGE_SIZE));
6567
const body = await getJson(url, auth, signal, what);

‎packages/testmu/tests/unit/recording.test.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ beforeEach(() => {
4444
Object.assign(api, {
4545
calls: [],
4646
sessions: [
47-
{ test_id: 'T3', name: 'e2e-run-1-android-1', build_name: 'run-1', start_timestamp: '2026-10-02T09:58:05Z' },
48-
{ test_id: 'T2', name: 'e2e-run-1-android-2', build_name: 'run-1', start_timestamp: '2026-10-02T09:58:07Z' },
49-
{ test_id: 'T1', name: 'e2e-run-1-android-2', build_name: 'run-1', start_timestamp: '2026-10-01T08:00:00Z' },
47+
{ test_id: 'T4', name: 'e2e-run-1-android-2', build_name: 'run-1', username: 'grace', start_timestamp: '2026-10-02T09:59:00Z' },
48+
{ test_id: 'T3', name: 'e2e-run-1-android-1', build_name: 'run-1', username: 'ada', start_timestamp: '2026-10-02T09:58:05Z' },
49+
{ test_id: 'T2', name: 'e2e-run-1-android-2', build_name: 'run-1', username: 'ada', start_timestamp: '2026-10-02T09:58:07Z' },
50+
{ test_id: 'T1', name: 'e2e-run-1-android-2', build_name: 'run-1', username: 'ada', start_timestamp: '2026-10-01T08:00:00Z' },
5051
],
5152
details: { T2: { test_id: 'T2', name: 'e2e-run-1-android-2', video_url: VIDEO }, T1: { test_id: 'T1', video_url: 'https://videos.example.com/old.mp4' } },
5253
override: undefined,
@@ -63,9 +64,10 @@ beforeEach(() => {
6364
const base = new URL(API).pathname;
6465
if (url.pathname === `${base}/sessions`) {
6566
const build = url.searchParams.get('build');
67+
const username = url.searchParams.get('username');
6668
const limit = Number(url.searchParams.get('limit') ?? '10');
6769
const offset = Number(url.searchParams.get('offset') ?? '0');
68-
const rows = api.sessions.filter((row) => row['build_name'] === build).slice(offset, offset + limit);
70+
const rows = api.sessions.filter((row) => row['build_name'] === build && (username === null || row['username'] === username)).slice(offset, offset + limit);
6971
// An empty page is Go's nil slice: `data: null`.
7072
return Response.json({ status: 'success', data: rows.length === 0 ? null : rows, message: 'Retrieve session list was successful', Meta: { result_set: { count: rows.length } } });
7173
}
@@ -104,28 +106,28 @@ async function record(recordLease: DeviceLease = lease, recordContext: ProviderR
104106
}
105107

106108
describe('testmu().record()', () => {
107-
it("starts at the newest session's start time, found by the slot's name in the lease's build, and links its video when it stops", async () => {
109+
it("starts at the newest session's start time, found by the slot's name in the lease's build among the user's own, and links its video when it stops", async () => {
108110
const recording = await record();
109111
expect(recording.startedAt).toBe('2026-10-02T09:58:07.000Z');
110-
expect(api.calls.map((call) => call.url.href)).toEqual([`${API}/sessions?build=run-1&limit=50&offset=0`]);
112+
expect(api.calls.map((call) => call.url.href)).toEqual([`${API}/sessions?build=run-1&username=ada&limit=50&offset=0`]);
111113
await expect(recording.stop(stopContext())).resolves.toEqual({ url: VIDEO, mediaType: 'video/mp4' });
112-
expect(api.calls.map((call) => call.url.href)).toEqual([`${API}/sessions?build=run-1&limit=50&offset=0`, `${API}/sessions/T2`]);
114+
expect(api.calls.map((call) => call.url.href)).toEqual([`${API}/sessions?build=run-1&username=ada&limit=50&offset=0`, `${API}/sessions/T2`]);
113115
expect(api.calls.map((call) => call.authorization)).toEqual([`Basic ${Buffer.from('ada:lt-key').toString('base64')}`, `Basic ${Buffer.from('ada:lt-key').toString('base64')}`]);
114116
});
115117

116118
it.each([
117119
['2026-10-02T09:58:07.25+05:30', '2026-10-02T04:28:07.250Z'],
118120
['2026-10-02 09:58:07', '2026-10-02T09:58:07.000Z'],
119121
])('reads the start time %s as %s, a time without a zone as UTC', async (start, iso) => {
120-
api.sessions[1]!['start_timestamp'] = start;
122+
api.sessions[2]!['start_timestamp'] = start;
121123
expect((await record()).startedAt).toBe(iso);
122124
});
123125

124126
it.each([undefined, null, '', 'yesterday', '2026-13-45T99:00:00Z'])('falls back to the moment it is called without a usable start time (%j)', async (start) => {
125127
vi.useFakeTimers({ toFake: ['Date'] });
126128
vi.setSystemTime(new Date('2026-10-02T10:00:00.000Z'));
127-
if (start === undefined) delete api.sessions[1]!['start_timestamp'];
128-
else api.sessions[1]!['start_timestamp'] = start;
129+
if (start === undefined) delete api.sessions[2]!['start_timestamp'];
130+
else api.sessions[2]!['start_timestamp'] = start;
129131
expect((await record()).startedAt).toBe('2026-10-02T10:00:00.000Z');
130132
});
131133

@@ -135,7 +137,7 @@ describe('testmu().record()', () => {
135137
});
136138

137139
it('pages through a build holding more sessions than one page', async () => {
138-
api.sessions = [...Array.from({ length: 50 }, (_, index) => ({ test_id: `X${index}`, name: `other-${index}`, build_name: 'run-1' })), ...api.sessions];
140+
api.sessions = [...Array.from({ length: 50 }, (_, index) => ({ test_id: `X${index}`, name: `other-${index}`, build_name: 'run-1', username: 'ada' })), ...api.sessions];
139141
await expect((await record()).stop(stopContext())).resolves.toMatchObject({ url: VIDEO });
140142
expect(api.calls.map((call) => call.url.searchParams.get('offset'))).toEqual(['0', '50', null]);
141143
});
@@ -144,7 +146,7 @@ describe('testmu().record()', () => {
144146
const recording = await record(lease, context({ env: { ...env, TESTMU_API_ENDPOINT: 'https://stage-mobile-api.lambdatest.com/mobile-automation/api/v1/' } }));
145147
await expect(recording.stop(stopContext())).resolves.toMatchObject({ url: VIDEO });
146148
expect(api.calls.map((call) => call.url.href)).toEqual([
147-
'https://stage-mobile-api.lambdatest.com/mobile-automation/api/v1/sessions?build=run-1&limit=50&offset=0',
149+
'https://stage-mobile-api.lambdatest.com/mobile-automation/api/v1/sessions?build=run-1&username=ada&limit=50&offset=0',
148150
'https://stage-mobile-api.lambdatest.com/mobile-automation/api/v1/sessions/T2',
149151
]);
150152
});

0 commit comments

Comments
 (0)