Skip to content

Commit df280ce

Browse files
committed
test(daemon): pin every close teardown step to the store address
stopBestEffortSessionResources takes a SessionRef, so the address and the session travel as the pair SessionRef documents and a caller cannot hand it session.name by accident. makeIosSimulatorRecordingSession accepts a separate address and name. One close test per step (recording, app_log, audio_probe, perf_capture) uses name "default" under address "cwd:<hash>:default"; each fails with "<Kind> resource record is missing" if only its own step reverts to session.name.
1 parent 547fcec commit df280ce

4 files changed

Lines changed: 261 additions & 119 deletions

File tree

‎src/daemon/session-lifecycle/internal/__tests__/session-close-app-log-address.test.ts‎

Lines changed: 0 additions & 107 deletions
This file was deleted.
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { beforeEach, expect, test, vi } from 'vitest';
2+
import type { AppLogLiveHandle } from '@agent-device/contracts/app-log-runtime';
3+
import type { AudioProbeLiveHandle } from '@agent-device/contracts/audio-probe-runtime';
4+
import type { PerfNativeCaptureLiveHandle } from '@agent-device/contracts/perf-runtime';
5+
import type { DurableResourceEnvelope } from '@agent-device/contracts/durable-resource-envelope';
6+
import {
7+
createDurableResourceEnvelope,
8+
encodeDurableDescriptor,
9+
hostAudioProbeDescriptorCodec,
10+
} from '@agent-device/capture-kit';
11+
import { appLogResourceStore } from '../../../app-log-resource-store.ts';
12+
import { audioProbeResourceStore } from '../../../audio-probe-resource-store.ts';
13+
import { perfCaptureResourceStore } from '../../../perf-capture-resource-store.ts';
14+
import { screenRecordingResourceStore } from '../../../screen-recording-resource-store.ts';
15+
import type { DurableCaptureResourceStore } from '@agent-device/capture-kit/durable-capture';
16+
import {
17+
sessionCloseShutdownFixture,
18+
type SessionState,
19+
} from './session-close-shutdown.fixtures.ts';
20+
import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts';
21+
22+
const {
23+
handleSessionCommands,
24+
localRuntimeOwner,
25+
makeIosSimulatorRecordingSession,
26+
makeSession,
27+
makeSessionStore,
28+
noopInvoke,
29+
path,
30+
recordingFinishMock,
31+
resetSessionCloseShutdownMocks,
32+
} = sessionCloseShutdownFixture;
33+
34+
beforeEach(resetSessionCloseShutdownMocks);
35+
36+
// An implicitly cwd-scoped session is *named* `default` but *stored* under `cwd:<hash>:default`
37+
// (`SessionRef`). Each durable-capture record lives in the address's directory, so close teardown
38+
// must address every resource by the store address. Each test below fails with
39+
// "<Kind> resource record is missing" if its step goes back to `session.name`.
40+
const ADDRESS = 'cwd:0f803c4542a46e92:default';
41+
const NAME = 'default';
42+
const IOS_SIM: SessionState['device'] = {
43+
platform: 'apple',
44+
appleOs: 'ios',
45+
id: '9105AAA0-3184-40BC-A9FC-46634C90DFFB',
46+
name: 'iPhone 16',
47+
kind: 'simulator',
48+
booted: true,
49+
};
50+
51+
async function closeAtAddress(sessionStore: ReturnType<typeof makeSessionStore>) {
52+
return await handleSessionCommands({
53+
req: { token: 't', session: ADDRESS, command: 'close', positionals: [], flags: {} },
54+
sessionName: ADDRESS,
55+
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
56+
sessionStore,
57+
invoke: noopInvoke,
58+
});
59+
}
60+
61+
function expectCompleted<K extends string>(
62+
store: DurableCaptureResourceStore<K>,
63+
resourcePath: string,
64+
): void {
65+
const record = store.read(resourcePath);
66+
expect(record.status).toBe('decoded');
67+
if (record.status === 'decoded') expect(record.envelope.lifecycle).toBe('completed');
68+
}
69+
70+
test('close finishes the recording of a cwd-scoped session by store address', async () => {
71+
const sessionStore = makeSessionStore();
72+
const session = makeIosSimulatorRecordingSession(sessionStore, ADDRESS, { name: NAME });
73+
const finish = recordingFinishMock(session);
74+
sessionStore.set(ADDRESS, session);
75+
const resourcePath = screenRecordingResourceStore.resolvePath(
76+
sessionStore.resolveSessionDir(ADDRESS),
77+
);
78+
79+
const response = await closeAtAddress(sessionStore);
80+
81+
expect(response?.ok).toBe(true);
82+
expect(finish).toHaveBeenCalledOnce();
83+
expectCompleted(screenRecordingResourceStore, resourcePath);
84+
expect(sessionStore.get(ADDRESS)).toBeUndefined();
85+
});
86+
87+
test('close stops the app log of a cwd-scoped session by store address', async () => {
88+
const sessionStore = makeSessionStore();
89+
const sessionDir = sessionStore.resolveSessionDir(ADDRESS);
90+
const outputPath = path.join(sessionDir, 'app.log');
91+
const forceCleanup = vi.fn(async () => ({ status: 'cleaned' }) as const);
92+
const handle: AppLogLiveHandle = {
93+
inspect: () => ({ backend: 'ios-simulator', state: 'active', startedAt: Date.now() - 1000 }),
94+
finish: async () => ({
95+
status: 'completed' as const,
96+
result: { backend: 'ios-simulator', outputPath, completedAt: Date.now() },
97+
}),
98+
forceCleanup,
99+
[Symbol.asyncDispose]: async () => {},
100+
};
101+
const envelope: DurableResourceEnvelope<'app-log'> = createDurableResourceEnvelope({
102+
resourceKind: 'app-log',
103+
sessionId: ADDRESS,
104+
device: {
105+
id: IOS_SIM.id,
106+
family: 'apple',
107+
appleOs: 'ios',
108+
kind: 'simulator',
109+
target: 'mobile',
110+
},
111+
owner: localRuntimeOwner('apple'),
112+
fence: { token: 'app-log-fence', generation: 1 },
113+
lifecycle: 'open',
114+
descriptor: {
115+
version: 1,
116+
body: {
117+
transport: 'apple-log-stream',
118+
backend: 'ios-simulator',
119+
outputPath,
120+
pidPath: path.join(sessionDir, 'app-log.pid'),
121+
},
122+
},
123+
metadata: { phase: 'active' },
124+
});
125+
sessionStore.set(ADDRESS, {
126+
...makeSession(NAME, IOS_SIM),
127+
appBundleId: 'com.apple.Preferences',
128+
appLog: { handle, envelope },
129+
});
130+
const resourcePath = appLogResourceStore.resolvePath(sessionDir);
131+
appLogResourceStore.write(resourcePath, envelope);
132+
133+
const response = await closeAtAddress(sessionStore);
134+
135+
expect(response?.ok).toBe(true);
136+
expect(forceCleanup).toHaveBeenCalledOnce();
137+
expectCompleted(appLogResourceStore, resourcePath);
138+
expect(sessionStore.get(ADDRESS)).toBeUndefined();
139+
});
140+
141+
test('close finishes the audio probe of a cwd-scoped session by store address', async () => {
142+
const sessionStore = makeSessionStore();
143+
const sessionDir = sessionStore.resolveSessionDir(ADDRESS);
144+
const statusPath = path.join(sessionDir, 'audio-probe.json');
145+
const startedAt = Date.now() - 2000;
146+
const stopped = {
147+
audio: 'probe' as const,
148+
state: 'stopped' as const,
149+
active: false,
150+
heard: false,
151+
source: 'system-audio' as const,
152+
backend: 'macos-screencapturekit',
153+
durationMs: 10000,
154+
elapsedMs: 2000,
155+
bucketMs: 1000,
156+
sampleCount: 2,
157+
sourceCount: 1,
158+
rmsDbfs: [] as number[],
159+
peakDbfs: [] as number[],
160+
};
161+
const finish = vi.fn(async () => ({ status: 'completed' as const, result: stopped }));
162+
const handle: AudioProbeLiveHandle = {
163+
inspect: () => ({
164+
source: 'system-audio' as const,
165+
backend: 'macos-screencapturekit',
166+
sourceCount: 1,
167+
notes: [],
168+
statusPath,
169+
startedAt,
170+
durationMs: 10000,
171+
bucketMs: 1000,
172+
}),
173+
status: async () => stopped,
174+
finish,
175+
forceCleanup: async () => ({ status: 'cleaned' }) as const,
176+
[Symbol.asyncDispose]: async () => {},
177+
};
178+
const envelope = createDurableResourceEnvelope({
179+
resourceKind: 'audio-probe',
180+
sessionId: ADDRESS,
181+
device: { id: IOS_SIM.id, family: 'apple', appleOs: 'ios', kind: 'simulator' },
182+
owner: localRuntimeOwner('apple'),
183+
fence: { token: 'audio-probe-fence', generation: 1 },
184+
lifecycle: 'open',
185+
descriptor: encodeDurableDescriptor(hostAudioProbeDescriptorCodec, {
186+
backend: 'macos-screencapturekit',
187+
source: 'system-audio',
188+
sourceCount: 1,
189+
notes: [],
190+
statusPath,
191+
startedAt,
192+
durationMs: 10000,
193+
bucketMs: 1000,
194+
marker: { pid: 4242, startTime: 'boot+1', command: 'helper' },
195+
}),
196+
});
197+
sessionStore.set(ADDRESS, { ...makeSession(NAME, IOS_SIM), audioProbe: { handle, envelope } });
198+
const resourcePath = audioProbeResourceStore.resolvePath(sessionDir);
199+
audioProbeResourceStore.write(resourcePath, envelope);
200+
201+
const response = await closeAtAddress(sessionStore);
202+
203+
expect(response?.ok).toBe(true);
204+
expect(finish).toHaveBeenCalledOnce();
205+
expectCompleted(audioProbeResourceStore, resourcePath);
206+
expect(sessionStore.get(ADDRESS)).toBeUndefined();
207+
});
208+
209+
test('close stops the perf capture of a cwd-scoped session by store address', async () => {
210+
const sessionStore = makeSessionStore();
211+
const sessionDir = sessionStore.resolveSessionDir(ADDRESS);
212+
const finish = vi.fn(async () => ({
213+
status: 'completed' as const,
214+
result: {
215+
kind: 'xctrace',
216+
mode: 'trace',
217+
state: 'stopped',
218+
outPath: path.join(sessionDir, 'app.trace'),
219+
} as const,
220+
}));
221+
const handle: PerfNativeCaptureLiveHandle = {
222+
inspect: () => ({ kind: 'xctrace', mode: 'trace', state: 'running' }),
223+
setOutputPath: vi.fn(),
224+
finish,
225+
forceCleanup: async () => ({ status: 'cleaned' }) as const,
226+
[Symbol.asyncDispose]: async () => {},
227+
};
228+
const envelope = createDurableResourceEnvelope({
229+
resourceKind: 'perf-capture',
230+
sessionId: ADDRESS,
231+
device: { id: IOS_SIM.id, family: 'apple', appleOs: 'ios', kind: 'simulator' },
232+
owner: localRuntimeOwner('apple'),
233+
fence: { token: 'perf-capture-fence', generation: 1 },
234+
lifecycle: 'open',
235+
descriptor: { version: 1, body: { kind: 'fixture' } },
236+
});
237+
sessionStore.set(ADDRESS, { ...makeSession(NAME, IOS_SIM), perfCapture: { handle, envelope } });
238+
const resourcePath = perfCaptureResourceStore.resolvePath(sessionDir);
239+
perfCaptureResourceStore.write(resourcePath, envelope);
240+
241+
const response = await closeAtAddress(sessionStore);
242+
243+
expect(response?.ok).toBe(true);
244+
expect(finish).toHaveBeenCalledOnce();
245+
expectCompleted(perfCaptureResourceStore, resourcePath);
246+
expect(sessionStore.get(ADDRESS)).toBeUndefined();
247+
});

