Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions crates/webcodex-core/src/project_validation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,55 @@ fn project_node_check_step_identity_capability_and_metadata_are_closed() {
assert_ne!(node, ruff);
}

#[test]
fn node_native_name_filter_identity_and_canonical_argv_are_closed() {
use crate::runner_protocol::{RunnerCapabilities, ShellJobValidationStep};
use crate::validation_identity::{
structured_validation_target_identity, ToolValidationIdentityKind,
};
let legacy: RunnerCapabilities =
serde_json::from_value(serde_json::json!({"project_validation_node_tap_v1":true})).unwrap();
assert!(!legacy.project_validation_node_name_filter_v1);
let id = |filter: Option<&str>| {
structured_validation_target_identity(
ToolValidationIdentityKind::NodeNativeTest,
&serde_json::json!({"cwd":".","filter":filter}),
)
.unwrap()
};
let old = structured_validation_target_identity(
ToolValidationIdentityKind::NodeNativeTest,
&serde_json::json!({"cwd":"."}),
)
.unwrap();
assert_eq!(old, id(None));
assert_eq!(old, id(Some("")));
assert_ne!(old, id(Some("alpha")));
assert_ne!(id(Some("alpha")), id(Some("beta")));
let step = |filter: Option<&str>| {
let mut args = vec!["--test".to_string(), "--test-reporter=tap".to_string()];
if let Some(filter) = filter {
args.push(format!("--test-name-pattern={filter}"));
}
ShellJobValidationStep {
name: "test".into(),
program: "node".into(),
args,
env: vec![],
}
};
assert!(step(None).is_canonical());
assert!(step(Some("alpha|beta")).is_canonical());
assert!(step(Some(" alpha ")).is_canonical());
assert_ne!(id(Some("alpha")), id(Some(" alpha ")));
for bad in ["", "\n", "-oops", &"x".repeat(201)] {
assert!(!step(Some(bad)).is_canonical(), "{bad:?}");
}
let mut malformed = step(Some("alpha"));
malformed.args.push("--eval=process.exit(0)".into());
assert!(!malformed.is_canonical());
}

