ace-demo 0.25.1 → 0.25.7
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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +9 -2
- data/lib/ace/demo/atoms/asciinema_tape_compiler.rb +2 -0
- data/lib/ace/demo/atoms/demo_yaml_parser.rb +91 -7
- data/lib/ace/demo/atoms/record_option_validator.rb +14 -0
- data/lib/ace/demo/atoms/vhs_tape_compiler.rb +2 -0
- data/lib/ace/demo/atoms/yaml_record_planner.rb +1 -0
- data/lib/ace/demo/cli/commands/record.rb +2 -0
- data/lib/ace/demo/cli/commands/verify.rb +2 -0
- data/lib/ace/demo/molecules/asciinema_executor.rb +11 -3
- data/lib/ace/demo/molecules/cast_verifier.rb +21 -5
- data/lib/ace/demo/molecules/tmux_directive_executor.rb +80 -0
- data/lib/ace/demo/molecules/verification_report_writer.rb +10 -0
- data/lib/ace/demo/organisms/demo_recorder.rb +39 -5
- data/lib/ace/demo/version.rb +1 -1
- data/lib/ace/demo.rb +2 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 711c81d675176437c438b14b23f02b2ad77c54f0a5073c53e19b36dbb6a0056d
|
|
4
|
+
data.tar.gz: ea03931e4c6316d7c7303cad0cc4386b3454a785a95fd291143c237143fd9f35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 051bea3997c221ad9d227da73e3f8d8a93b9c7b5d92814c7179d7889a3493ec0216b018a6ce4fd5c1e5476294d9ec8abf1ad58c35f825d861ead760ec76538f2
|
|
7
|
+
data.tar.gz: 40ec891204385d4536952565538d53a683ce944a988808c1e8d2db558a606bc36db454bed206ff26d2708a3223c0e5d53a99489bfec96218415ddb89a32ac5df
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.25.7] - 2026-06-30
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Failed YAML tape verification by default when the final asciinema shell exit is non-zero, unless `verify.allow_nonzero_exit: true` opts out explicitly.
|
|
14
|
+
- Surfaced non-zero cast exit codes in `record` / `verify` output and verification reports so broken recordings are diagnosable without opening the raw cast.
|
|
15
|
+
|
|
16
|
+
### Technical
|
|
17
|
+
- Stabilized demo recorder fast tests against the current working directory used for generated visual and cast paths.
|
|
18
|
+
|
|
19
|
+
## [0.25.6] - 2026-04-16
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Added additive YAML `tmux:` recorder-control directives for asciinema-backed demo tapes, covering shared-surface `attach`, `detach`, `wait`, `send`, and optional `capture` actions alongside existing visible `type:` commands.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Updated `ace-demo` recording/docs to treat tmux orchestration as structured recorder control instead of canonical raw tmux shell glue.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Updated the `ace-tmux` runtime dependency constraint to `~> 0.17` so recorder-side tmux directives stay aligned with the new runtime inspection release line.
|
|
29
|
+
- Rejected unsupported YAML `tmux.action: capture` directives during parsing, forwarded recording env values into recorder-side tmux control resolution, and clarified that tmux directives are supported only for the asciinema backend.
|
|
30
|
+
- Rejected YAML `tmux:` directives when the resolved recording backend is `vhs` so unsupported backend/control-surface combinations fail fast instead of being silently ignored during VHS compilation.
|
|
31
|
+
|
|
10
32
|
## [0.25.1] - 2026-04-16
|
|
11
33
|
|
|
12
34
|
### Fixed
|
data/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|

