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
9 changes: 7 additions & 2 deletions packages/capture-kit/src/durable-capture/transitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ test('a disposal finish disposes a preserving kind’s material too', async () =
});
});

test.each(['rebuild', 'retire', 'token', 'generation'] as const)(
test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)(
'a held finish after %s clears only its matching lifetime, handle and fence',
async (change) => {
const context = makeDurableCaptureContext();
Expand Down Expand Up @@ -270,7 +270,12 @@ test.each(['rebuild', 'retire', 'token', 'generation'] as const)(
context.sessionStore.update(ref, (current) => ({
...current,
name: 'updated',
capture: { ...active, envelope: { ...active.envelope, fence } },
capture: {
...active,
handle:
change === 'handle' ? makeDurableCaptureStartResult(context).handle : active.handle,
envelope: { ...active.envelope, fence },
},
}));
}
const before = context.sessionStore.get(context.sessionName)!;
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/architecture-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const DAEMON_INTERACTION_FACADE = {
exports: [
'FindRouteInput',
'InteractionRouteInput',
'bindInteractionSession',
'captureSnapshotForSession',
'createInteractionRuntime',
'finalizeTouchInteraction',
Expand Down
4 changes: 4 additions & 0 deletions scripts/layering/session-resource-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ test('session resources are constructed only by their durable domain owners', ()
sessionStore.update(ref, { perfCapture: perf });
sessionStore.update(ref, { screenRecording: recording });`,
],
[
'src/daemon/screen-recording-session-binding.ts',
`sessionStore.publish(address, { ...draft, screenRecording });`,
],
[
'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts',
`sessionStore.set(name, { ...session, audioProbe: audio });`,
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/session-resource-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const RESOURCE_OWNERS: Readonly<Record<string, ReadonlySet<string>>> = {
audioProbe: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']),
screenRecording: new Set([
'src/daemon/session-capture-binding.ts',
'src/daemon/screen-recording-session-binding.ts',
'src/daemon/session-state.ts',
]),
perfCapture: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']),
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/test-utils/store-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore {
export function makeStoredSessionRef(session: SessionState, address = session.name): SessionRef {
return makeSessionStore().publish(address, session);
}

export function storeSessionForTest(
store: SessionStore,
session: SessionState,
address = session.name,
): SessionRef {
const ref = store.lookup(address);
if (!ref) return store.publish(address, session);
if (ref.session !== session) throw new Error('A different test session occupies this address');
return ref;
}
6 changes: 4 additions & 2 deletions src/daemon/__tests__/android-owner-seam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ test('provider-owned Android sessions bypass local observation and recovery', as
},
],
};
const sessionStore = new SessionStore('/tmp/provider-owned-android');
const ref = sessionStore.publish(session.name, session);
await expect(
resolveDirectTouchReferenceFrameSafely({
session,
ref,
flags: undefined,
sessionStore: new SessionStore('/tmp/provider-owned-android'),
sessionStore,
contextFromFlags: () => ({}),
captureSnapshotForSession: async () => session.snapshot!,
observation,
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/__tests__/filesystem-boundary-faults.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import path from 'node:path';
Expand Down Expand Up @@ -158,7 +159,7 @@ function createSessionStoreFixture(root: string): FilesystemBoundaryFixture {

return {
targetPath,
run: async () => store.finalizeRepairTeardown(session),
run: async () => store.finalizeRepairTeardown(storeSessionForTest(store, session)),
expected: 'return',
verifyReturn: (_value, errno) => {
const tombstone = store.readRepairTombstone(session.name);
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/__tests__/generic-settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ beforeEach(() => {
mockCaptureSnapshotForSession.mockReset();
mockCaptureSnapshotForSession.mockImplementation(
(...args: Parameters<typeof captureSnapshotForSession>) => {
const [session, flags, sessionStore, _contextFromFlags, options] = args;
const [ref, flags, sessionStore, _contextFromFlags, options] = args;
const session = sessionStore.requireCurrent(ref);
return emulateCaptureSnapshotForSession(session, flags, sessionStore, options);
},
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
/**
* ADR 0012 decision 6 acceptance test: a healed sibling `.ad` produced by the
* repair loop must replay end-to-end in a FRESH session, with every selector
Expand Down Expand Up @@ -140,7 +141,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope
// repair-armed write on the same explicit finalize signal `close
// --save-script` sets). ---
markRepairTransactionComplete(session);
sessionStore.writeSessionLog(session);
sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
const healedPath = path.join(root, 'flow.healed.ad');
expect(fs.existsSync(healedPath)).toBe(true);
const healedScript = fs.readFileSync(healedPath, 'utf8');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
/**
* ADR 0012 decision 6, R2/R3, extended per #1262: behaviors introduced
* alongside the `resume.from` / `repairHint` agreement fix
Expand Down Expand Up @@ -188,7 +189,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a
// --- Commit: the transaction is COMPLETE, so the healed script actually
// publishes — the corrective press survives, "click" (never recorded) does
// not. Proves the empty-tail resume did not lead to a discarded repair. ---
const writeResult = sessionStore.writeSessionLog(session);
const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
expect(writeResult.written).toBe(true);
const healedPath = path.join(root, 'flow.healed.ad');
expect(fs.existsSync(healedPath)).toBe(true);
Expand Down Expand Up @@ -312,7 +313,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes
// since a `manual` divergence never dispatched it) does not. Proves the
// empty-tail resume did not lead to a discarded repair (the #1260
// discard-at-close trap, now also closed for `manual`). ---
const writeResult = sessionStore.writeSessionLog(session);
const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
expect(writeResult.written).toBe(true);
const healedPath = path.join(root, 'flow.healed.ad');
expect(fs.existsSync(healedPath)).toBe(true);
Expand Down Expand Up @@ -436,7 +437,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an

// --- Commit: COMPLETE, so the healed script publishes the corrective
// press; the pre-action "click" (never dispatched) does not appear. ---
const writeResult = sessionStore.writeSessionLog(session);
const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session));
expect(writeResult.written).toBe(true);
const healedPath = path.join(root, 'flow.healed.ad');
expect(fs.existsSync(healedPath)).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
/**
* ADR 0012 decision 6 repair-transaction close-ordering guarantees (BLOCKER 2/3 sequencing): the
* platform close must run and succeed BEFORE the healed `.ad` commits (never claim a successful
Expand Down Expand Up @@ -244,7 +245,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact

// Writer 1 commits a complete artifact at the default healed path.
const first = makeCompleteRepairSession(sessionStore, `${sessionName}-1`, root);
const r1 = sessionStore.writeSessionLog(first);
const r1 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, first));
expect(r1.written).toBe(true);
const committed = fs.readFileSync(healedPath, 'utf8');
expect(committed).toContain(HEAL_COMPLETE_SENTINEL);
Expand All @@ -261,7 +262,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact
result: { selectorChain: ['id="different"'] },
targetEvidence: freshEvidence('different', 'Different'),
};
const r2 = sessionStore.writeSessionLog(second);
const r2 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, second));
expect(r2.written).toBe(false);
expect(r2.written === false && r2.error?.message).toMatch(/already exists/);
// The first writer's complete artifact is byte-for-byte intact.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts';
/**
* ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c):
* proves the WHOLE chain end to end, at the layer these fixes actually live —
Expand Down Expand Up @@ -304,7 +305,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale
// Idle-reap tears the still-incomplete repair session down: the writer commits
// nothing (not complete) and a tombstone is left behind (the exact teardown
// step daemon-runtime.ts's teardownDaemonSession runs).
sessionStore.finalizeRepairTeardown(session);
sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session));
sessionStore.delete(sessionName);
expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(false);

Expand Down Expand Up @@ -353,7 +354,7 @@ test('C5a/BLOCKER 3: teardown of a COMPLETE repair auto-commits a self-contained

// Teardown (e.g. the client tearing down the ephemeral daemon after a clean
// repair) auto-commits the completed transaction and leaves no tombstone.
sessionStore.finalizeRepairTeardown(session);
sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session));
expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(true);
const healedScript = fs.readFileSync(path.join(root, 'flow.healed.ad'), 'utf8');
expect(healedScript).toContain(HEAL_COMPLETE_SENTINEL);
Expand Down Expand Up @@ -398,7 +399,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F
const digest = leg1Divergence.resume.planDigest;

// Idle-reap tears the incomplete repair down, leaving a tombstone.
sessionStore.finalizeRepairTeardown(sessionStore.get(sessionName)!);
sessionStore.finalizeRepairTeardown(sessionStore.lookup(sessionName)!);
sessionStore.delete(sessionName);
expect(sessionStore.readRepairTombstone(sessionName)).toBeDefined();

Expand Down
145 changes: 142 additions & 3 deletions src/daemon/__tests__/request-recording-health.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { test, expect, vi, beforeEach } from 'vitest';
import type { SessionState } from '../session-state.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import {
createRequestExecutionScope,
prepareLockedRequestScope,
} from '../request-execution-scope.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts';

vi.mock('../../platform-runtime-apple-resources.ts', async (importOriginal) => ({
Expand Down Expand Up @@ -54,7 +60,9 @@ test('runner-backed iOS recordings still invalidate on runner restarts', async (
sessionId: 'runner-after',
});

await refreshRecordingHealth(session);
const store = makeSessionStore();
const ref = store.publish(session.name, session);
await refreshRecordingHealth(store, ref);

expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1');
expect(session.screenRecording?.handle.inspect().invalidatedReason).toBe(
Expand All @@ -78,7 +86,9 @@ test.each([
});
mockObserveRunnerSession.mockResolvedValue(snapshot);

await refreshRecordingHealth(session);
const store = makeSessionStore();
const ref = store.publish(session.name, session);
await refreshRecordingHealth(store, ref);

expect(session.screenRecording.handle.inspect().invalidatedReason).toBe(reason);
});
Expand All @@ -91,9 +101,138 @@ test('a recording without a runner identity adopts the first live observation',
});
mockObserveRunnerSession.mockResolvedValue({ alive: true, sessionId: 'runner-first' });

await refreshRecordingHealth(session);
const store = makeSessionStore();
const ref = store.publish(session.name, session);
await refreshRecordingHealth(store, ref);

const recording = session.screenRecording.handle.inspect();
expect(recording.runnerSessionId).toBe('runner-first');
expect(recording.invalidatedReason).toBeUndefined();
});

test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)(
'a held health observation respects the current lifetime and resource: %s',
async (change) => {
const store = makeSessionStore();
const session = makeIosSimulatorSession(true);
const active = makeTestScreenRecordingResource(session, {
backend: 'runner AVAssetWriter',
showTouches: true,
runnerSessionId: 'runner-before',
});
session.screenRecording = active;
const ref = store.publish('default', session);
let finish!: (value: { alive: boolean; sessionId: string }) => void;
mockObserveRunnerSession.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const observation = refreshRecordingHealth(store, ref);
expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1');
if (change === 'rebuild') {
store.update(ref, { appName: 'Intervening app' });
} else if (change === 'retire') {
store.retire(ref);
store.publish('default', session);
} else {
const replacement = makeTestScreenRecordingResource(session, {
backend: 'runner AVAssetWriter',
showTouches: true,
runnerSessionId: 'successor-runner',
});
store.update(ref, {
screenRecording: {
...active,
handle: change === 'handle' ? replacement.handle : active.handle,
envelope: {
...active.envelope,
fence: {
...active.envelope.fence,
token: change === 'token' ? 'new-token' : active.envelope.fence.token,
generation: active.envelope.fence.generation + (change === 'generation' ? 1 : 0),
},
},
},
});
}
const current = store.get('default')!;
finish({ alive: true, sessionId: 'runner-after' });
await observation;
expect(store.get('default')).toBe(current);
expect(active.handle.inspect().invalidatedReason).toBe(
change === 'rebuild' ? 'iOS runner session restarted during recording' : undefined,
);
if (change === 'rebuild') expect(current.appName).toBe('Intervening app');
else expect(current.screenRecording?.handle.inspect().invalidatedReason).toBeUndefined();
},
);

test.each(['rebuild', 'retire'] as const)(
'locked request preparation keeps its captured lifetime after runner observation: %s',
async (change) => {
const store = makeSessionStore();
const session = makeIosSimulatorSession(true);
session.screenRecording = makeTestScreenRecordingResource(session, {
backend: 'runner AVAssetWriter',
showTouches: true,
runnerSessionId: 'runner-before',
});
const ref = store.publish('default', session);
let observed!: () => void;
const started = new Promise<void>((resolve) => {
observed = resolve;
});
let finish!: (value: { alive: boolean; sessionId: string }) => void;
mockObserveRunnerSession.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
observed();
}),
);
await using scope = await createRequestExecutionScope({
req: { token: 'token', session: 'default', command: 'snapshot', positionals: [] },
sessionStore: store,
leaseRegistry: new LeaseRegistry(),
});
const prepared = scope.runLocked(() =>
prepareLockedRequestScope({
scope,
sessionStore: store,
trackDownloadableArtifact: () => 'artifact',
}),
);
const outcome = prepared.then(
(value) => ({ value }),
(error) => ({ error }),
);
await started;
let current;
if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' });
else {
store.retire(ref);
current = makeIosSimulatorSession(false);
store.publish('default', current);
store.setRuntimeHints('default', { metroPort: 8083 });
}
finish({ alive: true, sessionId: 'runner-before' });
const result = await outcome;
expect(store.get('default')).toBe(current);
if (change === 'rebuild') {
expect(result).toMatchObject({
value: { type: 'scope', scope: { existingSession: current } },
});
expect(current.appName).toBe('Latest app');
if ('value' in result && result.value.type === 'scope') {
store.retire(ref);
store.publish('default', { ...makeIosSimulatorSession(false), surface: 'app' });
expect(result.value.scope.handlerContextFromFlags(undefined).surface).toBeUndefined();
}
} else {
expect(result).toMatchObject({ error: { details: { reason: 'session_lifetime_ended' } } });
expect(store.getRuntimeHints('default')).toEqual({ metroPort: 8083 });
}
},
);
Loading
Loading