#[test]
fn only_canonical_project_cargo_steps_accept_sorted_workspace_exclusions() {
use crate::runner_protocol::ShellJobValidationStep;
Expand Down
31 changes: 19 additions & 12 deletions crates/webcodex-core/src/runner_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,18 @@ mod tests;
pub use job::{
normalize_cargo_excluded_packages, normalize_cargo_packages, normalize_cargo_value,
normalize_go_packages, normalize_go_test_filter, normalize_go_test_packages,
normalize_pytest_filter, normalize_rust_test_filter, valid_rust_test_filter,
RunnerJobLogRequest, RunnerJobLogResponse, RunnerJobResult, RunnerJobStatusRequest,
RunnerJobStatusResponse, RunnerJobStopRequest, RunnerJobStopResponse, RunnerJobUpdateRequest,
RunnerJobUpdateResponse, RunnerJobsListRequest, RunnerJobsListResponse, RunnerShellJobResult,
ShellJobActivity, ShellJobActivityPhase, ShellJobActivitySource, ShellJobActivityState,
ShellJobCodexMetadata, ShellJobContext, ShellJobInfo, ShellJobInventory, ShellJobLogSnapshot,
ShellJobOpRequest, ShellJobOpResponse, ShellJobSnapshot, ShellJobStreamSnapshot,
ShellJobStructuredExecutionMetadata, ShellJobTestCountEvidence, ShellJobValidationMetadata,
ShellJobValidationProgress, ShellJobValidationStep, CARGO_PACKAGE_MAX_ITEMS,
CARGO_TEST_MIN_TESTS_MAX, CARGO_VALUE_MAX_BYTES, GO_TEST_PACKAGE_MAX_BYTES,
GO_TEST_PACKAGE_MAX_ITEMS, JOB_INVENTORY_MAX_ACTIVE_JOBS, JOB_INVENTORY_MAX_JOBS,
JOB_INVENTORY_MAX_SERIALIZED_BYTES, JOB_INVENTORY_MAX_TERMINAL_JOBS,
normalize_node_native_test_filter, normalize_pytest_filter, normalize_rust_test_filter,
valid_rust_test_filter, RunnerJobLogRequest, RunnerJobLogResponse, RunnerJobResult,
RunnerJobStatusRequest, RunnerJobStatusResponse, RunnerJobStopRequest, RunnerJobStopResponse,
RunnerJobUpdateRequest, RunnerJobUpdateResponse, RunnerJobsListRequest, RunnerJobsListResponse,
RunnerShellJobResult, ShellJobActivity, ShellJobActivityPhase, ShellJobActivitySource,
ShellJobActivityState, ShellJobCodexMetadata, ShellJobContext, ShellJobInfo, ShellJobInventory,
ShellJobLogSnapshot, ShellJobOpRequest, ShellJobOpResponse, ShellJobSnapshot,
ShellJobStreamSnapshot, ShellJobStructuredExecutionMetadata, ShellJobTestCountEvidence,
ShellJobValidationMetadata, ShellJobValidationProgress, ShellJobValidationStep,
CARGO_PACKAGE_MAX_ITEMS, CARGO_TEST_MIN_TESTS_MAX, CARGO_VALUE_MAX_BYTES,
GO_TEST_PACKAGE_MAX_BYTES, GO_TEST_PACKAGE_MAX_ITEMS, JOB_INVENTORY_MAX_ACTIVE_JOBS,
JOB_INVENTORY_MAX_JOBS, JOB_INVENTORY_MAX_SERIALIZED_BYTES, JOB_INVENTORY_MAX_TERMINAL_JOBS,
JOB_SNAPSHOT_STREAM_MAX_BYTES, JOB_TERMINAL_RETENTION_SECS, RUNNER_JOB_CONCURRENCY_MAX,
RUNNER_JOB_CONCURRENCY_MIN, RUST_TEST_FILTER_MAX_BYTES, VALIDATION_ASSERTION_NAME_MAX_CHARS,
};
Expand Down Expand Up @@ -695,6 +695,12 @@ runner_capabilities! {
#[serde(default, skip_serializing_if = "is_false")]
pub project_validation_node_tap_v1: bool = false;
}
/// Node native TAP test-name pattern (separate rolling-upgrade capability).
ProjectValidationNodeNameFilter => RUNNER_CAPABILITY_PROJECT_VALIDATION_NODE_NAME_FILTER("project_validation_node_name_filter_v1"),
v2_baseline = false {
#[serde(default, skip_serializing_if = "is_false")]
pub project_validation_node_name_filter_v1: bool = false;
}
/// The Runner understands the first-class model-facing `go_test` tool identity
/// and its durable `ShellJobValidationMetadata` contract. This is deliberately
/// separate from Go JSON parsing support: older Runners may advertise
Expand Down Expand Up @@ -2945,6 +2951,7 @@ mod envelope_tests {
project_validation_python_ruff_v1: false,
project_validation_node_script_check_v1: false,
project_validation_node_tap_v1: false,
project_validation_node_name_filter_v1: false,
project_validation_rust_clippy_v1: false,
project_validation_rust_clippy_offline_v1: false,
structured_go_test_tool: true,
Expand Down
66 changes: 61 additions & 5 deletions crates/webcodex-core/src/runner_protocol/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,26 @@ impl ShellJobValidationStep {
}

/// Exact native Node test executor; manifest and Runner capability are separate fences.
/// Accepts the canonical two-arg unfiltered form or the three-arg form with a
/// validated `--test-name-pattern=<pattern>` appended by the Runner.
pub fn is_structured_node_tap_test(&self) -> bool {
self.name == "test"
&& self.program == "node"
&& self.env.is_empty()
&& self.args == ["--test", "--test-reporter=tap"]
&& self.args.first().map(String::as_str) == Some("--test")
&& self.args.get(1).map(String::as_str) == Some("--test-reporter=tap")
&& match self.args.len() {
2 => true,
3 => self.args[2]
.strip_prefix("--test-name-pattern=")
.is_some_and(|pattern| {
matches!(
normalize_node_native_test_filter(pattern),
Ok(Some(normalized)) if normalized == pattern
)
}),
_ => false,
}
}

/// Fixed local framework CLI and report placeholder, rewritten only by the
Expand Down Expand Up @@ -663,6 +678,27 @@ pub fn normalize_go_test_filter(raw: &str) -> Result<Option<String>, &'static st
Ok((!raw.is_empty()).then(|| raw.to_owned()))
}

/// Bounded native Node `--test-name-pattern` JavaScript regexp. Preserve
/// meaningful whitespace; blank-only input means no filter. Empty
/// means no filter. Rejects control characters, option-shaped values (leading
/// `-`), and values exceeding the shared 200-byte bound. The Runner transports
/// this as one literal argv `--test-name-pattern=<pattern>`, never as shell.
pub fn normalize_node_native_test_filter(raw: &str) -> Result<Option<String>, &'static str> {
if raw.len() > RUST_TEST_FILTER_MAX_BYTES {
return Err("Node test filter exceeds 200 bytes");
}
if raw.chars().any(char::is_control) {
return Err("Node test filter contains control characters");
}
if raw.trim().is_empty() {
return Ok(None);
}
if raw.trim_start().starts_with('-') {
return Err("Node test filter must not start with '-'");
}
Ok(Some(raw.to_string()))
}

