@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
22import { test } from 'vitest' ;
33import type { DeviceInfo } from '@agent-device/kernel/device' ;
44import type { Interactor , RunnerContext } from './interactor-types.ts' ;
5- import { bindLocalSnapshotInteractor , bindProviderSnapshotInteractor } from './snapshot-runtime.ts' ;
5+ import {
6+ bindLocalSnapshotInteractor ,
7+ bindProviderSnapshotInteractor ,
8+ captureSnapshotSignal ,
9+ } from './snapshot-runtime.ts' ;
610
711const device : DeviceInfo = {
812 id : 'snapshot-device' ,
@@ -66,3 +70,69 @@ test('provider snapshot binding fails closed when its selected owner loses the i
6670 error . message === 'Provider-owned snapshot operation has no bound provider interactor.' ,
6771 ) ;
6872} ) ;
73+
74+ // ---------------------------------------------------------------------------
75+ // Per-capture cancellation (`CaptureSnapshotInput.signal`). A `DeviceBinding`'s
76+ // signal is fixed at bind time, but `wait` binds once and polls many times, so
77+ // each poll must be able to cancel its own capture without cancelling the
78+ // binding. These are the contract-level halves of that claim; the poll-deadline
79+ // behaviour itself is proven end to end in `src/daemon/__tests__/wait-runtime.test.ts`.
80+ // ---------------------------------------------------------------------------
81+
82+ test ( 'a capture with no per-capture signal receives the binding signal itself, not a wrapper' , ( ) => {
83+ const binding = new AbortController ( ) . signal ;
84+
85+ // Identity, deliberately: a wrapper would satisfy a deep-equal while quietly changing
86+ // cancellation semantics for every single-capture consumer (`snapshot`, `diff`), which pass
87+ // no signal at all.
88+ assert . equal ( captureSnapshotSignal ( binding , { } ) , binding ) ;
89+ assert . equal ( captureSnapshotSignal ( binding , { options : { appBundleId : 'x' } } ) , binding ) ;
90+ } ) ;
91+
92+ test ( 'binding-level cancellation still aborts a capture that carries its own signal' , ( ) => {
93+ const binding = new AbortController ( ) ;
94+ const perCapture = new AbortController ( ) ;
95+
96+ const composed = captureSnapshotSignal ( binding . signal , { signal : perCapture . signal } ) ;
97+
98+ assert . equal ( composed . aborted , false ) ;
99+ binding . abort ( ) ;
100+ // The composition adds a second way to cancel; it must not have replaced the first.
101+ assert . equal ( composed . aborted , true ) ;
102+ } ) ;
103+
104+ test ( 'a per-capture signal aborts its own capture without aborting the binding' , ( ) => {
105+ const binding = new AbortController ( ) ;
106+ const perCapture = new AbortController ( ) ;
107+
108+ const composed = captureSnapshotSignal ( binding . signal , { signal : perCapture . signal } ) ;
109+ perCapture . abort ( new DOMException ( 'Wait deadline exceeded' , 'TimeoutError' ) ) ;
110+
111+ assert . equal ( composed . aborted , true ) ;
112+ // The binding outlives the poll: the next poll of the same wait still has a live binding.
113+ assert . equal ( binding . signal . aborted , false ) ;
114+ } ) ;
115+
116+ test ( 'the shared interactor binding composes the per-capture signal it is handed' , async ( ) => {
117+ const binding = new AbortController ( ) ;
118+ const perCapture = new AbortController ( ) ;
119+ let capturedSignal : AbortSignal | undefined ;
120+ const operations = bindLocalSnapshotInteractor ( {
121+ device,
122+ signal : binding . signal ,
123+ resolveInteractor : async ( ) =>
124+ ( {
125+ snapshot : async ( options : Parameters < Interactor [ 'snapshot' ] > [ 0 ] ) => {
126+ capturedSignal = options ?. signal ;
127+ return { backend : 'android' , nodes : [ ] } ;
128+ } ,
129+ } ) as unknown as Interactor ,
130+ } ) ;
131+
132+ await operations . captureSnapshot ( { signal : perCapture . signal } ) ;
133+
134+ assert . ok ( capturedSignal , 'the interactor must receive a signal' ) ;
135+ assert . equal ( capturedSignal . aborted , false ) ;
136+ perCapture . abort ( new DOMException ( 'Wait deadline exceeded' , 'TimeoutError' ) ) ;
137+ assert . equal ( capturedSignal . aborted , true , 'the poll deadline must reach the platform' ) ;
138+ } ) ;
0 commit comments