///|
/// Parse a single NDJSON line into an Envelope.
pub fn parse_ndjson_line(line : String) -> Envelope raise {
let json = @json.parse(line)
@json.from_json(json)
}
///|
/// Serialize an Envelope to a single NDJSON line (no trailing newline).
pub fn Envelope::to_ndjson_line(self : Envelope) -> String {
self.to_json().stringify()
}
///|
/// Parse an NDJSON string (multiple lines) into an array of Envelopes.
/// Blank lines are skipped.
pub fn parse_ndjson(input : String) -> Array[Envelope] raise {
let envelopes : Array[Envelope] = []
let lines = input.split("\n")
for line in lines {
let trimmed = line.trim().to_string()
if trimmed.length() > 0 {
envelopes.push(parse_ndjson_line(trimmed))
}
}
envelopes
}
///|
/// Serialize an array of Envelopes to NDJSON (one JSON object per line).
pub fn envelopes_to_ndjson(envelopes : Array[Envelope]) -> String {
let buf = StringBuilder::new()
for i, env in envelopes {
if i > 0 {
buf.write_char('\n')
}
buf.write_string(env.to_ndjson_line())
}
buf.to_string()
}
///|
test "ndjson single line roundtrip" {
let env = Envelope::Meta(Meta::{
protocolVersion: "27.0.0",
implementation: Product::{ name: "test", version: None },
runtime: Product::{ name: "moonbit", version: None },
os: Product::{ name: "linux", version: None },
cpu: Product::{ name: "amd64", version: None },
ci: None,
})
let line = env.to_ndjson_line()
let back = parse_ndjson_line(line) catch { _ => panic() }
match back {
Meta(m) => assert_eq(m.protocolVersion, "27.0.0")
_ => panic()
}
}
///|
test "ndjson multi-line parse" {
let line1 = "{\"meta\":{\"protocolVersion\":\"27.0.0\",\"implementation\":{\"name\":\"x\"},\"runtime\":{\"name\":\"y\"},\"os\":{\"name\":\"z\"},\"cpu\":{\"name\":\"w\"}}}"
let line2 = "{\"source\":{\"uri\":\"a.feature\",\"data\":\"Feature: A\",\"mediaType\":\"text/x.cucumber.gherkin+plain\"}}"
let input = line1 + "\n" + line2 + "\n"
let envelopes = parse_ndjson(input) catch { _ => panic() }
assert_eq(envelopes.length(), 2)
match envelopes[0] {
Meta(m) => assert_eq(m.protocolVersion, "27.0.0")
_ => panic()
}
match envelopes[1] {
Source(s) => assert_eq(s.uri, "a.feature")
_ => panic()
}
}
///|
test "ndjson roundtrip multi envelope" {
let envs = [
Envelope::TestRunStarted(TestRunStarted::{
timestamp: Timestamp::{ seconds: 1, nanos: 0 },
id: None,
}),
Envelope::TestRunFinished(TestRunFinished::{
message: None,
success: true,
timestamp: Timestamp::{ seconds: 2, nanos: 0 },
exception: None,
testRunStartedId: None,
}),
]
let ndjson = envelopes_to_ndjson(envs)
let back = parse_ndjson(ndjson) catch { _ => panic() }
assert_eq(back.length(), 2)
match back[0] {
TestRunStarted(t) => assert_eq(t.timestamp.seconds, 1)
_ => panic()
}
match back[1] {
TestRunFinished(t) => assert_eq(t.success, true)
_ => panic()
}
}
///|
test "ndjson skips blank lines" {
let input = "\n{\"meta\":{\"protocolVersion\":\"27.0.0\",\"implementation\":{\"name\":\"x\"},\"runtime\":{\"name\":\"y\"},\"os\":{\"name\":\"z\"},\"cpu\":{\"name\":\"w\"}}}\n\n"
let envelopes = parse_ndjson(input) catch { _ => panic() }
assert_eq(envelopes.length(), 1)
}
///|
test "fixture all_variants ndjson roundtrip" {
// 23 lines in the fixture: all 21 Envelope variant types represented
// (some variants like testStepStarted/testStepFinished appear twice for realism)
let fixture =
#|{"meta":{"protocolVersion":"27.0.0","implementation":{"name":"cucumber-moonbit"},"runtime":{"name":"moonbit"},"os":{"name":"linux"},"cpu":{"name":"amd64"}}}
#|{"source":{"uri":"features/hello.feature","data":"Feature: Hello\n Scenario: World\n Given a step","mediaType":"text/x.cucumber.gherkin+plain"}}
#|{"gherkinDocument":{"uri":"features/hello.feature","comments":[],"feature":{"location":{"line":1},"tags":[],"language":"en","keyword":"Feature","name":"Hello","description":"","children":[{"scenario":{"location":{"line":2},"tags":[],"keyword":"Scenario","name":"World","description":"","steps":[{"location":{"line":3},"keyword":"Given ","text":"a step","id":"step-1","keywordType":"Context"}],"examples":[],"id":"scenario-1"}}]}}}
#|{"pickle":{"id":"pickle-1","uri":"features/hello.feature","name":"World","language":"en","steps":[{"id":"pickle-step-1","text":"a step","astNodeIds":["step-1"],"type":"Context"}],"tags":[],"astNodeIds":["scenario-1"]}}
#|{"stepDefinition":{"id":"step-def-1","pattern":{"source":"a step","type":"CUCUMBER_EXPRESSION"},"sourceReference":{"uri":"steps/steps.mbt","location":{"line":5}}}}
#|{"hook":{"id":"hook-1","sourceReference":{"uri":"support/hooks.mbt","location":{"line":10}},"name":"before all","type":"BEFORE_TEST_RUN"}}
#|{"parameterType":{"id":"param-type-1","name":"color","preferForRegularExpressionMatch":true,"regularExpressions":["red|blue|green"],"useForSnippets":true,"sourceReference":{"uri":"support/params.mbt","location":{"line":1}}}}
#|{"testCase":{"id":"test-case-1","pickleId":"pickle-1","testSteps":[{"id":"test-step-1","hookId":"hook-1"},{"id":"test-step-2","pickleStepId":"pickle-step-1","stepDefinitionIds":["step-def-1"],"stepMatchArgumentsLists":[{"stepMatchArguments":[]}]}]}}
#|{"testRunStarted":{"timestamp":{"seconds":1700000000,"nanos":0}}}
#|{"testRunHookStarted":{"id":"run-hook-started-1","testRunStartedId":"test-run-1","hookId":"hook-1","timestamp":{"seconds":1700000001,"nanos":0}}}
#|{"testRunHookFinished":{"testRunHookStartedId":"run-hook-started-1","result":{"duration":{"seconds":0,"nanos":50000000},"status":"PASSED"},"timestamp":{"seconds":1700000001,"nanos":50000000}}}
#|{"testCaseStarted":{"attempt":0,"id":"test-case-started-1","testCaseId":"test-case-1","timestamp":{"seconds":1700000002,"nanos":0}}}
#|{"testStepStarted":{"testCaseStartedId":"test-case-started-1","testStepId":"test-step-1","timestamp":{"seconds":1700000002,"nanos":100000}}}
#|{"testStepFinished":{"testCaseStartedId":"test-case-started-1","testStepId":"test-step-1","testStepResult":{"duration":{"seconds":0,"nanos":1000000},"status":"PASSED"},"timestamp":{"seconds":1700000002,"nanos":1100000}}}
#|{"testStepStarted":{"testCaseStartedId":"test-case-started-1","testStepId":"test-step-2","timestamp":{"seconds":1700000002,"nanos":2000000}}}
#|{"testStepFinished":{"testCaseStartedId":"test-case-started-1","testStepId":"test-step-2","testStepResult":{"duration":{"seconds":0,"nanos":5000000},"status":"PASSED"},"timestamp":{"seconds":1700000002,"nanos":7000000}}}
#|{"testCaseFinished":{"testCaseStartedId":"test-case-started-1","timestamp":{"seconds":1700000003,"nanos":0},"willBeRetried":false}}
#|{"testRunFinished":{"success":true,"timestamp":{"seconds":1700000004,"nanos":0}}}
#|{"attachment":{"body":"Screenshot data","contentEncoding":"BASE64","mediaType":"image/png","testCaseStartedId":"test-case-started-1","testStepId":"test-step-2"}}
#|{"externalAttachment":{"url":"https://example.com/report.html","mediaType":"text/html"}}
#|{"parseError":{"source":{"uri":"bad.feature","location":{"line":5,"column":1}},"message":"expected: Feature, got: Scenario"}}
#|{"undefinedParameterType":{"expression":"a {color} thing","name":"color"}}
#|{"suggestion":{"id":"sug-1","pickleStepId":"pickle-step-1","snippets":[{"language":"moonbit","code":"fn step_a_step(world: World) -> Unit { ... }"}]}}
let envelopes = parse_ndjson(fixture)
assert_eq(envelopes.length(), 23)
// Verify first message is Meta
match envelopes[0] {
Meta(m) => assert_eq(m.protocolVersion, "27.0.0")
_ => panic()
}
// Verify last message is Suggestion
match envelopes[22] {
Suggestion(s) => assert_eq(s.id, "sug-1")
_ => panic()
}
// Round-trip: serialize to NDJSON and parse back
let ndjson = envelopes_to_ndjson(envelopes)
let back = parse_ndjson(ndjson)
assert_eq(back.length(), 23)
// Spot-check several variants in the round-tripped result
match back[1] {
Source(s) => assert_eq(s.uri, "features/hello.feature")
_ => panic()
}
match back[2] {
GherkinDocument(doc) => {
guard doc.feature is Some(f)
assert_eq(f.name, "Hello")
}
_ => panic()
}
match back[3] {
Pickle(p) => assert_eq(p.name, "World")
_ => panic()
}
match back[4] {
StepDefinition(sd) => assert_eq(sd.pattern.source, "a step")
_ => panic()
}
match back[5] {
Hook(h) => assert_eq(h.name, Some("before all"))
_ => panic()
}
match back[6] {
ParameterType(pt) => assert_eq(pt.name, "color")
_ => panic()
}
match back[7] {
TestCase(tc) => assert_eq(tc.testSteps.length(), 2)
_ => panic()
}
match back[8] {
TestRunStarted(t) => assert_eq(t.timestamp.seconds, 1700000000)
_ => panic()
}
match back[18] {
Attachment(a) => assert_eq(a.mediaType, "image/png")
_ => panic()
}
match back[19] {
ExternalAttachment(e) => assert_eq(e.mediaType, "text/html")
_ => panic()
}
match back[20] {
ParseError(p) => assert_eq(p.message, "expected: Feature, got: Scenario")
_ => panic()
}
match back[21] {
UndefinedParameterType(u) => assert_eq(u.name, "color")
_ => panic()
}
}