|
|
20
20
|
|
|
21
|
-
`ace-demo` records terminal sessions as proof-of-work evidence for agent-driven workflows. Tapes define what to capture — either as simple [VHS](https://github.com/charmbracelet/vhs) scripts (`.tape`) or as YAML specs (`.tape.yml`) with sandbox setup, scenes, and
|
|
21
|
+
`ace-demo` records terminal sessions as proof-of-work evidence for agent-driven workflows. Tapes define what to capture — either as simple [VHS](https://github.com/charmbracelet/vhs) scripts (`.tape`) or as YAML specs (`.tape.yml`) with sandbox setup, scenes, teardown, and optional tmux recorder-control directives.
|
|
22
22
|
|
|
23
23
|
Recordings attach directly to GitHub pull requests as reviewable evidence. Requires `vhs`, `chromium`, and `ttyd` for deterministic rendering (see [setup requirements](docs/setup.md)).
|
|
24
24
|
|
|
@@ -43,6 +43,11 @@ scenes:
|
|
|
43
43
|
commands:
|
|
44
44
|
- type: ace-demo list
|
|
45
45
|
sleep: 4s
|
|
46
|
+
- tmux:
|
|
47
|
+
action: wait
|
|
48
|
+
for: window-active
|
|
49
|
+
session: fork-demo
|
|
50
|
+
window: work
|
|
46
51
|
- type: ace-demo record hello
|
|
47
52
|
sleep: 6s
|
|
48
53
|
|
|
@@ -51,10 +56,12 @@ teardown:
|
|
|
51
56
|
```
|
|
52
57
|
|
|
53
58
|
- **setup** — sandbox isolation, git init, fixture copying, or arbitrary shell via `run: <cmd>`
|
|
54
|
-
- **scenes** — named command sequences
|
|
59
|
+
- **scenes** — named command sequences with visible `type:` shell commands and optional `tmux:` recorder-control directives for asciinema-backed recordings
|
|
55
60
|
- **teardown** — cleanup directives that always run (even on failure)
|
|
56
61
|
- **settings** — optional `font_size`, `width`, `height`, `format` overrides
|
|
57
62
|
|
|
63
|
+
Use `type:` for on-camera shell commands that should appear in the recording. Use `tmux:` only with the asciinema backend for recorder-control actions such as `attach`, `detach`, `wait`, and `send` when the demo needs deterministic tmux choreography without raw shell glue. YAML tapes recorded with the `vhs` backend must not include `tmux:` directives.
|
|
64
|
+
|
|
58
65
|
Legacy `.tape` files use raw VHS syntax directly. See the [Usage Guide](docs/usage.md) for the full tape specification.
|
|
59
66
|
|
|
60
67
|
## Use Cases
|
|
@@ -27,6 +27,8 @@ module Ace
|
|
|
27
27
|
lines << "# Scene: #{scene_name}" unless scene_name.to_s.strip.empty?
|
|
28
28
|
|
|
29
29
|
scene.fetch("commands", []).each do |command|
|
|
30
|
+
next unless command["type"]
|
|
31
|
+
|
|
30
32
|
lines << command.fetch("type")
|
|
31
33
|
sleep_value = validate_sleep!(command["sleep"] || default_timeout)
|
|
32
34
|
lines << "sleep #{sleep_value}"
|
|
@@ -170,6 +170,13 @@ module Ace
|
|
|
170
170
|
raise DemoYamlParseError, "verify must be a map in #{source_path}" unless verify.is_a?(Hash)
|
|
171
171
|
|
|
172
172
|
normalized = {}
|
|
173
|
+
if verify.key?("allow_nonzero_exit")
|
|
174
|
+
normalized["allow_nonzero_exit"] = normalize_boolean(
|
|
175
|
+
verify["allow_nonzero_exit"],
|
|
176
|
+
"verify.allow_nonzero_exit",
|
|
177
|
+
source_path
|
|
178
|
+
)
|
|
179
|
+
end
|
|
173
180
|
normalized["require_vars"] = normalize_string_list(verify["require_vars"], "verify.require_vars", source_path) if verify.key?("require_vars")
|
|
174
181
|
normalized["require_output"] = normalize_string_list(verify["require_output"], "verify.require_output", source_path) if verify.key?("require_output")
|
|
175
182
|
if verify.key?("require_output_sequence")
|
|
@@ -185,6 +192,13 @@ module Ace
|
|
|
185
192
|
end
|
|
186
193
|
private_class_method :normalize_verify
|
|
187
194
|
|
|
195
|
+
def normalize_boolean(value, field, source_path)
|
|
196
|
+
return value if value == true || value == false
|
|
197
|
+
|
|
198
|
+
raise DemoYamlParseError, "#{field} must be a boolean in #{source_path}"
|
|
199
|
+
end
|
|
200
|
+
private_class_method :normalize_boolean
|
|
201
|
+
|
|
188
202
|
def normalize_string_list(value, field, source_path)
|
|
189
203
|
raise DemoYamlParseError, "#{field} must be an array in #{source_path}" unless value.is_a?(Array)
|
|
190
204
|
|
|
@@ -205,18 +219,88 @@ module Ace
|
|
|
205
219
|
"scenes[#{scene_index}].commands[#{command_index}] must be a map in #{source_path}"
|
|
206
220
|
end
|
|
207
221
|
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
normalized = {"sleep" => command["sleep"]&.to_s}.compact
|
|
223
|
+
has_type = !command["type"].to_s.strip.empty?
|
|
224
|
+
has_tmux = command["tmux"].is_a?(Hash)
|
|
225
|
+
|
|
226
|
+
if has_type && has_tmux
|
|
210
227
|
raise DemoYamlParseError,
|
|
211
|
-
"scenes[#{scene_index}].commands[#{command_index}]
|
|
228
|
+
"scenes[#{scene_index}].commands[#{command_index}] cannot define both type and tmux in #{source_path}"
|
|
212
229
|
end
|
|
213
230
|
|
|
214
|
-
|
|
215
|
-
"type"
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
if has_type
|
|
232
|
+
normalized["type"] = command["type"].to_s
|
|
233
|
+
return normalized
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if has_tmux
|
|
237
|
+
normalized["tmux"] = normalize_tmux_command(
|
|
238
|
+
command["tmux"],
|
|
239
|
+
scene_index,
|
|
240
|
+
command_index,
|
|
241
|
+
source_path: source_path
|
|
242
|
+
)
|
|
243
|
+
return normalized
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
raise DemoYamlParseError,
|
|
247
|
+
"scenes[#{scene_index}].commands[#{command_index}] must define either type or tmux in #{source_path}"
|
|
218
248
|
end
|
|
219
249
|
private_class_method :normalize_command
|
|
250
|
+
|
|
251
|
+
def normalize_tmux_command(tmux, scene_index, command_index, source_path:)
|
|
252
|
+
directive = tmux.transform_keys(&:to_s)
|
|
253
|
+
action = directive["action"]&.to_s&.strip
|
|
254
|
+
if action.nil? || action.empty?
|
|
255
|
+
raise DemoYamlParseError,
|
|
256
|
+
"scenes[#{scene_index}].commands[#{command_index}].tmux.action is required in #{source_path}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
allowed = %w[attach detach wait send]
|
|
260
|
+
unless allowed.include?(action)
|
|
261
|
+
raise DemoYamlParseError,
|
|
262
|
+
"Unknown tmux action '#{action}' in #{source_path}. Allowed: #{allowed.join(', ')}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
normalized = {"action" => action}
|
|
266
|
+
%w[session window pane pattern].each do |field|
|
|
267
|
+
normalized[field] = directive[field].to_s if directive.key?(field)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
case action
|
|
271
|
+
when "attach", "detach"
|
|
272
|
+
normalized["session"] = required_string!(directive, "session", scene_index, command_index, source_path)
|
|
273
|
+
when "wait"
|
|
274
|
+
normalized["for"] = required_string!(directive, "for", scene_index, command_index, source_path)
|
|
275
|
+
normalized["pattern"] = normalized["pattern"] if normalized["pattern"]
|
|
276
|
+
normalized["timeout"] = Float(directive["timeout"]) if directive.key?("timeout")
|
|
277
|
+
when "send"
|
|
278
|
+
command_text = directive["command"]&.to_s&.strip
|
|
279
|
+
key_text = directive["key"]&.to_s&.strip
|
|
280
|
+
if command_text.to_s.empty? == key_text.to_s.empty?
|
|
281
|
+
raise DemoYamlParseError,
|
|
282
|
+
"tmux send must define exactly one of command or key in #{source_path}"
|
|
283
|
+
end
|
|
284
|
+
normalized["command"] = command_text unless command_text.to_s.empty?
|
|
285
|
+
normalized["key"] = key_text unless key_text.to_s.empty?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
normalized
|
|
289
|
+
rescue ArgumentError, TypeError => e
|
|
290
|
+
raise DemoYamlParseError, "#{e.message} (#{source_path})"
|
|
291
|
+
end
|
|
292
|
+
private_class_method :normalize_tmux_command
|
|
293
|
+
|
|
294
|
+
def required_string!(directive, field, scene_index, command_index, source_path)
|
|
295
|
+
value = directive[field]&.to_s&.strip
|
|
296
|
+
if value.nil? || value.empty?
|
|
297
|
+
raise DemoYamlParseError,
|
|
298
|
+
"scenes[#{scene_index}].commands[#{command_index}].tmux.#{field} is required in #{source_path}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
value
|
|
302
|
+
end
|
|
303
|
+
private_class_method :required_string!
|
|
220
304
|
end
|
|
221
305
|
end
|
|
222
306
|
end
|
|
@@ -34,11 +34,25 @@ module Ace
|
|
|
34
34
|
raise ArgumentError, "Format 'webm' requires --backend vhs when recording YAML tapes"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def validate_yaml_backend_capabilities!(backend:, spec:)
|
|
38
|
+
return unless backend == "vhs"
|
|
39
|
+
return unless yaml_uses_tmux_directives?(spec)
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "Backend 'vhs' does not support tmux directives in YAML tapes; use backend 'asciinema' or remove tmux commands"
|
|
42
|
+
end
|
|
43
|
+
|
|
37
44
|
def validate_raw_tape_backend!(backend:)
|
|
38
45
|
return if backend.nil? || backend == "vhs"
|
|
39
46
|
|
|
40
47
|
raise ArgumentError, "Raw .tape recordings support backend 'vhs' only"
|
|
41
48
|
end
|
|
49
|
+
|
|
50
|
+
def yaml_uses_tmux_directives?(spec)
|
|
51
|
+
Array(spec["scenes"]).any? do |scene|
|
|
52
|
+
Array(scene["commands"]).any? { |command| command.is_a?(Hash) && command["tmux"].is_a?(Hash) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private_class_method :yaml_uses_tmux_directives?
|
|
42
56
|
end
|
|
43
57
|
end
|
|
44
58
|
end
|
|
@@ -26,6 +26,8 @@ module Ace
|
|
|
26
26
|
lines << "# Scene: #{scene_name}" unless scene_name.to_s.strip.empty?
|
|
27
27
|
|
|
28
28
|
scene.fetch("commands", []).each do |command|
|
|
29
|
+
next unless command["type"]
|
|
30
|
+
|
|
29
31
|
type_text = command.fetch("type")
|
|
30
32
|
if type_text.include?('"') || type_text.include?("$") || type_text.include?("\\")
|
|
31
33
|
lines << "Type `#{type_text}`"
|
|
@@ -28,6 +28,7 @@ module Ace
|
|
|
28
28
|
allow_nil: false
|
|
29
29
|
)
|
|
30
30
|
RecordOptionValidator.validate_yaml_backend_format!(backend: selected_backend, format: selected_format)
|
|
31
|
+
RecordOptionValidator.validate_yaml_backend_capabilities!(backend: selected_backend, spec: spec)
|
|
31
32
|
|
|
32
33
|
selected_speed = playback_speed.nil? ? settings["playback_speed"] : playback_speed
|
|
33
34
|
selected_speed = Atoms::PlaybackSpeedParser.parse(selected_speed)
|
|
@@ -322,6 +322,8 @@ module Ace
|
|
|
322
322
|
puts "Missing output: #{missing_output.join(', ')}" unless missing_output.empty?
|
|
323
323
|
missing_sequence = verification.details&.fetch(:missing_output_sequence, []) || []
|
|
324
324
|
puts "Missing output sequence: #{missing_sequence.join(' -> ')}" unless missing_sequence.empty?
|
|
325
|
+
exit_code = verification.details&.fetch(:exit_code, nil)
|
|
326
|
+
puts "Exit code: #{exit_code}" unless exit_code.nil? || exit_code.zero?
|
|
325
327
|
puts "Assertions replay: skipped" if verification.details&.fetch(:assertions_skipped, false)
|
|
326
328
|
end
|
|
327
329
|
|
|
@@ -61,6 +61,8 @@ module Ace
|
|
|
61
61
|
puts "Missing output: #{missing_output.join(', ')}" unless missing_output.empty?
|
|
62
62
|
missing_sequence = verification.details&.fetch(:missing_output_sequence, [])
|
|
63
63
|
puts "Missing output sequence: #{missing_sequence.join(' -> ')}" unless missing_sequence.empty?
|
|
64
|
+
exit_code = verification.details&.fetch(:exit_code, nil)
|
|
65
|
+
puts "Exit code: #{exit_code}" unless exit_code.nil? || exit_code.zero?
|
|
64
66
|
puts "Assertions replay: skipped" if verification.details&.fetch(:assertions_skipped, false)
|
|
65
67
|
end
|
|
66
68
|
end
|
|
@@ -42,7 +42,7 @@ module Ace
|
|
|
42
42
|
raise AsciinemaNotFoundError, "Asciinema not found (#{effective_bin}). Install: #{INSTALL_URL}"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
def run_interactive(cmd, commands:, env: {}, asciinema_bin: "asciinema", chdir: nil)
|
|
45
|
+
def run_interactive(cmd, commands:, env: {}, handler: nil, asciinema_bin: "asciinema", chdir: nil)
|
|
46
46
|
effective_bin = cmd.first || asciinema_bin
|
|
47
47
|
options = {}
|
|
48
48
|
options[:chdir] = chdir if chdir
|
|
@@ -80,8 +80,16 @@ module Ace
|
|
|
80
80
|
wait_for_prompt(buffer, buffer_mutex, buffer_cv)
|
|
81
81
|
|
|
82
82
|
commands.each do |command|
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
if handler && command[:kind] != :shell
|
|
84
|
+
outcome = handler.call(command, write_io: write_io)
|
|
85
|
+
if outcome.is_a?(Hash) && outcome[:shell_command]
|
|
86
|
+
write_io.write("#{outcome[:shell_command]}\n")
|
|
87
|
+
write_io.flush
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
write_io.write("#{command.fetch(:command)}\n")
|
|
91
|
+
write_io.flush
|
|
92
|
+
end
|
|
85
93
|
@sleeper.sleep(command.fetch(:sleep))
|
|
86
94
|
end
|
|
87
95
|
|
|
@@ -26,6 +26,8 @@ module Ace
|
|
|
26
26
|
recorded_commands = (recorded_inputs + echoed_commands + script_commands).uniq
|
|
27
27
|
verification_spec = tape_spec.fetch("verify", {})
|
|
28
28
|
captured_vars = extract_output_vars(echoed_commands)
|
|
29
|
+
exit_code = final_exit_code(recording.events)
|
|
30
|
+
nonzero_exit_disallowed = exit_code && exit_code != 0 && !verification_spec.fetch("allow_nonzero_exit", false)
|
|
29
31
|
|
|
30
32
|
commands_found = expected.select { |command| include_command?(recorded_commands, command) }
|
|
31
33
|
commands_missing = expected - commands_found
|
|
@@ -43,14 +45,16 @@ module Ace
|
|
|
43
45
|
)
|
|
44
46
|
|
|
45
47
|
success = commands_missing.empty? && missing_vars.empty? && missing_output.empty? &&
|
|
46
|
-
missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty?
|
|
48
|
+
missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty? &&
|
|
49
|
+
!nonzero_exit_disallowed
|
|
47
50
|
classification, status, summary, retryable = classify(
|
|
48
51
|
commands_missing: commands_missing,
|
|
49
52
|
missing_vars: missing_vars,
|
|
50
53
|
missing_output: missing_output,
|
|
51
54
|
missing_output_sequence: missing_output_sequence,
|
|
52
55
|
forbidden_hits: forbidden_hits,
|
|
53
|
-
assertion_failures: assertion_failures
|
|
56
|
+
assertion_failures: assertion_failures,
|
|
57
|
+
nonzero_exit_disallowed: nonzero_exit_disallowed
|
|
54
58
|
)
|
|
55
59
|
|
|
56
60
|
Models::VerificationResult.new(
|
|
@@ -68,6 +72,7 @@ module Ace
|
|
|
68
72
|
script_commands_recorded: script_commands.length,
|
|
69
73
|
commands_expected: expected.length,
|
|
70
74
|
captured_vars: captured_vars,
|
|
75
|
+
exit_code: exit_code,
|
|
71
76
|
missing_vars: missing_vars,
|
|
72
77
|
missing_output: missing_output,
|
|
73
78
|
missing_output_sequence: missing_output_sequence,
|
|
@@ -117,10 +122,12 @@ module Ace
|
|
|
117
122
|
end
|
|
118
123
|
end
|
|
119
124
|
|
|
120
|
-
def classify(commands_missing:, missing_vars:, missing_output:, missing_output_sequence:, forbidden_hits:, assertion_failures:)
|
|
125
|
+
def classify(commands_missing:, missing_vars:, missing_output:, missing_output_sequence:, forbidden_hits:, assertion_failures:, nonzero_exit_disallowed:)
|
|
121
126
|
return ["pass", "pass", "Verification passed", false] if commands_missing.empty? && missing_vars.empty? &&
|
|
122
|
-
missing_output.empty? && missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty?
|
|
123
|
-
|
|
127
|
+
missing_output.empty? && missing_output_sequence.empty? && forbidden_hits.empty? && assertion_failures.empty? &&
|
|
128
|
+
!nonzero_exit_disallowed
|
|
129
|
+
unless commands_missing.empty? && missing_vars.empty? && missing_output.empty? && missing_output_sequence.empty? &&
|
|
130
|
+
!nonzero_exit_disallowed
|
|
124
131
|
return ["scenario_defect", "scenario-defect", "Recording scenario failed verification", true]
|
|
125
132
|
end
|
|
126
133
|
|
|
@@ -226,6 +233,15 @@ module Ace
|
|
|
226
233
|
[]
|
|
227
234
|
end
|
|
228
235
|
|
|
236
|
+
def final_exit_code(events)
|
|
237
|
+
exit_event = events.reverse.find { |event| event.type == "x" }
|
|
238
|
+
return nil unless exit_event
|
|
239
|
+
|
|
240
|
+
Integer(exit_event.data)
|
|
241
|
+
rescue ArgumentError, TypeError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
229
245
|
ANSI_ESCAPE_PATTERN = /\e\[[0-9;?]*[A-Za-z]/.freeze
|
|
230
246
|
private_constant :ANSI_ESCAPE_PATTERN
|
|
231
247
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Demo
|
|
7
|
+
module Molecules
|
|
8
|
+
class TmuxDirectiveExecutor
|
|
9
|
+
def initialize(control_surface: nil, executor: nil, tmux: "tmux")
|
|
10
|
+
@control_surface = control_surface
|
|
11
|
+
@executor = executor || Ace::Tmux::Molecules::TmuxExecutor.new
|
|
12
|
+
@tmux = tmux
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(command, env = nil)
|
|
16
|
+
directive = command.fetch("tmux")
|
|
17
|
+
action = directive.fetch("action")
|
|
18
|
+
surface = control_surface_for(env: env)
|
|
19
|
+
|
|
20
|
+
case action
|
|
21
|
+
when "attach"
|
|
22
|
+
session = directive.fetch("session")
|
|
23
|
+
{shell_command: [tmux, "attach-session", "-t", session].map { |part| Shellwords.escape(part) }.join(" ")}
|
|
24
|
+
when "detach"
|
|
25
|
+
surface.detach_session(session: directive["session"])
|
|
26
|
+
nil
|
|
27
|
+
when "wait"
|
|
28
|
+
surface.wait_for_condition(
|
|
29
|
+
condition: directive.fetch("for"),
|
|
30
|
+
session: directive["session"],
|
|
31
|
+
window: directive["window"],
|
|
32
|
+
pane: directive["pane"],
|
|
33
|
+
pattern: directive["pattern"],
|
|
34
|
+
timeout: directive.fetch("timeout", Ace::Tmux::Organisms::ControlSurface::DEFAULT_TIMEOUT)
|
|
35
|
+
)
|
|
36
|
+
nil
|
|
37
|
+
when "send"
|
|
38
|
+
if directive["command"]
|
|
39
|
+
surface.send_command(
|
|
40
|
+
session: directive["session"],
|
|
41
|
+
window: directive["window"],
|
|
42
|
+
pane: directive["pane"],
|
|
43
|
+
command: directive["command"]
|
|
44
|
+
)
|
|
45
|
+
else
|
|
46
|
+
surface.send_key(
|
|
47
|
+
session: directive["session"],
|
|
48
|
+
window: directive["window"],
|
|
49
|
+
pane: directive["pane"],
|
|
50
|
+
key: directive.fetch("key")
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "Unsupported tmux action '#{action}'"
|
|
56
|
+
end
|
|
57
|
+
rescue KeyError => e
|
|
58
|
+
raise ArgumentError, "Invalid tmux directive: missing #{e.key}"
|
|
59
|
+
rescue Ace::Tmux::Error => e
|
|
60
|
+
raise Ace::Demo::Error, e.message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :control_surface, :executor, :tmux
|
|
66
|
+
|
|
67
|
+
def control_surface_for(env:)
|
|
68
|
+
return control_surface if control_surface
|
|
69
|
+
|
|
70
|
+
resolver = Ace::Tmux::Molecules::RuntimeTargetResolver.new(
|
|
71
|
+
executor: executor,
|
|
72
|
+
tmux: tmux,
|
|
73
|
+
env: env || ENV
|
|
74
|
+
)
|
|
75
|
+
Ace::Tmux::Organisms::ControlSurface.new(executor: executor, resolver: resolver, tmux: tmux)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -41,6 +41,7 @@ module Ace
|
|
|
41
41
|
append_list(lines, "Missing Vars", details[:missing_vars])
|
|
42
42
|
append_list(lines, "Missing Output", details[:missing_output])
|
|
43
43
|
append_list(lines, "Missing Output Sequence", details[:missing_output_sequence])
|
|
44
|
+
append_exit_code(lines, details[:exit_code])
|
|
44
45
|
append_hits(lines, "Forbidden Output Hits", details[:forbidden_hits])
|
|
45
46
|
append_assertion_skip(lines, details[:assertions_skipped])
|
|
46
47
|
append_assertions(lines, details[:assertion_failures])
|
|
@@ -90,6 +91,15 @@ module Ace
|
|
|
90
91
|
lines << ""
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
def append_exit_code(lines, exit_code)
|
|
95
|
+
return if exit_code.nil? || exit_code.to_i.zero?
|
|
96
|
+
|
|
97
|
+
lines << "## Exit Code"
|
|
98
|
+
lines << ""
|
|
99
|
+
lines << "- `#{exit_code}`"
|
|
100
|
+
lines << ""
|
|
101
|
+
end
|
|
102
|
+
|
|
93
103
|
def append_assertions(lines, assertions)
|
|
94
104
|
return if assertions.nil? || assertions.empty?
|
|
95
105
|
|
|
@@ -19,6 +19,7 @@ module Ace
|
|
|
19
19
|
yaml_compiler: Atoms::VhsTapeCompiler,
|
|
20
20
|
asciinema_tape_compiler: Atoms::AsciinemaTapeCompiler,
|
|
21
21
|
media_retimer: Molecules::MediaRetimer.new,
|
|
22
|
+
tmux_directive_executor: Molecules::TmuxDirectiveExecutor.new,
|
|
22
23
|
sandbox_builder: Molecules::DemoSandboxBuilder.new,
|
|
23
24
|
teardown_executor: Molecules::DemoTeardownExecutor.new,
|
|
24
25
|
output_dir: Demo.config["output_dir"],
|
|
@@ -37,6 +38,7 @@ module Ace
|
|
|
37
38
|
@yaml_compiler = yaml_compiler
|
|
38
39
|
@asciinema_tape_compiler = asciinema_tape_compiler
|
|
39
40
|
@media_retimer = media_retimer
|
|
41
|
+
@tmux_directive_executor = tmux_directive_executor
|
|
40
42
|
@sandbox_builder = sandbox_builder
|
|
41
43
|
@teardown_executor = teardown_executor
|
|
42
44
|
@output_dir = output_dir || ".ace-local/demo"
|
|
@@ -176,7 +178,15 @@ module Ace
|
|
|
176
178
|
tty_size: settings.fetch("tty_size", "80x24"),
|
|
177
179
|
asciinema_bin: @asciinema_bin
|
|
178
180
|
)
|
|
179
|
-
@asciinema_executor.run_interactive(
|
|
181
|
+
@asciinema_executor.run_interactive(
|
|
182
|
+
record_cmd,
|
|
183
|
+
commands: commands,
|
|
184
|
+
env: env,
|
|
185
|
+
chdir: sandbox[:path],
|
|
186
|
+
handler: lambda { |command, write_io:|
|
|
187
|
+
handle_interactive_command(command, write_io: write_io, env: env)
|
|
188
|
+
}
|
|
189
|
+
)
|
|
180
190
|
normalize_cast_terminal_size(cast_output_path, tty_size: settings.fetch("tty_size", "80x24"))
|
|
181
191
|
|
|
182
192
|
convert_cmd = Atoms::AggCommandBuilder.build(
|
|
@@ -230,14 +240,38 @@ module Ace
|
|
|
230
240
|
def interactive_commands_for(spec:)
|
|
231
241
|
spec.fetch("scenes", []).flat_map do |scene|
|
|
232
242
|
scene.fetch("commands", []).map do |command|
|
|
233
|
-
|
|
234
|
-
command: command.fetch("type"),
|
|
235
|
-
sleep: sleep_seconds(command["sleep"] || "2s")
|
|
236
|
-
}
|
|
243
|
+
normalize_interactive_command(command)
|
|
237
244
|
end
|
|
238
245
|
end
|
|
239
246
|
end
|
|
240
247
|
|
|
248
|
+
def normalize_interactive_command(command)
|
|
249
|
+
if command["type"]
|
|
250
|
+
return {
|
|
251
|
+
kind: :shell,
|
|
252
|
+
command: command.fetch("type"),
|
|
253
|
+
sleep: sleep_seconds(command["sleep"] || "2s")
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
{
|
|
258
|
+
kind: :tmux,
|
|
259
|
+
directive: command.fetch("tmux"),
|
|
260
|
+
sleep: sleep_seconds(command["sleep"] || "2s")
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def handle_interactive_command(command, write_io:, env:)
|
|
265
|
+
case command[:kind]
|
|
266
|
+
when :shell
|
|
267
|
+
write_io.write("#{command.fetch(:command)}\n")
|
|
268
|
+
write_io.flush
|
|
269
|
+
nil
|
|
270
|
+
when :tmux
|
|
271
|
+
@tmux_directive_executor.execute({"tmux" => command.fetch(:directive)}, env)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
241
275
|
def sleep_seconds(value)
|
|
242
276
|
text = value.to_s.strip
|
|
243
277
|
match = text.match(/\A(?<number>\d+(?:\.\d+)?)(?<unit>ms|s|m|h)?\z/)
|
data/lib/ace/demo/version.rb
CHANGED
data/lib/ace/demo.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "ace/support/config"
|
|
5
|
+
require "ace/tmux"
|
|
5
6
|
|
|
6
7
|
require_relative "demo/version"
|
|
7
8
|
require_relative "demo/atoms/vhs_command_builder"
|
|
@@ -40,6 +41,7 @@ require_relative "demo/molecules/media_retimer"
|
|
|
40
41
|
require_relative "demo/molecules/demo_comment_poster"
|
|
41
42
|
require_relative "demo/molecules/verification_report_writer"
|
|
42
43
|
require_relative "demo/molecules/recording_manifest_writer"
|
|
44
|
+
require_relative "demo/molecules/tmux_directive_executor"
|
|
43
45
|
require_relative "demo/organisms/demo_recorder"
|
|
44
46
|
require_relative "demo/organisms/demo_attacher"
|
|
45
47
|
require_relative "demo/organisms/tape_creator"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-demo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.25.
|
|
4
|
+
version: 0.25.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-06-30 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '0.13'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: ace-tmux
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.17'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.17'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: ace-support-test-helpers
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -189,6 +203,7 @@ files:
|
|
|
189
203
|
- lib/ace/demo/molecules/tape_resolver.rb
|
|
190
204
|
- lib/ace/demo/molecules/tape_scanner.rb
|
|
191
205
|
- lib/ace/demo/molecules/tape_writer.rb
|
|
206
|
+
- lib/ace/demo/molecules/tmux_directive_executor.rb
|
|
192
207
|
- lib/ace/demo/molecules/verification_report_writer.rb
|
|
193
208
|
- lib/ace/demo/molecules/vhs_executor.rb
|
|
194
209
|
- lib/ace/demo/organisms/demo_attacher.rb
|