ace-runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ace-defaults/runtime/config.yml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/Rakefile +12 -0
- data/docs/usage.md +189 -0
- data/exe/ace-runtime +17 -0
- data/lib/ace/runtime/atoms/detector.rb +34 -0
- data/lib/ace/runtime/atoms/name_sanitizer.rb +34 -0
- data/lib/ace/runtime/atoms/send_contract.rb +160 -0
- data/lib/ace/runtime/cli/commands/send.rb +120 -0
- data/lib/ace/runtime/cli.rb +69 -0
- data/lib/ace/runtime/errors.rb +60 -0
- data/lib/ace/runtime/molecules/runtime_selector.rb +73 -0
- data/lib/ace/runtime/registry.rb +68 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +597 -0
- data/lib/ace/runtime/testing/scripted_runtime.rb +224 -0
- data/lib/ace/runtime/testing.rb +9 -0
- data/lib/ace/runtime/version.rb +7 -0
- data/lib/ace/runtime.rb +102 -0
- metadata +194 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Runtime
|
|
5
|
+
module Testing
|
|
6
|
+
# THE acceptance bar for any ace-runtime adapter (shared-examples
|
|
7
|
+
# pattern). Include AdapterContract in a Minitest::Test class and
|
|
8
|
+
# provide two hooks:
|
|
9
|
+
#
|
|
10
|
+
# def build_fixture = Testing::ScriptedRuntime.new
|
|
11
|
+
# def build_adapter(fixture) = adapter wired to the fixture
|
|
12
|
+
#
|
|
13
|
+
# Then also include exactly one send matrix module matching the
|
|
14
|
+
# profile the adapter declares via #send_profile:
|
|
15
|
+
# AdapterContract::PlainPaneSendMatrix or
|
|
16
|
+
# AdapterContract::AgentAwareSendMatrix.
|
|
17
|
+
#
|
|
18
|
+
# The adapter must implement the full published intent API over
|
|
19
|
+
# the fixture's native ops and translate fixture signals at its
|
|
20
|
+
# boundary (FixtureUnavailable -> RuntimeUnavailableError,
|
|
21
|
+
# FixtureStall -> SendStalledError, FixtureTargetMissing ->
|
|
22
|
+
# TargetNotFoundError).
|
|
23
|
+
module AdapterContract
|
|
24
|
+
WAIT_TIMEOUT = 0.05
|
|
25
|
+
|
|
26
|
+
def setup
|
|
27
|
+
super
|
|
28
|
+
@contract_fixture = build_fixture
|
|
29
|
+
@contract_adapter = build_adapter(@contract_fixture)
|
|
30
|
+
@contract_pane = nil
|
|
31
|
+
@contract_setup_op_count = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :contract_fixture, :contract_adapter
|
|
35
|
+
|
|
36
|
+
def fixture
|
|
37
|
+
@contract_fixture
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def adapter
|
|
41
|
+
@contract_adapter
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- identity and context ---
|
|
45
|
+
|
|
46
|
+
def test_context_reports_scripted_environment
|
|
47
|
+
fixture.set_context(in_runtime: true, session: "main", window: "work", pane: "%1")
|
|
48
|
+
|
|
49
|
+
context = adapter.context
|
|
50
|
+
|
|
51
|
+
assert_equal true, context[:in_runtime]
|
|
52
|
+
assert_equal "main", context[:session]
|
|
53
|
+
assert_equal "work", context[:window]
|
|
54
|
+
assert_equal "%1", context[:pane]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_context_reports_absence_outside_runtime
|
|
58
|
+
fixture.set_context(in_runtime: false, session: nil, window: nil, pane: nil)
|
|
59
|
+
|
|
60
|
+
context = adapter.context
|
|
61
|
+
|
|
62
|
+
refute context[:in_runtime]
|
|
63
|
+
assert_nil context[:session]
|
|
64
|
+
assert_nil context[:window]
|
|
65
|
+
assert_nil context[:pane]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- ensure_window ---
|
|
69
|
+
|
|
70
|
+
def test_ensure_window_creates_window_with_sanitized_name
|
|
71
|
+
handle = adapter.ensure_window(name: "my work!", root: "/tmp/work")
|
|
72
|
+
|
|
73
|
+
assert_instance_of String, handle
|
|
74
|
+
refute handle.strip.empty?, "window handle must be opaque but present"
|
|
75
|
+
create = fixture.calls.find { |call| call[:op] == :create_window }
|
|
76
|
+
assert create, "expected a create_window transport call"
|
|
77
|
+
assert_equal "my-work", create[:name]
|
|
78
|
+
assert_equal "/tmp/work", create[:root]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_ensure_window_is_idempotent_by_name
|
|
82
|
+
first = adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
83
|
+
second = adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
84
|
+
|
|
85
|
+
assert_equal first, second
|
|
86
|
+
assert_equal 1, fixture.calls.count { |call| call[:op] == :create_window }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_ensure_window_conflicting_root_raises_window_conflict
|
|
90
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
91
|
+
|
|
92
|
+
error = assert_raises(WindowConflictError) do
|
|
93
|
+
adapter.ensure_window(name: "work", root: "/other/root")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
assert_match(/root|preset/i, error.message)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_ensure_window_conflicting_preset_raises_window_conflict
|
|
100
|
+
adapter.ensure_window(name: "work", root: "/tmp/work", preset: "main")
|
|
101
|
+
|
|
102
|
+
error = assert_raises(WindowConflictError) do
|
|
103
|
+
adapter.ensure_window(name: "work", root: "/tmp/work", preset: "other")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
assert_match(/preset/i, error.message)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- prepare_pane ---
|
|
110
|
+
|
|
111
|
+
def test_prepare_pane_splits_and_retains_a_writable_pane
|
|
112
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
113
|
+
|
|
114
|
+
pane = adapter.prepare_pane(window: "work")
|
|
115
|
+
|
|
116
|
+
assert_instance_of String, pane
|
|
117
|
+
refute pane.strip.empty?, "pane handle must be opaque but present"
|
|
118
|
+
assert fixture.calls.any? { |call| call[:op] == :split_window }, "expected a split"
|
|
119
|
+
assert_includes fixture.calls, {op: :set_keep_alive, pane: pane, value: true}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_prepare_pane_reuses_retained_pane_without_splitting
|
|
123
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
124
|
+
first = adapter.prepare_pane(window: "work")
|
|
125
|
+
fixture.calls.clear
|
|
126
|
+
|
|
127
|
+
second = adapter.prepare_pane(window: "work")
|
|
128
|
+
|
|
129
|
+
assert_equal first, second
|
|
130
|
+
assert_empty fixture.calls.select { |call| call[:op] == :split_window }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_prepare_pane_splits_when_only_a_bare_pane_exists
|
|
134
|
+
fixture.seed_window("work", root: "/tmp/work")
|
|
135
|
+
fixture.seed_pane("work", keep_alive: false)
|
|
136
|
+
bare_pane = fixture.panes.keys.first
|
|
137
|
+
|
|
138
|
+
pane = adapter.prepare_pane(window: "work")
|
|
139
|
+
|
|
140
|
+
assert fixture.calls.any? { |call| call[:op] == :split_window }, "bare pane must not satisfy preparation"
|
|
141
|
+
refute_equal bare_pane, pane
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def test_prepared_pane_stays_targetable_after_command_exit
|
|
145
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
146
|
+
pane = adapter.prepare_pane(window: "work")
|
|
147
|
+
fixture.set_output(pane, "command ran\n")
|
|
148
|
+
fixture.exit_pane(pane)
|
|
149
|
+
|
|
150
|
+
assert adapter.wait_lifecycle(condition: "pane-exists", target: pane, timeout: WAIT_TIMEOUT)
|
|
151
|
+
assert_equal "command ran\n", adapter.capture(pane: pane, lines: 10)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- focus / close / listing ---
|
|
155
|
+
|
|
156
|
+
def test_focus_selects_window
|
|
157
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
158
|
+
|
|
159
|
+
adapter.focus(window: "work")
|
|
160
|
+
|
|
161
|
+
assert fixture.calls.any? { |call| call[:op] == :focus_window && call[:name] == "work" }
|
|
162
|
+
entries = adapter.list_windows
|
|
163
|
+
assert entries.find { |entry| entry[:name] == "work" }[:active]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def test_close_window_removes_window
|
|
167
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
168
|
+
|
|
169
|
+
adapter.close_window(window: "work")
|
|
170
|
+
|
|
171
|
+
refute adapter.list_windows.any? { |entry| entry[:name] == "work" }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def test_list_windows_reports_scripted_windows
|
|
175
|
+
fixture.seed_window("alpha", root: "/tmp/a")
|
|
176
|
+
fixture.seed_window("beta", root: "/tmp/b")
|
|
177
|
+
|
|
178
|
+
names = adapter.list_windows.map { |entry| entry[:name] }
|
|
179
|
+
|
|
180
|
+
assert_equal %w[alpha beta], names.sort
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_list_panes_reports_opaque_handles
|
|
184
|
+
fixture.seed_window("work", root: "/tmp/work")
|
|
185
|
+
fixture.seed_pane("work")
|
|
186
|
+
fixture.seed_pane("work")
|
|
187
|
+
|
|
188
|
+
entries = adapter.list_panes(window: "work")
|
|
189
|
+
|
|
190
|
+
assert_equal 2, entries.length
|
|
191
|
+
entries.each do |entry|
|
|
192
|
+
assert_instance_of String, entry[:pane]
|
|
193
|
+
refute entry[:pane].strip.empty?
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- capture ---
|
|
198
|
+
|
|
199
|
+
def test_capture_returns_scripted_output
|
|
200
|
+
pane = prepare_target!
|
|
201
|
+
fixture.set_output(pane, "line one\nline two\n")
|
|
202
|
+
|
|
203
|
+
assert_equal "line one\nline two\n", adapter.capture(pane: pane, lines: 40)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def test_capture_is_bounded_by_lines
|
|
207
|
+
pane = prepare_target!
|
|
208
|
+
fixture.set_output(pane, "l1\nl2\nl3\n")
|
|
209
|
+
|
|
210
|
+
assert_equal "l2\nl3\n", adapter.capture(pane: pane, lines: 2)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def test_capture_missing_pane_raises_target_not_found
|
|
214
|
+
prepare_target!
|
|
215
|
+
|
|
216
|
+
assert_raises(TargetNotFoundError) do
|
|
217
|
+
adapter.capture(pane: "%999", lines: 10)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# --- waits ---
|
|
222
|
+
|
|
223
|
+
def test_wait_output_returns_when_pattern_present
|
|
224
|
+
pane = prepare_target!
|
|
225
|
+
fixture.set_output(pane, "build finished\n")
|
|
226
|
+
|
|
227
|
+
assert adapter.wait_output(pane: pane, pattern: "build finished", timeout: WAIT_TIMEOUT)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_wait_output_times_out_with_seconds_context
|
|
231
|
+
pane = prepare_target!
|
|
232
|
+
|
|
233
|
+
error = assert_raises(WaitTimeoutError) do
|
|
234
|
+
adapter.wait_output(pane: pane, pattern: "never appears", timeout: WAIT_TIMEOUT)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
assert_equal WAIT_TIMEOUT, error.timeout
|
|
238
|
+
assert_match(/output/, error.message)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def test_wait_agent_returns_when_state_matches
|
|
242
|
+
pane = prepare_target!
|
|
243
|
+
fixture.set_agent_state(pane, "working")
|
|
244
|
+
|
|
245
|
+
assert adapter.wait_agent(pane: pane, states: ["idle", "working"], timeout: WAIT_TIMEOUT)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def test_wait_agent_times_out_when_state_never_matches
|
|
249
|
+
pane = prepare_target!
|
|
250
|
+
fixture.set_agent_state(pane, "working")
|
|
251
|
+
|
|
252
|
+
error = assert_raises(WaitTimeoutError) do
|
|
253
|
+
adapter.wait_agent(pane: pane, states: ["idle"], timeout: WAIT_TIMEOUT)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
assert_equal WAIT_TIMEOUT, error.timeout
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def test_wait_lifecycle_rejects_conditions_outside_the_observation_set
|
|
260
|
+
pane = prepare_target!
|
|
261
|
+
|
|
262
|
+
error = assert_raises(ArgumentError) do
|
|
263
|
+
adapter.wait_lifecycle(condition: "subtree-complete", target: pane, timeout: WAIT_TIMEOUT)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
assert_match(/window-exists|lifecycle/, error.message)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def test_lifecycle_window_conditions
|
|
270
|
+
adapter.ensure_window(name: "work", root: "/tmp/work")
|
|
271
|
+
|
|
272
|
+
assert adapter.wait_lifecycle(condition: "window-exists", target: "work", timeout: WAIT_TIMEOUT)
|
|
273
|
+
|
|
274
|
+
adapter.focus(window: "work")
|
|
275
|
+
|
|
276
|
+
assert adapter.wait_lifecycle(condition: "window-active", target: "work", timeout: WAIT_TIMEOUT)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def test_lifecycle_pane_conditions
|
|
280
|
+
pane = prepare_target!
|
|
281
|
+
|
|
282
|
+
assert adapter.wait_lifecycle(condition: "pane-exists", target: pane, timeout: WAIT_TIMEOUT)
|
|
283
|
+
|
|
284
|
+
fixture.exit_pane(pane)
|
|
285
|
+
|
|
286
|
+
assert adapter.wait_lifecycle(condition: "pane-exited", target: pane, timeout: WAIT_TIMEOUT)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def test_lifecycle_condition_times_out_when_unmet
|
|
290
|
+
pane = prepare_target!
|
|
291
|
+
|
|
292
|
+
error = assert_raises(WaitTimeoutError) do
|
|
293
|
+
adapter.wait_lifecycle(condition: "pane-exited", target: pane, timeout: WAIT_TIMEOUT)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
assert_equal WAIT_TIMEOUT, error.timeout
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# pane-exited is an observation only: the contract never treats
|
|
300
|
+
# it as assignment success, receipt confirmation, or prune
|
|
301
|
+
# authorization. That boundary is enforced above the contract;
|
|
302
|
+
# the suite asserts the observation itself.
|
|
303
|
+
def test_pane_exited_is_observation_only
|
|
304
|
+
fixture.seed_window("work", root: "/tmp/work")
|
|
305
|
+
pane = fixture.seed_pane("work")
|
|
306
|
+
fixture.exit_pane(pane)
|
|
307
|
+
|
|
308
|
+
assert adapter.wait_lifecycle(condition: "pane-exited", target: pane, timeout: WAIT_TIMEOUT)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# --- error model ---
|
|
312
|
+
|
|
313
|
+
# Targeted operations must translate native "missing target"
|
|
314
|
+
# failures into the typed contract error; a leaked fixture or
|
|
315
|
+
# native error class fails the acceptance bar.
|
|
316
|
+
def test_missing_targets_raise_target_not_found_on_targeted_operations
|
|
317
|
+
prepare_target!
|
|
318
|
+
|
|
319
|
+
assert_raises(TargetNotFoundError) { adapter.focus(window: "no-such-window") }
|
|
320
|
+
assert_raises(TargetNotFoundError) { adapter.close_window(window: "no-such-window") }
|
|
321
|
+
assert_raises(TargetNotFoundError) { adapter.prepare_pane(window: "no-such-window") }
|
|
322
|
+
assert_raises(TargetNotFoundError) { adapter.send(pane: "%999", command: "run", items: []) }
|
|
323
|
+
assert_raises(TargetNotFoundError) { adapter.send_text(pane: "%999", text: "hi") }
|
|
324
|
+
assert_raises(TargetNotFoundError) { adapter.send_keys(pane: "%999", keys: ["C-c"]) }
|
|
325
|
+
assert_raises(TargetNotFoundError) { adapter.capture(pane: "%999", lines: 10) }
|
|
326
|
+
assert_raises(TargetNotFoundError) { adapter.wait_output(pane: "%999", pattern: "x", timeout: WAIT_TIMEOUT) }
|
|
327
|
+
assert_raises(TargetNotFoundError) { adapter.wait_agent(pane: "%999", states: ["idle"], timeout: WAIT_TIMEOUT) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Listing an absent window is an empty set (poll-friendly), but
|
|
331
|
+
# every transport operation must fail typed when the runtime is
|
|
332
|
+
# unreachable — never silently succeed or leak a native error.
|
|
333
|
+
def test_unavailable_runtime_raises_runtime_unavailable_on_transport_operations
|
|
334
|
+
prepare_target!
|
|
335
|
+
fixture.unavailable = true
|
|
336
|
+
|
|
337
|
+
assert_raises(RuntimeUnavailableError) { adapter.ensure_window(name: "other", root: "/tmp/other") }
|
|
338
|
+
assert_raises(RuntimeUnavailableError) { adapter.prepare_pane(window: "work") }
|
|
339
|
+
assert_raises(RuntimeUnavailableError) { adapter.focus(window: "work") }
|
|
340
|
+
assert_raises(RuntimeUnavailableError) { adapter.send(pane: @contract_pane, command: "run", items: []) }
|
|
341
|
+
assert_raises(RuntimeUnavailableError) { adapter.send_command(pane: @contract_pane, command: "run") }
|
|
342
|
+
assert_raises(RuntimeUnavailableError) { adapter.capture(pane: @contract_pane, lines: 10) }
|
|
343
|
+
assert_raises(RuntimeUnavailableError) { adapter.wait_output(pane: @contract_pane, pattern: "x", timeout: WAIT_TIMEOUT) }
|
|
344
|
+
assert_raises(RuntimeUnavailableError) { adapter.wait_agent(pane: @contract_pane, states: ["idle"], timeout: WAIT_TIMEOUT) }
|
|
345
|
+
assert_raises(RuntimeUnavailableError) { adapter.wait_lifecycle(condition: "window-exists", target: "work", timeout: WAIT_TIMEOUT) }
|
|
346
|
+
assert_raises(RuntimeUnavailableError) { adapter.close_window(window: "work") }
|
|
347
|
+
assert_raises(RuntimeUnavailableError) { adapter.list_windows }
|
|
348
|
+
assert_raises(RuntimeUnavailableError) { adapter.list_panes(window: "work") }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def test_stalled_send_raises_send_stalled_error_and_is_never_auto_resent
|
|
352
|
+
pane = prepare_target!
|
|
353
|
+
|
|
354
|
+
fixture.stall_next_send!
|
|
355
|
+
|
|
356
|
+
assert_raises(SendStalledError) do
|
|
357
|
+
adapter.send(pane: pane, command: "run", items: [])
|
|
358
|
+
end
|
|
359
|
+
assert_empty transport_calls, "a stalled submission must not have delivered anything"
|
|
360
|
+
|
|
361
|
+
# The contract never auto-resends; an explicit caller retry is
|
|
362
|
+
# a new submission and may deliver.
|
|
363
|
+
adapter.send(pane: pane, command: "run", items: [])
|
|
364
|
+
expected_delivery = adapter.send_profile == :plain_pane ? 2 : 1
|
|
365
|
+
assert_equal expected_delivery, transport_calls.length
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def test_send_to_missing_pane_raises_target_not_found
|
|
369
|
+
prepare_target!
|
|
370
|
+
|
|
371
|
+
assert_raises(TargetNotFoundError) do
|
|
372
|
+
adapter.send(pane: "%999", command: "run", items: [])
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# --- send matrix (common, both profiles) ---
|
|
377
|
+
|
|
378
|
+
def test_empty_send_is_rejected_before_transport
|
|
379
|
+
with_target do
|
|
380
|
+
assert_rejected_before_transport(command: nil, items: [])
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def test_invalid_item_shape_is_rejected_before_transport
|
|
385
|
+
with_target do
|
|
386
|
+
assert_rejected_before_transport(command: nil, items: [{blob: "x"}])
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def test_command_with_message_item_is_rejected_before_transport
|
|
391
|
+
with_target do
|
|
392
|
+
assert_rejected_before_transport(command: "run", items: [{message: "hi"}])
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def test_send_result_reports_when_no_enter_was_dropped
|
|
397
|
+
pane = prepare_target!
|
|
398
|
+
|
|
399
|
+
result = adapter.send(pane: pane, command: "run", items: [])
|
|
400
|
+
|
|
401
|
+
refute result.dropped_trailing_enter
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# --- shared helpers ---
|
|
405
|
+
|
|
406
|
+
private
|
|
407
|
+
|
|
408
|
+
# Transport ops recorded after target preparation — the calls
|
|
409
|
+
# the code under test actually made.
|
|
410
|
+
def transport_calls
|
|
411
|
+
fixture.calls.drop(@contract_setup_op_count || 0)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Prepares a retained target and returns its opaque pane handle.
|
|
415
|
+
def prepare_target!(name: "work", root: "/tmp/ace-runtime-contract")
|
|
416
|
+
adapter.ensure_window(name: name, root: root)
|
|
417
|
+
pane = adapter.prepare_pane(window: name)
|
|
418
|
+
fixture.set_output(pane, "ready\n")
|
|
419
|
+
@contract_setup_op_count = fixture.calls.length
|
|
420
|
+
@contract_pane = pane
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def with_target
|
|
424
|
+
prepare_target! unless @contract_pane
|
|
425
|
+
yield
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def assert_rejected_before_transport(command:, items:)
|
|
429
|
+
error = assert_raises(SendRejectedError) do
|
|
430
|
+
adapter.send(pane: @contract_pane, command: command, items: items)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
assert_empty transport_calls, "rejected send must not reach transport"
|
|
434
|
+
error
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
module AdapterContract
|
|
439
|
+
# Normative matrix for adapters whose send_profile is
|
|
440
|
+
# :plain_pane: messages are raw text (no implicit submission),
|
|
441
|
+
# keys are keystrokes, each Enter submits pending text, and
|
|
442
|
+
# command submits once before its trailing keys.
|
|
443
|
+
module PlainPaneSendMatrix
|
|
444
|
+
def test_ordered_items_deliver_distinctly
|
|
445
|
+
prepare_target!
|
|
446
|
+
|
|
447
|
+
adapter.send(
|
|
448
|
+
pane: @contract_pane,
|
|
449
|
+
command: nil,
|
|
450
|
+
items: [{message: "one"}, {key: "Enter"}, {message: "two"}]
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
assert_equal [
|
|
454
|
+
{op: :write_text, pane: @contract_pane, text: "one"},
|
|
455
|
+
{op: :press_key, pane: @contract_pane, key: "Enter"},
|
|
456
|
+
{op: :write_text, pane: @contract_pane, text: "two"}
|
|
457
|
+
], transport_calls
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def test_messages_with_single_trailing_enter_submit_exactly_once
|
|
461
|
+
prepare_target!
|
|
462
|
+
|
|
463
|
+
result = adapter.send(
|
|
464
|
+
pane: @contract_pane,
|
|
465
|
+
command: nil,
|
|
466
|
+
items: [{message: "hello"}, {key: "Enter"}]
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
assert_equal [
|
|
470
|
+
{op: :write_text, pane: @contract_pane, text: "hello"},
|
|
471
|
+
{op: :press_key, pane: @contract_pane, key: "Enter"}
|
|
472
|
+
], transport_calls
|
|
473
|
+
refute result.dropped_trailing_enter
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def test_command_submits_once_then_delivers_trailing_keys
|
|
477
|
+
prepare_target!
|
|
478
|
+
|
|
479
|
+
adapter.send(pane: @contract_pane, command: "run", items: [{key: "C-c"}])
|
|
480
|
+
|
|
481
|
+
assert_equal [
|
|
482
|
+
{op: :write_text, pane: @contract_pane, text: "run"},
|
|
483
|
+
{op: :press_key, pane: @contract_pane, key: "Enter"},
|
|
484
|
+
{op: :press_key, pane: @contract_pane, key: "C-c"}
|
|
485
|
+
], transport_calls
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def test_convenience_shapes_delegate_to_send
|
|
489
|
+
prepare_target!
|
|
490
|
+
|
|
491
|
+
adapter.send_command(pane: @contract_pane, command: "run")
|
|
492
|
+
adapter.send_text(pane: @contract_pane, text: "hi")
|
|
493
|
+
adapter.send_keys(pane: @contract_pane, keys: %w[C-c C-r])
|
|
494
|
+
|
|
495
|
+
assert_equal [
|
|
496
|
+
{op: :write_text, pane: @contract_pane, text: "run"},
|
|
497
|
+
{op: :press_key, pane: @contract_pane, key: "Enter"},
|
|
498
|
+
{op: :write_text, pane: @contract_pane, text: "hi"},
|
|
499
|
+
{op: :press_key, pane: @contract_pane, key: "C-c"},
|
|
500
|
+
{op: :press_key, pane: @contract_pane, key: "C-r"}
|
|
501
|
+
], transport_calls
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Normative matrix for adapters whose send_profile is
|
|
506
|
+
# :agent_aware: command or the concatenated messages become ONE
|
|
507
|
+
# self-submitting prompt, at most one trailing Enter is dropped
|
|
508
|
+
# and reported, keys-only sequences go to the agent, and every
|
|
509
|
+
# other interleaving is rejected before any transport call.
|
|
510
|
+
module AgentAwareSendMatrix
|
|
511
|
+
def test_messages_deliver_as_one_prompt_without_enter
|
|
512
|
+
prepare_target!
|
|
513
|
+
|
|
514
|
+
result = adapter.send(
|
|
515
|
+
pane: @contract_pane,
|
|
516
|
+
command: nil,
|
|
517
|
+
items: [{message: "line one"}, {message: "line two"}]
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
assert_equal [{op: :write_text, pane: @contract_pane, text: "line one\nline two"}], transport_calls
|
|
521
|
+
refute result.dropped_trailing_enter
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def test_single_trailing_enter_after_messages_is_dropped_and_reported
|
|
525
|
+
prepare_target!
|
|
526
|
+
|
|
527
|
+
result = adapter.send(
|
|
528
|
+
pane: @contract_pane,
|
|
529
|
+
command: nil,
|
|
530
|
+
items: [{message: "hello"}, {key: "Enter"}]
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
assert_equal [{op: :write_text, pane: @contract_pane, text: "hello"}], transport_calls
|
|
534
|
+
assert result.dropped_trailing_enter
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def test_command_with_single_trailing_enter_is_dropped_and_reported
|
|
538
|
+
prepare_target!
|
|
539
|
+
|
|
540
|
+
result = adapter.send(pane: @contract_pane, command: "run", items: [{key: "Enter"}])
|
|
541
|
+
|
|
542
|
+
assert_equal [{op: :write_text, pane: @contract_pane, text: "run"}], transport_calls
|
|
543
|
+
assert result.dropped_trailing_enter
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def test_keys_only_sequences_go_to_agent_keys
|
|
547
|
+
prepare_target!
|
|
548
|
+
|
|
549
|
+
result = adapter.send(
|
|
550
|
+
pane: @contract_pane,
|
|
551
|
+
command: nil,
|
|
552
|
+
items: [{key: "C-c"}, {key: "C-r"}]
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
assert_equal [
|
|
556
|
+
{op: :press_key, pane: @contract_pane, key: "C-c"},
|
|
557
|
+
{op: :press_key, pane: @contract_pane, key: "C-r"}
|
|
558
|
+
], transport_calls
|
|
559
|
+
refute result.dropped_trailing_enter
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def test_send_text_delivers_single_prompt
|
|
563
|
+
prepare_target!
|
|
564
|
+
|
|
565
|
+
adapter.send_text(pane: @contract_pane, text: "hello")
|
|
566
|
+
|
|
567
|
+
assert_equal [{op: :write_text, pane: @contract_pane, text: "hello"}], transport_calls
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def test_keys_between_messages_rejected_before_transport
|
|
571
|
+
prepare_target!
|
|
572
|
+
|
|
573
|
+
assert_rejected_before_transport(
|
|
574
|
+
command: nil,
|
|
575
|
+
items: [{message: "a"}, {key: "C-c"}, {message: "b"}]
|
|
576
|
+
)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def test_multiple_enters_rejected_before_transport
|
|
580
|
+
prepare_target!
|
|
581
|
+
|
|
582
|
+
assert_rejected_before_transport(
|
|
583
|
+
command: nil,
|
|
584
|
+
items: [{message: "a"}, {key: "Enter"}, {key: "Enter"}]
|
|
585
|
+
)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def test_command_with_non_enter_key_rejected_before_transport
|
|
589
|
+
prepare_target!
|
|
590
|
+
|
|
591
|
+
assert_rejected_before_transport(command: "run", items: [{key: "C-c"}])
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|