Skip to content

Commit 9b91321

Browse files
committed
serde/json/test: add JSONTestSuite parsing conformance suite
Fetch the test_parsing corpus from https://github.com/nst/JSONTestSuite (MIT, Nicolas Seriot) via a pinned http_archive and run it as a parameterized gtest. File-name prefixes encode the expected parser behavior per RFC 8259: y_ must be accepted, n_ must be rejected, i_ is implementation-defined (the parser just must not crash). The current parser passes all 318 cases with default config. get_runfile_path gains an optional repo parameter (default _main) so callers can resolve data from external Bazel repositories.
1 parent cde833e commit 9b91321

5 files changed

Lines changed: 140 additions & 7 deletions

File tree

MODULE.bazel

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,20 @@ http_archive(
482482
strip_prefix = "antithesis-sdk-cpp-0.4.7",
483483
urls = ["https://github.com/antithesishq/antithesis-sdk-cpp/archive/refs/tags/0.4.7.tar.gz"],
484484
)
485+
486+
# JSON parser conformance corpus used by
487+
# //src/v/serde/json/tests:json_test_suite_test. MIT-licensed; see
488+
# https://github.com/nst/JSONTestSuite.
489+
http_archive(
490+
name = "nst_json_test_suite",
491+
build_file_content = """
492+
filegroup(
493+
name = "test_parsing",
494+
srcs = glob(["test_parsing/*.json"]),
495+
visibility = ["//visibility:public"],
496+
)
497+
""",
498+
integrity = "sha256-Mqsvs3zCZnnb2RZNu3BTvO8QE0ZD1TywzvRd52gNzcw=",
499+
strip_prefix = "JSONTestSuite-1ef36fa01286573e846ac449e8683f8833c5b26a",
500+
urls = ["https://github.com/nst/JSONTestSuite/archive/1ef36fa01286573e846ac449e8683f8833c5b26a.tar.gz"],
501+
)

src/v/serde/json/tests/BUILD

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,27 @@ redpanda_cc_gtest(
102102
],
103103
)
104104

