Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from 'vitest';
import {
makeIosSession,
authoringPublication,
} from '../../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
import { makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts';

test('recording replay fixtures use the selected scoped journal without a public-name slot', async () => {
const store = makeSessionStore();
const address = 'cwd:recording-fixture:default';
const ref = store.publish(
address,
makeIosSession('default', {
scriptPublication: authoringPublication('armed'),
}),
);
const invoke = makeRecordingReplayInvoke({ sessionStore: store, sessionName: address });
expect(
await invoke({
token: 'test',
session: 'default',
command: 'click',
positionals: ['1', '2'],
flags: {},
}),
).toMatchObject({ ok: true });
await store.flushEvents();
expect(store.requireCurrent(ref).actions).toHaveLength(1);
expect(store.readEvents(address).events).toHaveLength(1);
expect(store.lookup('default')).toBeUndefined();
expect(store.readEvents('default').events).toEqual([]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function makeRecordingReplayInvoke(config: RecordingReplayInvokeConfig):
}
const session = resolveInvokeSession(config, req);
const evidence = isSessionRecording(session) ? config.evidence?.(req) : undefined;
sessionStore.recordAction(storeSessionForTest(sessionStore, session), {
sessionStore.recordAction(storeSessionForTest(sessionStore, session, config.sessionName), {
command: req.command,
positionals: req.positionals ?? [],
flags: req.flags ?? {},
Expand Down
29 changes: 29 additions & 0 deletions src/daemon/__tests__/snapshot-command-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,32 @@ for (const change of ['unchanged', 'rebuild', 'replace'] as const) {
}
});
}

test('a composed snapshot refuses its supplied retired lifetime before facts or capture', async () => {
const sessionStore = makeSessionStore();
const address = 'cwd:composed-snapshot:default';
const ref = sessionStore.publish(address, makeAndroidSession('default'));
sessionStore.retire(ref);
const successor = sessionStore.publish(address, makeAndroidSession('default'));
const fixture = snapshotRuntimeFixture();
const facts = fixture.inspectFacts;
let inspections = 0;
captureMock.mockResolvedValue({ nodes: [], truncated: false, backend: 'uiautomator' });
await expect(
dispatchSnapshotViaRuntime({
req: { command: 'snapshot', positionals: [], token: 't', session: 'default' },
sessionName: address,
sessionRef: ref,
logPath: '/dev/null',
sessionStore,
...fixture,
inspectFacts: async (device) => {
inspections++;
return await facts(device);
},
}),
).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } });
expect(inspections).toBe(0);
expect(captureMock).not.toHaveBeenCalled();
expect(sessionStore.requireCurrent(successor)).toBe(successor.session);
});
25 changes: 25 additions & 0 deletions src/daemon/handlers/__tests__/session-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,28 @@ test('clipboard write with no text argument reports how to clear instead', async
'clipboard write requires text (use "" to clear clipboard)',
);
});

for (const action of ['read', 'write'] as const) {
test(`clipboard ${action} refuses retirement during device resolution before binding`, async () => {
const spies = harness({ read: available, write: available });
const input = request(action === 'read' ? ['read'] : ['write', 'text']);
const ref = input.sessionStore.lookup(input.sessionName)!;
mockResolveTargetDevice.mockImplementationOnce(async () => {
input.sessionStore.retire(ref);
input.sessionStore.publish(input.sessionName, makeSession(input.sessionName, androidDevice));
return androidDevice;
});
await expect(
handleSessionClipboardCommand({
...input,
req: { ...input.req, flags: { platform: 'android', serial: androidDevice.id } },
...spies,
}),
).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } });
expect(spies.inspectFacts).not.toHaveBeenCalled();
expect(spies.bindDevice).not.toHaveBeenCalled();
expect(mockEnsureDeviceReady).not.toHaveBeenCalled();
expect(spies.readClipboard).not.toHaveBeenCalled();
expect(spies.writeClipboard).not.toHaveBeenCalled();
});
}
98 changes: 98 additions & 0 deletions src/daemon/handlers/__tests__/session-runtime-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,101 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc
}),
);
});

for (const phase of ['admission', 'effect'] as const) {
test(`runtime clear does not clear a successor's hints after retirement during ${phase}`, async () => {
const sessionStore = makeSessionStore();
const address = `cwd:runtime-clear-${phase}:default`;
const device = {
platform: 'android' as const,
id: `runtime-clear-${phase}`,
name: 'Pixel',
kind: 'emulator' as const,
booted: true,
};
const ref = sessionStore.publish(address, {
...makeSession('default', device),
appBundleId: 'com.example.old',
});
sessionStore.setRuntimeHints(address, { platform: 'android', metroHost: 'old' });
const successorHints = { platform: 'android' as const, metroHost: 'successor' };
const replace = () => {
sessionStore.retire(ref);
sessionStore.publish(address, {
...makeSession('default', device),
appBundleId: 'com.example.new',
});
sessionStore.setRuntimeHints(address, successorHints);
};
if (phase === 'admission') {
mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => {
replace();
return lifecycleRuntimeFacts(target);
});
} else {
mockClearRuntimeHints.mockImplementationOnce(async () => {
replace();
});
}
await expect(
handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'runtime',
positionals: ['clear'],
flags: {},
},
sessionName: address,
logPath: '/dev/null',
sessionStore,
invoke: noopInvoke,
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: { reason: 'session_lifetime_ended' },
});
expect(sessionStore.getRuntimeHints(address)).toBe(successorHints);
expect(refFrameState(sessionStore.get(address)!)).toBe('active');
expect(mockClearRuntimeHints).toHaveBeenCalledTimes(phase === 'admission' ? 0 : 1);
});
}

