1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! Regression coverage for two related but distinct Elixir e2e bytes-marshalling
//! defects that share this file because both were exercised by the same
//! `try_push_bytes_value` / `render_struct_fields` code paths.
//!
//! alef#308: a `bytes` value nested in a struct field (e.g. an options struct's
//! `content` field, reached via `render_struct_fields` / `docs_file_read.jinja`)
//! later crosses a `Jason.encode!` hop, and Jason blows up on the first
//! non-UTF-8 byte with `Jason.EncodeError: invalid byte 0xC4`. The fix there
//! converts the struct-field path to an integer-list shape
//! (`:binary.bin_to_list(...)`), matching the shape the already-working inline
//! `Vec<u8>` JSON-array path emits. `docs_file_read_template_emits_integer_list_not_bare_binary`
//! below pins that fix and must keep passing.
//!
//! alef#453: an `arg_type = "bytes"` argument passed *directly* to a free
//! function (not nested in a struct) maps straight to a `rustler::Binary` NIF
//! parameter and never crosses `Jason.encode!` on any facade shape (see
//! `json_encode_param_indices` in `src/backends/rustler/gen_bindings/public_api_args.rs`,
//! which never marks a bytes param). Rustler's `Binary` decoder does not accept
//! an Elixir list, so the integer-list form that alef#308 needs for the
//! struct-field path raises `ArgumentError` here instead. The fix makes
//! `try_push_bytes_value` emit a raw binary (`File.read!`/`Base.decode64!`)
//! for this direct-argument path. `bytes_arg_file_path_emits_raw_binary_not_integer_list`
//! and `bytes_arg_base64_emits_raw_binary_not_integer_list` below pin that fix.
use alef::core::config::NewAlefConfig;
use alef::e2e::codegen::E2eCodegen;
use alef::e2e::codegen::elixir::ElixirCodegen;
use alef::e2e::fixture::{
Assertion, Fixture, FixtureDocs, FixtureDocsFileInput, FixtureDocsPresentation, FixtureGroup,
};
fn build_config_with_args(args_toml: &str) -> (alef::e2e::config::E2eConfig, alef::core::config::ResolvedCrateConfig) {
let toml_src = format!(
r#"
[workspace]
languages = ["elixir"]
[[crates]]
name = "mylib"
sources = ["src/lib.rs"]
[crates.e2e]
fixtures = "fixtures"
output = "e2e"
[crates.e2e.call]
function = "extract"
module = "MyLib"
result_var = "result"
returns_result = true
args = [
{args_toml}
]
"#
);
let cfg: NewAlefConfig = toml::from_str(&toml_src).expect("config parses");
let e2e = cfg.crates[0].e2e.clone().unwrap();
let resolved = cfg.resolve().expect("resolves").remove(0);
(e2e, resolved)
}
fn fixture_with_input(input: serde_json::Value) -> FixtureGroup {
FixtureGroup {
category: "test".to_string(),
fixtures: vec![Fixture {
docs: None,
requirements: Vec::new(),
id: "test_fixture".to_string(),
category: Some("test".to_string()),
description: "test fixture".to_string(),
tags: Vec::new(),
skip: None,
env: None,
setup: Vec::new(),
call: None,
input,
mock_response: None,
visitor: None,
args: Vec::new(),
assertion_recipes: Vec::new(),
assertions: vec![Assertion {
skip: None,
assertion_type: "not_empty".to_string(),
field: Some("output".to_string()),
value: None,
values: None,
method: None,
check: None,
args: None,
return_type: None,
}],
source: "test/test_fixture.json".to_string(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
}],
}
}
/// Builds a fixture whose `input.config` object has a docs-presentation file attached
/// to one of its keys, driving the `render_struct_fields` -> `docs_file_read.jinja`
/// path (args.rs `render_struct_fields`, the template render call) rather than the
/// `arg_type = "bytes"` file-path/base64 branches exercised above.
fn fixture_with_docs_file(input: serde_json::Value, doc_field: &str, doc_path: &str) -> FixtureGroup {
FixtureGroup {
category: "test".to_string(),
fixtures: vec![Fixture {
docs: Some(FixtureDocs {
sample_url: None,
topic: "test".to_string(),
stem: None,
paths: Default::default(),
title: None,
description: None,
input: None,
shows: Vec::new(),
error: None,
presentation: Some(FixtureDocsPresentation {
call: None,
input: None,
args: None,
files: vec![FixtureDocsFileInput {
field: doc_field.to_string(),
path: doc_path.to_string(),
}],
operations: Vec::new(),
}),
client: None,
side_effects: Default::default(),
coverage_exceptions: Default::default(),
sample_url_vars: Default::default(),
body_file: None,
}),
requirements: Vec::new(),
id: "test_fixture".to_string(),
category: Some("test".to_string()),
description: "test fixture".to_string(),
tags: Vec::new(),
skip: None,
env: None,
setup: Vec::new(),
call: None,
input,
mock_response: None,
visitor: None,
args: Vec::new(),
assertion_recipes: Vec::new(),
assertions: vec![Assertion {
skip: None,
assertion_type: "not_empty".to_string(),
field: Some("output".to_string()),
value: None,
values: None,
method: None,
check: None,
args: None,
return_type: None,
}],
source: "test/test_fixture.json".to_string(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
}],
}
}
fn generate_test_body(args_toml: &str, input: serde_json::Value) -> String {
generate_test_body_with_docs(args_toml, vec![fixture_with_input(input)])
}
fn generate_test_body_with_docs(args_toml: &str, groups: Vec<FixtureGroup>) -> String {
let (e2e, resolved) = build_config_with_args(args_toml);
let files = ElixirCodegen
.generate(&groups, &e2e, &resolved, &[], &[], &[], &[])
.expect("generation succeeds");
let test_file = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("test_test.exs"))
.expect("Elixir test file is emitted");
test_file.content.clone()
}
/// Drives the `docs_file_read.jinja` template path directly (args.rs
/// `render_struct_fields`, the call site rendering that template): a `json_object`
/// arg whose value object has a field carrying a fixture docs-presentation file.
/// This is the path that actually produces `File.read!` calls in xberg's generated
/// Elixir e2e suite today (a bare relative path, not `test_documents`-prefixed) -
/// unlike the two tests above, whose branches currently emit no real call sites.
#[test]
fn docs_file_read_template_emits_integer_list_not_bare_binary() {
let args_toml = r#"
{ name = "config", field = "input.config", type = "json_object", element_type = "MyConfigType" }
"#;
let input = serde_json::json!({
"config": { "content": "placeholder" }
});
let groups = vec![fixture_with_docs_file(input, "/config/content", "text/plain.txt")];
let body = generate_test_body_with_docs(args_toml, groups);
assert!(
body.contains(r#"content: :binary.bin_to_list(File.read!("text/plain.txt"))"#),
"expected exact integer-list docs-file read, got:\n{body}"
);
assert!(
!body.contains(r#"content: File.read!("text/plain.txt")"#),
"found a bare File.read! from the docs_file_read.jinja path, unwrapped before the Jason.encode! hop:\n{body}"
);
}
/// Covers the `test_documents`-prefixed branch in args.rs (~line 500): a direct
/// `arg_type = "bytes"` argument, as used by a free function taking a bytes
/// parameter (e.g. `fn sample(data: &[u8])`), maps straight to a
/// `rustler::Binary` NIF parameter and must receive a raw binary. alef#453:
/// this branch previously emitted the byte-integer-list shape meant for the
/// struct-field/`Jason.encode!` path (alef#308), and Rustler's `Binary`
/// decoder rejects a list, raising `ArgumentError`.
#[test]
fn bytes_arg_file_path_emits_raw_binary_not_integer_list() {
let args_toml = r#"
{ name = "data", field = "input.data", type = "bytes" }
"#;
let input = serde_json::json!({
"data": "docs/sample.bin"
});
let body = generate_test_body(args_toml, input);
assert!(
body.contains(r#"data = File.read!("../../test_documents/docs/sample.bin")"#),
"expected exact raw-binary file read, got:\n{body}"
);
assert!(
!body.contains(":binary.bin_to_list"),
"found a byte-integer-list conversion on a direct bytes arg - Rustler's Binary decoder \
rejects a list and raises ArgumentError (alef#453):\n{body}"
);
}
/// Covers the base64-decode branch in args.rs (~line 518). Same reasoning as
/// the file-path case above: a direct `bytes` argument is passed to the NIF
/// as a raw binary, never JSON-encoded, so decoding straight to a binary
/// (not an integer list) is required (alef#453).
#[test]
fn bytes_arg_base64_emits_raw_binary_not_integer_list() {
let args_toml = r#"
{ name = "data", field = "input.data", type = "bytes" }
"#;
// "xyz+AMQ" is not shaped like a file path (no '/'), so it takes the base64 branch.
let input = serde_json::json!({
"data": "xyzAMQ"
});
let body = generate_test_body(args_toml, input);
assert!(
body.contains(r#"data = Base.decode64!("xyzAMQ", padding: false)"#),
"expected exact raw-binary base64 decode, got:\n{body}"
);
assert!(
!body.contains(":binary.bin_to_list"),
"found a byte-integer-list conversion on a direct bytes arg - Rustler's Binary decoder \
rejects a list and raises ArgumentError (alef#453):\n{body}"
);
}