105+
redpanda_cc_gtest(
106+
name = "json_test_suite_test",
107+
timeout = "short",
108+
srcs = [
109+
"json_test_suite_test.cc",
110+
],
111+
cpu = 1,
112+
data = ["@nst_json_test_suite//:test_parsing"],
113+
deps = [
114+
":test_cases",
115+
"//src/v/bytes:iobuf",
116+
"//src/v/serde/json:parser",
117+
"//src/v/test_utils:gtest",
118+
"//src/v/test_utils:runfiles",
119+
"//src/v/utils:file_io",
120+
"@googletest//:gtest",
121+
"@googletest//:gtest_main",
122+
"@seastar",
123+
],
124+
)
125+
105126
redpanda_test_cc_library(
106127
name = "data",
107128
srcs = [
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file licenses/BSL.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
#include "serde/json/parser.h"
13+
#include "serde/json/tests/test_cases.h"
14+
#include "test_utils/runfiles.h"
15+
#include "test_utils/test.h"
16+
#include "utils/file_io.h"
17+
18+
#include <gtest/gtest.h>
19+
20+
#include <filesystem>
21+
#include <string>
22+
23+
using namespace serde::json;
24+
25+
/// Test suite from https://github.com/nst/JSONTestSuite.
26+
///
27+
/// File-name prefixes encode the expected parser behavior per RFC 8259:
28+
/// - y_ must be accepted
29+
/// - n_ must be rejected
30+
/// - i_ parsers are free to accept or reject
31+
class json_test_suite_test
32+
: public seastar_test
33+
, public ::testing::WithParamInterface<std::string> {};
34+
35+
TEST_P_CORO(json_test_suite_test, test_parsing) {
36+
const auto& test_case_path = GetParam();
37+
auto filename = std::filesystem::path(test_case_path).filename().string();
38+
39+
auto contents = co_await read_fully(test_case_path);
40+
auto parser = serde::json::parser(std::move(contents), parser_config{});
41+
42+
while (co_await parser.next()) {
43+
}
44+
45+
auto final_token = parser.token();
46+
ASSERT_TRUE_CORO(final_token == token::eof || final_token == token::error)
47+
<< "parser::next() returned false but final token is neither eof nor "
48+
"error: "
49+
<< final_token;
50+
bool accepted = final_token == token::eof;
51+
52+
if (filename.starts_with("y_")) {
53+
EXPECT_TRUE(accepted) << filename << ": expected accept, got reject";
54+
} else if (filename.starts_with("n_")) {
55+
EXPECT_FALSE(accepted) << filename << ": expected reject, got accept";
56+
} else {
57+
vassert(
58+
filename.starts_with("i_"),
59+
"Unexpected test case name prefix: {}",
60+
filename);
61+
// Implementation-defined: either outcome is acceptable. The parser
62+
// must not crash.
63+
}
64+
}
65+
66+
INSTANTIATE_TEST_SUITE_P(
67+
json_test_suite,
68+
json_test_suite_test,
69+
::testing::ValuesIn(
70+
serde::json::testing::collect_json_test_cases(
71+
test_utils::get_runfile_path("test_parsing", "nst_json_test_suite"))),
72+
[](const ::testing::TestParamInfo<std::string>& info) {
73+
// GTest requires parameter names to match [a-zA-Z0-9_] and to be
74+
// unique. Use the filename stem and hex-escape any non-alphanumeric
75+
// chars (using `_XX`) to preserve uniqueness across similar names.
76+
auto stem = std::filesystem::path(info.param).stem().string();
77+
std::string out;
78+
out.reserve(stem.size());
79+
for (auto c : stem) {
80+
auto uc = static_cast<unsigned char>(c);
81+
if (std::isalnum(uc) || uc == '_') {
82+
out.push_back(c);
83+
} else {
84+
fmt::format_to(std::back_inserter(out), "_{:02x}", uc);
85+
}
86+
}
87+
return out;
88+
});

src/v/test_utils/runfiles.cc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
namespace test_utils {
1212

13-
std::string get_runfile_path(std::string_view path) {
13+
std::string get_runfile_path(std::string_view path, std::string_view repo) {
1414
using bazel::tools::cpp::runfiles::Runfiles;
1515
std::string error;
1616
std::unique_ptr<Runfiles> runfiles;
@@ -25,7 +25,7 @@ std::string get_runfile_path(std::string_view path) {
2525
if (runfiles == nullptr) {
2626
throw std::runtime_error(error);
2727
}
28-
return runfiles->Rlocation(fmt::format("_main/{}", path));
28+
return runfiles->Rlocation(fmt::format("{}/{}", repo, path));
2929
}
3030

3131
} // namespace test_utils

src/v/test_utils/runfiles.h

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@
66
namespace test_utils {
77

88
/*
9-
* Usage:
10-
* - Add a `data = [path/to/some.file]` to the test target
11-
* - Call get_runfile_path with "root/.../path/to/some.file"
9+
* Resolve `path` to an absolute filesystem path at test runtime.
1210
*
13-
* The return value is the path to the file.
11+
* `path` is a repo-relative path (e.g. "src/v/.../testdata/foo.json" for
12+
* files from this repository). The file must be listed in the test
13+
* target's `data = [...]` attribute so Bazel stages it into the
14+
* runfiles tree.
15+
*
16+
* `repo` selects which Bazel repository the path is rooted in. It
17+
* defaults to "_main" (this repository). For files exposed by an
18+
* external repository — e.g. a `http_archive` declared in MODULE.bazel —
19+
* pass that repository's name.
1420
*/
15-
std::string get_runfile_path(std::string_view);
21+
std::string
22+
get_runfile_path(std::string_view path, std::string_view repo = "_main");
1623

1724
} // namespace test_utils

0 commit comments

Comments
 (0)