‎src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown.fixtures.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,22 @@ function makeSession(name: string, device: SessionState['device']): SessionState
125125
};
126126
}
127127

128+
/**
129+
* `address` is the store key the record lives under; `options.name` is the session's own name.
130+
* They differ for an implicitly cwd-scoped session (`default` under `cwd:<hash>:default`, see
131+
* `SessionRef`), and default to the same string otherwise.
132+
*/
128133
function makeIosSimulatorRecordingSession(
129134
sessionStore: SessionStore,
130-
name: string,
135+
address: string,
131136
options: {
137+
name?: string;
132138
recorderExitCode?: number;
133139
cleanupConfirmed?: boolean;
134140
device?: SessionState['device'];
135141
} = {},
136142
): SessionState {
143+
const name = options.name ?? address;
137144
const session = makeSession(
138145
name,
139146
options.device ?? {
@@ -195,7 +202,7 @@ function makeIosSimulatorRecordingSession(
195202
};
196203
const envelope = createDurableResourceEnvelope({
197204
resourceKind: 'screen-recording',
198-
sessionId: name,
205+
sessionId: address,
199206
device: { id: session.device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' },
200207
owner: localRuntimeOwner('apple'),
201208
fence: { token: name + '-fence', generation: 1 },
@@ -208,7 +215,7 @@ function makeIosSimulatorRecordingSession(
208215
envelope,
209216
};
210217
screenRecordingResourceStore.write(
211-
screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(name)),
218+
screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(address)),
212219
envelope,
213220
);
214221
return session;

0 commit comments

Comments
 (0)