fn is_canonical_go_vet_args(args: &[&str]) -> bool {
if args.len() < 2 || args[0] != "vet" {
return false;
Expand Down Expand Up @@ -1113,6 +1149,29 @@ impl ShellJobValidationMetadata {
)
) || provenance.request.scope.is_some()
|| provenance.request.dependency_policy.is_some()
|| (self.adapter.starts_with("node:")
&& self.adapter.ends_with(":test")
&& {
let requested = provenance
.request
.test
.as_ref()
.and_then(|test| test.filter.as_deref())
.map(normalize_node_native_test_filter)
.transpose();
match (self.adapter.as_str(), requested) {
("node:tap:test", Ok(filter)) => {
self.steps[0].args.get(2).and_then(|arg| {
arg.strip_prefix("--test-name-pattern=")
}) != filter.flatten().as_deref()
}
("node:vitest:test" | "node:jest:test", Ok(Some(Some(_)))) => {
true
}
(_, Err(_)) => true,
_ => false,
}
})
|| (provenance.request.action
== crate::project_validation::ProjectValidationAction::Lint
&& self.steps[0].args != ["--run", "lint"])
Expand Down Expand Up @@ -1168,10 +1227,7 @@ impl ShellJobValidationMetadata {
"node:tap:test" => {
self.tool == "project_validate"
&& self.kind == "test"
&& step.name == "test"
&& step.program == "node"
&& step.args == ["--test", "--test-reporter=tap"]
&& step.env.is_empty()
&& step.is_structured_node_tap_test()
}
"node:script:check" => {
self.tool == "project_validate"
Expand Down
46 changes: 39 additions & 7 deletions crates/webcodex-core/src/validation_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ fn node_native_test_summary(stdout: &str) -> Option<CargoTestSummary> {
// Its N counts top-level assertions, not all nested tests in # tests.
let root_count = body.last()?.strip_prefix("1..")?.parse::<u64>().ok()?;
let mut root_assertions = 0u64;
// Native Node may emit a synthetic passing file assertion after root 1..0.
// Require that exact adjacent named wrapper before excluding its count.
let mut pending_empty_file_wrapper = false;
let mut empty_file_wrapper_name: Option<&str> = None;
let mut synthetic_file_wrappers = 0u64;
let fields = [
"# tests ",
"# suites ",
Expand All @@ -274,11 +279,31 @@ fn node_native_test_summary(stdout: &str) -> Option<CargoTestSummary> {
.strip_prefix("# duration_ms ")
.and_then(|value| value.parse::<f64>().ok())
.is_some_and(|value| value.is_finite() && value >= 0.0);
if line.starts_with("1..")
|| line.starts_with("TAP version ")
|| repeated_count
|| repeated_duration
{
if line.starts_with("TAP version ") || repeated_count || repeated_duration {
return None;
}
if pending_empty_file_wrapper {
let name = line.strip_prefix("# Subtest: ")?;
if name.is_empty() {
return None;
}
empty_file_wrapper_name = Some(name);
pending_empty_file_wrapper = false;
continue;
}
if let Some(name) = empty_file_wrapper_name.take() {
root_assertions = root_assertions.checked_add(1)?;
if *line != format!("ok {root_assertions} - {name}") {
return None;
}
synthetic_file_wrappers = synthetic_file_wrappers.checked_add(1)?;
continue;
}
if *line == "1..0" {
pending_empty_file_wrapper = true;
continue;
}
if line.starts_with("1..") {
return None;
}
let ordinal = line
Expand All @@ -291,7 +316,10 @@ fn node_native_test_summary(stdout: &str) -> Option<CargoTestSummary> {
}
}
}
if root_assertions != root_count {
if pending_empty_file_wrapper
|| empty_file_wrapper_name.is_some()
|| root_assertions != root_count
{
return None;
}
// Accounting lines must themselves be top-level (unindented).
Expand Down Expand Up @@ -319,8 +347,12 @@ fn node_native_test_summary(stdout: &str) -> Option<CargoTestSummary> {
{
return None;
}
let real_passed = passed.checked_sub(synthetic_file_wrappers)?;
if synthetic_file_wrappers > 0 && real_passed == 0 && failed == 0 && skipped == 0 && todo == 0 {
return None;
}
Some(CargoTestSummary {
passed: Some(passed),
passed: Some(real_passed),
failed: Some(failed),
ignored: skipped.checked_add(todo),
})
Expand Down
38 changes: 38 additions & 0 deletions crates/webcodex-core/src/validation_evidence_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,44 @@ fn node_native_tap_counts_only_complete_conservative_reporter_trailers() {
);
}

#[test]
fn node_native_tap_filtered_file_wrappers_never_inflate_real_test_count() {
use crate::validation_evidence::parse_node_native_test_diagnostics;
// Native Node 24 emits a synthetic passing file-level assertion when a
// test file has no names matching --test-name-pattern. In a multi-file
// run those assertions must not be counted as executed tests.
let mixed = "TAP version 13\n# Subtest: alpha\nok 1 - alpha\n1..0\n# Subtest: b.test.js\nok 2 - b.test.js\n1..2\n# tests 2\n# suites 0\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 87.4\n";
let summary = parse_node_native_test_diagnostics(mixed, false)
.test_summary
.expect("one matched Node test can be proven despite a nonmatching file");
assert_eq!(
(summary.passed, summary.failed, summary.ignored),
(Some(1), Some(0), Some(0))
);
for forged in [
mixed.replace("# Subtest: b.test.js", "# Subtest: other.test.js"),
mixed.replace("# Subtest: b.test.js\n", ""),
mixed.replace("1..0\n# Subtest:", "1..0\n# diagnostic: fake\n# Subtest:"),
mixed.replace("ok 2 - b.test.js", "not ok 2 - b.test.js"),
mixed.replace("ok 2 - b.test.js", "ok 2 - b.test.js # SKIP"),
mixed.replace("1..0", "1..0\n1..0"),
] {
assert!(
parse_node_native_test_diagnostics(&forged, false)
.test_summary
.is_none(),
"malformed file wrapper must not prove any executed tests"
);
}
let none = "TAP version 13\n1..0\n# Subtest: a.test.js\nok 1 - a.test.js\n1..0\n# Subtest: b.test.js\nok 2 - b.test.js\n1..2\n# tests 2\n# suites 0\n# pass 2\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 80.0\n";
assert!(
parse_node_native_test_diagnostics(none, false)
.test_summary
.is_none(),
"zero matching tests must not become a successful test-count receipt"
);
}

#[test]
fn node_native_tap_root_plan_and_footer_are_authoritative() {
use crate::validation_evidence::parse_node_native_test_diagnostics;
Expand Down
19 changes: 16 additions & 3 deletions crates/webcodex-core/src/validation_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,22 @@ pub fn structured_validation_target_identity(
}
serde_json::json!({"tool": tool_name, "kind": "check", "cwd": cwd, "script": script, "engine": "node-native-run-v1"})
}
ToolValidationIdentityKind::NodeNativeTest
| ToolValidationIdentityKind::NodeVitestTest
| ToolValidationIdentityKind::NodeJestTest => {
ToolValidationIdentityKind::NodeNativeTest => {
let filter = match obj.get("filter") {
None | Some(Value::Null) => None,
Some(value) => {
crate::runner_protocol::normalize_node_native_test_filter(value.as_str()?)
.ok()?
}
};
// Keep the existing unfiltered Node target identity unchanged.
let mut semantic = serde_json::json!({"tool":tool_name,"kind":"test","cwd":cwd});
if let Some(filter) = filter {
semantic["filter"] = Value::String(filter);
}
semantic
}
ToolValidationIdentityKind::NodeVitestTest | ToolValidationIdentityKind::NodeJestTest => {
serde_json::json!({"tool":tool_name,"kind":"test","cwd":cwd})
}
ToolValidationIdentityKind::PythonPytest => {
Expand Down
1 change: 1 addition & 0 deletions crates/webcodex-runner-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result<String,
project_validation_python_ruff_v1: false,
project_validation_node_script_check_v1: false,
project_validation_node_tap_v1: false,
project_validation_node_name_filter_v1: false,
project_validation_rust_clippy_v1: false,
project_validation_rust_clippy_offline_v1: false,
// Like JSON parsing, first-class durable go_test support is
Expand Down
21 changes: 21 additions & 0 deletions crates/webcodex-runner-registry/src/job_updates/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,23 @@ impl JobStartPlan {
.any(ShellJobValidationStep::is_structured_node_tap_test);
let project_test_options =
validation_provenance.is_some_and(|provenance| provenance.request.test.is_some());
// Re-evaluate the resolved backend at Job admission, including queued
// requests and auto Node plans after a rolling Runner replacement.
let node_name_filter = validation_provenance.is_some_and(|provenance| {
provenance.backend == "node"
&& provenance
.request
.test
.as_ref()
.and_then(|test| test.filter.as_deref())
.is_some_and(|filter| !filter.trim().is_empty())
}) || steps.iter().any(|step| {
step.program == "node"
&& step
.args
.get(2)
.is_some_and(|arg| arg.starts_with("--test-name-pattern="))
});
let go_test_filter = steps.iter().any(|step| {
step.is_structured_go_test_json() && step.args.get(2).is_some_and(|arg| arg == "-run")
});
Expand Down Expand Up @@ -754,6 +771,10 @@ impl JobStartPlan {
RunnerFeature::ProjectValidationRustClippyOffline,
),
(node_test, RunnerFeature::ProjectValidationNodeTap),
(
node_name_filter,
RunnerFeature::ProjectValidationNodeNameFilter,
),
(
project_test_options || go_test_filter,
RunnerFeature::ProjectValidationTestOptions,
Expand Down
Loading
Loading