///|
/// Drain pending attachments from args and emit as envelopes.
fn emit_attachments(
sinks : Array[&@core.MessageSink],
attachments : Array[@core.PendingAttachment],
test_case_started_id : String,
test_step_id : String?,
) -> Unit {
for att in attachments {
match att {
@core.PendingAttachment::Embedded(
body~,
encoding~,
media_type~,
file_name~
) => {
// AttachmentContentEncoding constructors are not directly accessible
// from outside cucumber-messages, so we convert via JSON stringify
// and strip the surrounding quotes.
let enc_str = encoding.to_json().stringify()
let enc_clean = enc_str.substring(start=1, end=enc_str.length() - 1)
emit(
sinks,
make_attachment_envelope(
body,
enc_clean,
media_type,
file_name,
Some(test_case_started_id),
test_step_id,
),
)
}
@core.PendingAttachment::External(url~, media_type~) =>
emit(
sinks,
make_external_attachment_envelope(
url,
media_type,
Some(test_case_started_id),
test_step_id,
),
)
}
}
}
///|
fn parse_keyword_string(s : String) -> @core.StepKeyword {
match s {
"Given " => @core.StepKeyword::Given
"When " => @core.StepKeyword::When
"Then " => @core.StepKeyword::Then
_ => @core.StepKeyword::Step
}
}
///|
/// Execute a single scenario from a compiled Pickle.
///
/// When a `hook_registry` is provided (and contains hooks), lifecycle hooks
/// are called at the appropriate points: before/after test case and
/// before/after each test step.
pub fn execute_scenario(
registry : @core.StepRegistry,
feature_name~ : String,
scenario_name~ : String,
pickle_id~ : String,
tags~ : Array[String],
steps~ : Array[@cucumber_messages.PickleStep],
hook_registry? : @core.HookRegistry = @core.HookRegistry::new(),
sinks? : Array[&@core.MessageSink] = [],
test_case_started_id? : String = "",
test_step_ids? : Array[String] = [],
dry_run? : Bool = false,
) -> ScenarioResult {
let info : @core.ScenarioInfo = { feature_name, scenario_name, tags }
let has_sinks = sinks.length() > 0 && test_step_ids.length() > 0
let before_case_hooks = hook_registry.by_type(@core.HookType::BeforeTestCase)
let after_case_hooks = hook_registry.by_type(@core.HookType::AfterTestCase)
let before_step_hooks = hook_registry.by_type(@core.HookType::BeforeTestStep)
let after_step_hooks = hook_registry.by_type(@core.HookType::AfterTestStep)
// Dry-run: match steps but skip hooks and handler execution
if dry_run {
let step_results : Array[StepResult] = []
let mut step_idx = 0
for _i, step in steps {
let keyword = match step.type_ {
Some(@cucumber_messages.PickleStepType::Context) => "Given "
Some(@cucumber_messages.PickleStepType::Action) => "When "
Some(@cucumber_messages.PickleStepType::Outcome) => "Then "
_ => "* "
}
let ts_id = if step_idx < test_step_ids.length() {
test_step_ids[step_idx]
} else {
""
}
step_idx += 1
if has_sinks {
emit(
sinks,
make_test_step_started_envelope(test_case_started_id, ts_id),
)
}
let (status, diagnostic) : (StepStatus, Error?) = match
registry.find_match(step.text, keyword~) {
Undefined(step_text~, keyword=kw, snippet~, suggestions~) =>
(
StepStatus::Undefined,
Some(
@core.undefined_step_error(
step=step_text,
keyword=kw,
snippet~,
suggestions~,
),
),
)
Matched(_, _) => (StepStatus::Skipped(Some("dry run")), None)
}
if has_sinks {
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id,
ts_id,
step_status_to_string(status),
step_status_message(status),
),
)
}
step_results.push({
text: step.text,
keyword,
status,
duration_ms: 0L,
diagnostic,
})
}
let statuses = step_results.map(fn(r) { r.status })
return {
feature_name,
scenario_name,
pickle_id,
tags,
steps: step_results,
status: ScenarioStatus::from_steps(statuses),
duration_ms: 0L,
}
}
// Track overall test step index across hook steps and regular steps
let mut step_idx = 0
// before_test_case hooks — emit TestStepStarted/Finished for each
let mut hook_err : String? = None
for hook in before_case_hooks {
let ts_id = if step_idx < test_step_ids.length() {
test_step_ids[step_idx]
} else {
""
}
step_idx += 1
if has_sinks {
emit(sinks, make_test_step_started_envelope(test_case_started_id, ts_id))
}
let status_str = if hook_err is Some(_) {
"SKIPPED"
} else {
match hook.handler {
@core.CaseHandler(h) =>
try {
let hook_ctx = @core.CaseHookCtx::new(info)
h(hook_ctx)
if has_sinks && hook_ctx.pending_attachments().length() > 0 {
emit_attachments(
sinks,
hook_ctx.pending_attachments(),
test_case_started_id,
Some(ts_id),
)
hook_ctx.pending_attachments().clear()
}
"PASSED"
} catch {
e => {
hook_err = Some(e.to_string())
"FAILED"
}
}
_ => "PASSED"
}
}
let err_msg : String? = match status_str {
"FAILED" => hook_err
_ => None
}
if has_sinks {
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id, ts_id, status_str, err_msg,
),
)
}
}
// If before_test_case hook failed, skip all regular steps
if hook_err is Some(msg) {
let step_results : Array[StepResult] = []
for _i, step in steps {
let keyword = match step.type_ {
Some(@cucumber_messages.PickleStepType::Context) => "Given "
Some(@cucumber_messages.PickleStepType::Action) => "When "
Some(@cucumber_messages.PickleStepType::Outcome) => "Then "
_ => "* "
}
let ts_id = if step_idx < test_step_ids.length() {
test_step_ids[step_idx]
} else {
""
}
step_idx += 1
if has_sinks {
emit(
sinks,
make_test_step_started_envelope(test_case_started_id, ts_id),
)
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id,
ts_id,
"SKIPPED",
None,
),
)
}
step_results.push({
text: step.text,
keyword,
status: StepStatus::Skipped(None),
duration_ms: 0L,
diagnostic: None,
})
}
// after_test_case hooks still run even on before failure
let case_hook_result : @core.HookResult = @core.HookResult::Failed([
@core.HookError::StepFailed(
step="",
keyword=@core.StepKeyword::Step,
message=msg,
),
])
for hook in after_case_hooks {
let ts_id = if step_idx < test_step_ids.length() {
test_step_ids[step_idx]
} else {
""
}
step_idx += 1
if has_sinks {
emit(
sinks,
make_test_step_started_envelope(test_case_started_id, ts_id),
)
}
let after_status = match hook.handler {
@core.CaseAfterHandler(h) =>
try {
let hook_ctx = @core.CaseHookCtx::new(info)
h(hook_ctx, case_hook_result)
if has_sinks && hook_ctx.pending_attachments().length() > 0 {
emit_attachments(
sinks,
hook_ctx.pending_attachments(),
test_case_started_id,
Some(ts_id),
)
hook_ctx.pending_attachments().clear()
}
"PASSED"
} catch {
_ => "FAILED"
}
_ => "PASSED"
}
if has_sinks {
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id,
ts_id,
after_status,
None,
),
)
}
}
return {
feature_name,
scenario_name,
pickle_id,
tags,
steps: step_results,
status: ScenarioStatus::Failed,
duration_ms: 0L,
}
}
// Execute steps with optional before/after_step hooks
let step_results : Array[StepResult] = []
let mut failed = false
for _i, step in steps {
let keyword = match step.type_ {
Some(@cucumber_messages.PickleStepType::Context) => "Given "
Some(@cucumber_messages.PickleStepType::Action) => "When "
Some(@cucumber_messages.PickleStepType::Outcome) => "Then "
_ => "* "
}
let ts_id = if step_idx < test_step_ids.length() {
test_step_ids[step_idx]
} else {
""
}
step_idx += 1
if has_sinks {
emit(sinks, make_test_step_started_envelope(test_case_started_id, ts_id))
}
if failed {
if has_sinks {
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id,
ts_id,
"SKIPPED",
None,
),
)
}
step_results.push({
text: step.text,
keyword,
status: StepStatus::Skipped(None),
duration_ms: 0L,
diagnostic: None,
})
continue
}
let step_info : @core.StepInfo = { keyword, text: step.text }
// before_test_step hooks
let before_err : String? = if before_step_hooks.length() > 0 {
let mut err : String? = None
for hook in before_step_hooks {
if err is Some(_) {
break
}
match hook.handler {
@core.StepHandler(h) => {
let hook_ctx = @core.StepHookCtx::new(info, step_info)
h(hook_ctx) catch {
e => err = Some(e.to_string())
}
if has_sinks && hook_ctx.pending_attachments().length() > 0 {
emit_attachments(
sinks,
hook_ctx.pending_attachments(),
test_case_started_id,
Some(ts_id),
)
hook_ctx.pending_attachments().clear()
}
}
_ => ()
}
}
err
} else {
None
}
let mut ctx_for_drain : @core.Ctx? = None
let (status, diagnostic) : (StepStatus, Error?) = if before_err is Some(msg) {
(
StepStatus::Failed(msg),
Some(@core.step_failed_error(step=step.text, keyword~, message=msg)),
)
} else {
match registry.find_match(step.text, keyword~) {
Undefined(step_text~, keyword=kw, snippet~, suggestions~) =>
(
StepStatus::Undefined,
Some(
@core.undefined_step_error(
step=step_text,
keyword=kw,
snippet~,
suggestions~,
),
),
)
Matched(step_def, args) =>
try {
// Append block argument (DocString/DataTable) as last StepArg
match step.argument {
Some(arg) =>
match (arg.docString, arg.dataTable) {
(Some(ds), _) => {
let doc : @core.DocString = {
content: ds.content,
media_type: ds.mediaType,
}
args.push(@core.StepArg::{
value: DocStringVal(doc),
raw: ds.content,
})
}
(_, Some(dt)) => {
let raw_rows = dt.rows.map(fn(row) {
row.cells.map(fn(cell) { cell.value })
})
let table = @core.DataTable::from_rows(raw_rows)
args.push(@core.StepArg::{
value: DataTableVal(table),
raw: "",
})
}
_ => ()
}
None => ()
}
let ctx = @core.Ctx::new(args, info, step_info)
(step_def.handler.0)(ctx)
ctx_for_drain = Some(ctx)
(StepStatus::Passed, None)
} catch {
@core.MoonspecError::PendingStep(step~, keyword=kw, message~) =>
(
StepStatus::Pending,
Some(@core.pending_step_error(step~, keyword=kw, message~)),
)
e => {
let enriched = "Step '" +
step.text +
"' (" +
keyword.trim(chars=" ").to_string() +
"): " +
e.to_string()
(
StepStatus::Failed(enriched),
Some(
@core.step_failed_error(
step=step.text,
keyword~,
message=enriched,
),
),
)
}
}
}
}
if has_sinks {
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id,
ts_id,
step_status_to_string(status),
step_status_message(status),
),
)
}
// Drain attachments from ctx
if has_sinks {
match ctx_for_drain {
Some(c) =>
if c.pending_attachments().length() > 0 {
emit_attachments(
sinks,
c.pending_attachments(),
test_case_started_id,
Some(ts_id),
)
c.pending_attachments().clear()
}
None => ()
}
}
// after_test_step hooks
if after_step_hooks.length() > 0 {
let step_hook_result : @core.HookResult = match status {
StepStatus::Failed(msg) =>
@core.HookResult::Failed([
@core.HookError::StepFailed(
step=step.text,
keyword=parse_keyword_string(keyword),
message=msg,
),
])
_ => @core.HookResult::Passed
}
for hook in after_step_hooks {
match hook.handler {
@core.StepAfterHandler(h) => {
let hook_ctx = @core.StepHookCtx::new(info, step_info)
let _ = h(hook_ctx, step_hook_result) catch { _ => () }
if has_sinks && hook_ctx.pending_attachments().length() > 0 {
emit_attachments(
sinks,
hook_ctx.pending_attachments(),
test_case_started_id,
Some(ts_id),
)
hook_ctx.pending_attachments().clear()
}
}
_ => ()
}
}
}
match status {
StepStatus::Failed(_) | StepStatus::Undefined | StepStatus::Pending =>
failed = true
_ => ()
}
step_results.push({
text: step.text,
keyword,
status,
duration_ms: 0L,
diagnostic,
})
}
// after_test_case hooks — emit TestStepStarted/Finished for each
let step_errors : Array[@core.HookError] = []
for r in step_results {
match r.status {
StepStatus::Failed(msg) =>
step_errors.push(
@core.HookError::StepFailed(
step=r.text,
keyword=parse_keyword_string(r.keyword),
message=msg,
),
)
_ => ()
}
}
let case_hook_result : @core.HookResult = if step_errors.length() > 0 {
@core.HookResult::Failed(step_errors)
} else {
@core.HookResult::Passed
}
for hook in after_case_hooks {
let ts_id = if step_idx < test_step_ids.length() {
test_step_ids[step_idx]
} else {
""
}
step_idx += 1
if has_sinks {
emit(sinks, make_test_step_started_envelope(test_case_started_id, ts_id))
}
let after_status = match hook.handler {
@core.CaseAfterHandler(h) =>
try {
let hook_ctx = @core.CaseHookCtx::new(info)
h(hook_ctx, case_hook_result)
if has_sinks && hook_ctx.pending_attachments().length() > 0 {
emit_attachments(
sinks,
hook_ctx.pending_attachments(),
test_case_started_id,
Some(ts_id),
)
hook_ctx.pending_attachments().clear()
}
"PASSED"
} catch {
_ => "FAILED"
}
_ => "PASSED"
}
if has_sinks {
emit(
sinks,
make_test_step_finished_envelope(
test_case_started_id,
ts_id,
after_status,
None,
),
)
}
}
let statuses = step_results.map(fn(r) { r.status })
{
feature_name,
scenario_name,
pickle_id,
tags,
steps: step_results,
status: ScenarioStatus::from_steps(statuses),
duration_ms: 0L,
}
}