test('runtime clear expires and uses the latest matching record after admission', async () => {
const sessionStore = makeSessionStore();
const device = {
platform: 'android' as const,
id: 'runtime-clear-rebuild',
name: 'Pixel',
kind: 'emulator' as const,
booted: true,
};
const ref = sessionStore.publish('clear-rebuild', {
...makeSession('clear-rebuild', device),
appBundleId: 'old.app',
});
sessionStore.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'old' });
mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => {
sessionStore.update(ref, { appBundleId: 'rebuilt.app' });
return lifecycleRuntimeFacts(target);
});
const response = await handleSessionCommands({
req: {
token: 't',
session: ref.address,
command: 'runtime',
positionals: ['clear'],
flags: {},
},
sessionName: ref.address,
logPath: '/dev/null',
sessionStore,
invoke: noopInvoke,
});
expect(response).toMatchObject({ ok: true, data: { cleared: true } });
expect(mockClearRuntimeHints).toHaveBeenCalledWith(
expect.objectContaining({ appId: 'rebuilt.app' }),
);
expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired');
expect(refFrameState(ref.session)).toBe('active');
});
1 change: 1 addition & 0 deletions src/daemon/handlers/session-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export async function handleSessionClipboardCommand(params: {
}

const device = await resolveCommandDevice({ session, flags });
if (ref) sessionStore.requireCurrent(ref);
const bound = await resolveBoundClipboardRuntime({
device,
action,
Expand Down
19 changes: 12 additions & 7 deletions src/daemon/handlers/session-runtime-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts';
import { publicPlatformString } from '@agent-device/kernel/device';
import { clearRuntimeHintsRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan';
import { SessionStore } from '../session-store.ts';
import type { SessionRef } from '../session-state.ts';
import { expireRefFrame } from '../ref-frame.ts';
import { admitRuntimeUse } from '../runtime-admission.ts';
import {
Expand Down Expand Up @@ -64,13 +65,14 @@ export async function handleRuntimeCommand(params: {
'runtime requires set, show, clear, port-reverse, or gesture-viewport',
);
}
const session = sessionStore.get(sessionName);
const ref = sessionStore.lookup(sessionName);
const session = ref?.session;
const current = sessionStore.getRuntimeHints(sessionName);
if (action === 'clear') {
return await clearRuntimeCommand({
sessionName,
sessionStore,
session,
ref,
current,
inspectFacts: params.inspectFacts,
bindDevice: params.bindDevice,
Expand Down Expand Up @@ -118,12 +120,13 @@ function isRuntimeAction(action: string): action is RuntimeAction {
async function clearRuntimeCommand(params: {
sessionName: string;
sessionStore: SessionStore;
session: ReturnType<SessionStore['get']>;
ref: SessionRef | undefined;
current: ReturnType<SessionStore['getRuntimeHints']>;
inspectFacts?: InspectDeviceRuntimeFacts;
bindDevice?: BindDeviceRuntime;
}): Promise<DaemonResponse> {
const { sessionName, sessionStore, session, current, inspectFacts, bindDevice } = params;
const { sessionName, sessionStore, ref, current, inspectFacts, bindDevice } = params;
const session = ref ? sessionStore.requireCurrent(ref) : undefined;
if (hasRuntimeTransportHints(current) && session?.appBundleId) {
const admission = await admitClearRuntime({
device: session.device,
Expand All @@ -133,13 +136,15 @@ async function clearRuntimeCommand(params: {
if (admission.type === 'response') return admission.response;
// Native hint removal can change the app's reachable surface. Expire the existing frame at
// the mutation boundary, after admission and immediately before the bound package effect.
expireRefFrame(session);
const currentSession = sessionStore.requireCurrent(ref!);
expireRefFrame(currentSession);
await admission.runtime.operations.clearRuntimeHints({
appId: session.appBundleId,
appId: currentSession.appBundleId,
values: runtimeHintValues(current),
});
}
const cleared = sessionStore.clearRuntimeHints(sessionName);
const cleared = ref ? sessionStore.clearRuntimeHints(ref) : Boolean(current);
if (!ref) sessionStore.setRuntimeHints(sessionName, undefined);
return { ok: true, data: { session: sessionName, cleared } };
}

Expand Down
8 changes: 4 additions & 4 deletions src/daemon/handlers/session-selector-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ type SessionCommandPrepareOutcome =
/**
* The one orchestration every session/selector-route leaf shares: guard, resolve the device,
* admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates
* (immediately before the prepared invocation runs, never after), derive and record the next
* session. `prepare` is where each leaf's own admission and binding lives; everything around it
* is identical, so it lives here once instead of once per command. Every leaf on this route now
* (immediately before the prepared invocation runs, never after), apply the optional session
* patch and record the action. `prepare` owns each leaf's admission and binding; the shared
* orchestration lives here once. Every leaf on this route now
* supplies a bind-and-execute thunk — R57 retired the last capability-gate-then-`dispatchCommand`
* one with `trigger-app-event`.
*/
Expand Down Expand Up @@ -144,7 +144,7 @@ type SessionRouteRuntimeResolver = (
/**
* The whole shape a migrated session-route leaf needs: admit and bind through the caller's own
* resolver, then hand `runSessionOrSelectorDispatch` the bound runtime's `execute` to invoke after
* expiring the frame. Only the resolver, the command name and the optional session derivation
* expiring the frame. Only the resolver, the command name and the optional post-execution session patch
* differ per leaf, so one entry point here is what keeps `keyboard` and `trigger-app-event` from
* drifting into two copies of the same wiring.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import {
withDiagnosticsScope,
flushDiagnosticsToSessionFile,
} from '@agent-device/host-kit/diagnostics';
import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts';
import { test, expect, vi, beforeEach } from 'vitest';
import { attachRefs } from '@agent-device/kernel/snapshot';
import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts';
Expand Down Expand Up @@ -612,3 +619,35 @@ test('fill @ref preserves fallback coordinates for recording when platform resul
expect(event?.x).toBe(60);
expect(event?.y).toBe(40);
});

test('an already retired coordinate touch skips its frame probe without a warning', async () => {
const sessionStore = makeSessionStore();
const ref = sessionStore.publish('retired-point', makeSession('retired-point'));
sessionStore.retire(ref);
const capture = vi.fn();
const logPath = path.join(mkdtempForTestSync('retired-point-'), 'request.log');
await withDiagnosticsScope(
{ command: 'click', session: ref.address, logPath, debug: true },
async () => {
const payloads = await buildTargetedTouchResponsePayloads({
params: {
req: { token: 't', command: 'click', positionals: ['1', '2'], session: ref.address },
sessionName: ref.address,
sessionRef: ref,
sessionStore,
contextFromFlags,
captureSnapshotForSession: capture,
},
result: { kind: 'point', point: { x: 1, y: 2 } },
staleRefsWarning: undefined,
extra: {},
});
expect(payloads.responseData).toMatchObject({ x: 1, y: 2 });
flushDiagnosticsToSessionFile({ force: true });
},
);
expect(capture).not.toHaveBeenCalled();
expect(fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '').not.toContain(
'touch_reference_frame_resolve_failed',
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,9 @@ export async function buildTargetedTouchResponsePayloads(params: {
extra: Record<string, unknown>;
}): Promise<InteractionResponsePayloads> {
const { params: handlerParams, result, publicData, extra } = params;
const liveBeforeProbe = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!);
Comment thread
thymikee marked this conversation as resolved.
const probedFrame =
result.kind === 'point'
liveBeforeProbe && result.kind === 'point'
? await resolveDirectTouchReferenceFrameSafely({
ref: handlerParams.sessionRef!,
flags: handlerParams.req.flags,
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/server/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ export async function startDaemonRuntime(

const teardownDaemonSession = async (ref: SessionRef): Promise<void> => {
const session = sessionStore.resolveCurrent(ref) ?? ref.session;
const runtimeHints = runtimeHintValues(sessionStore.getRuntimeHints(ref.address));
try {
await teardownDaemonSessionForShutdown({
ref,
Expand All @@ -469,7 +470,7 @@ export async function startDaemonRuntime(
scope: createDaemonRecoveryPlatformScope(),
session: sessionToFinalize,
stateDir: baseDir,
runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionToFinalize.name)),
runtimeHints,
}),
beforeDelete: async (sessionToFinalize) => {
await finalizeDaemonSessionLease({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn
flags: { ...req.flags, snapshotInteractiveOnly: true },
},
sessionName: 'default',
sessionRef: ref,
logPath: '/tmp/daemon.log',
sessionStore,
inspectFacts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ export async function composeOpenWithInitialSnapshot(params: {
if (!openResponse.ok || req.flags?.foreground !== true) return openResponse;

try {
sessionStore.requireCurrent(ref);
const snapshotResponse = await dispatchSnapshotViaRuntime({
req: {
...req,
Expand All @@ -126,6 +125,7 @@ export async function composeOpenWithInitialSnapshot(params: {
flags: { ...req.flags, snapshotInteractiveOnly: true },
},
sessionName: ref.address,
sessionRef: ref,
logPath,
sessionStore,
inspectFacts: params.inspectFacts,
Expand Down
Loading
Loading