Apply stable CLI failure exit codes
This commit is contained in:
parent
50d298ead7
commit
b808dad0e5
8 changed files with 224 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"kind": "disasmer-filtered-public-tree",
|
"kind": "disasmer-filtered-public-tree",
|
||||||
"source_commit": "3d37a45472228c553477f8462927dfa6a6622882",
|
"source_commit": "143d109ea0261671012be8ef6b48e86284601eec",
|
||||||
"release_name": "dryrun-3d37a4547222",
|
"release_name": "dryrun-143d109ea026",
|
||||||
"filtered_out": [
|
"filtered_out": [
|
||||||
"private/**",
|
"private/**",
|
||||||
"experiments/**",
|
"experiments/**",
|
||||||
|
|
|
||||||
|
|
@ -1676,12 +1676,16 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result<Value> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_report<T: Serialize>(report: &T, json_output: bool) -> Result<()> {
|
fn emit_report<T: Serialize>(report: &T, json_output: bool) -> Result<()> {
|
||||||
let value = serde_json::to_value(report)?;
|
let mut value = serde_json::to_value(report)?;
|
||||||
|
let exit_code = apply_command_report_exit_code(&mut value);
|
||||||
if json_output {
|
if json_output {
|
||||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||||
} else {
|
} else {
|
||||||
println!("{}", human_report(&value));
|
println!("{}", human_report(&value));
|
||||||
}
|
}
|
||||||
|
if let Some(exit_code) = exit_code {
|
||||||
|
std::process::exit(exit_code);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2046,6 +2050,33 @@ fn compact_json(value: &Value) -> String {
|
||||||
serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_owned())
|
serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_command_report_exit_code(value: &mut Value) -> Option<i32> {
|
||||||
|
for pointer in [
|
||||||
|
"/machine_error",
|
||||||
|
"/run_start/machine_error",
|
||||||
|
"/restart_request/machine_error",
|
||||||
|
"/cancel_request/machine_error",
|
||||||
|
"/task_restart/machine_error",
|
||||||
|
] {
|
||||||
|
let Some(machine_error) = value.pointer_mut(pointer) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(exit_code) = machine_error
|
||||||
|
.get("stable_exit_code")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|code| i32::try_from(code).ok())
|
||||||
|
.filter(|code| *code != 0)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(object) = machine_error.as_object_mut() {
|
||||||
|
object.insert("process_exit_code_applied".to_owned(), json!(true));
|
||||||
|
}
|
||||||
|
return Some(exit_code);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn cli_error_summary(message: &str) -> Value {
|
fn cli_error_summary(message: &str) -> Value {
|
||||||
let category = classify_cli_error_message(message);
|
let category = classify_cli_error_message(message);
|
||||||
cli_error_summary_for_category(category, message)
|
cli_error_summary_for_category(category, message)
|
||||||
|
|
@ -2230,7 +2261,25 @@ fn cli_error_next_actions(category: &str) -> Vec<&'static str> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() {
|
||||||
|
if let Err(error) = run_cli() {
|
||||||
|
let message = error.to_string();
|
||||||
|
let machine_error = cli_error_summary(&message);
|
||||||
|
let category = machine_error
|
||||||
|
.get("category")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let exit_code = machine_error
|
||||||
|
.get("stable_exit_code")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|code| i32::try_from(code).ok())
|
||||||
|
.unwrap_or(1);
|
||||||
|
eprintln!("Error ({category}, exit {exit_code}): {error:#}");
|
||||||
|
std::process::exit(exit_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cli() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Doctor(args) => {
|
Commands::Doctor(args) => {
|
||||||
|
|
@ -2912,15 +2961,25 @@ fn artifact_name_from_path(path: &str) -> String {
|
||||||
path.rsplit('/').next().unwrap_or(path).to_owned()
|
path.rsplit('/').next().unwrap_or(path).to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn response_error_message(response: &Value, fallback: &str) -> String {
|
||||||
|
response
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| fallback.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
fn process_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
|
fn process_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
|
||||||
if response.get("type").and_then(Value::as_str) != Some("process_started") {
|
if response.get("type").and_then(Value::as_str) != Some("process_started") {
|
||||||
|
let message = response_error_message(response, "coordinator rejected process restart");
|
||||||
return json!({
|
return json!({
|
||||||
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
|
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
|
||||||
"operation": "restart_virtual_process",
|
"operation": "restart_virtual_process",
|
||||||
"accepted": false,
|
"accepted": false,
|
||||||
"requires_confirmation": requires_confirmation,
|
"requires_confirmation": requires_confirmation,
|
||||||
"explicit_user_action": true,
|
"explicit_user_action": true,
|
||||||
"error": response.get("message").cloned().unwrap_or(Value::Null),
|
"error": message,
|
||||||
|
"machine_error": cli_error_summary(&message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2939,6 +2998,7 @@ fn process_restart_request_summary(response: &Value, requires_confirmation: bool
|
||||||
|
|
||||||
fn process_cancel_request_summary(response: &Value, requires_confirmation: bool) -> Value {
|
fn process_cancel_request_summary(response: &Value, requires_confirmation: bool) -> Value {
|
||||||
if response.get("type").and_then(Value::as_str) != Some("process_cancellation_requested") {
|
if response.get("type").and_then(Value::as_str) != Some("process_cancellation_requested") {
|
||||||
|
let message = response_error_message(response, "coordinator rejected process cancel");
|
||||||
return json!({
|
return json!({
|
||||||
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
|
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
|
||||||
"operation": "cancel_virtual_process",
|
"operation": "cancel_virtual_process",
|
||||||
|
|
@ -2946,7 +3006,8 @@ fn process_cancel_request_summary(response: &Value, requires_confirmation: bool)
|
||||||
"requires_confirmation": requires_confirmation,
|
"requires_confirmation": requires_confirmation,
|
||||||
"explicit_user_action": true,
|
"explicit_user_action": true,
|
||||||
"whole_process_cancel_available": true,
|
"whole_process_cancel_available": true,
|
||||||
"error": response.get("message").cloned().unwrap_or(Value::Null),
|
"error": message,
|
||||||
|
"machine_error": cli_error_summary(&message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2975,6 +3036,7 @@ fn process_cancel_request_summary(response: &Value, requires_confirmation: bool)
|
||||||
|
|
||||||
fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
|
fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value {
|
||||||
if response.get("type").and_then(Value::as_str) != Some("task_restart") {
|
if response.get("type").and_then(Value::as_str) != Some("task_restart") {
|
||||||
|
let message = response_error_message(response, "coordinator rejected task restart");
|
||||||
return json!({
|
return json!({
|
||||||
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
|
"status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"),
|
||||||
"operation": "restart_selected_task",
|
"operation": "restart_selected_task",
|
||||||
|
|
@ -2982,7 +3044,8 @@ fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -
|
||||||
"requires_confirmation": requires_confirmation,
|
"requires_confirmation": requires_confirmation,
|
||||||
"explicit_user_action": true,
|
"explicit_user_action": true,
|
||||||
"clean_boundary_required": true,
|
"clean_boundary_required": true,
|
||||||
"error": response.get("message").cloned().unwrap_or(Value::Null),
|
"error": message,
|
||||||
|
"machine_error": cli_error_summary(&message),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4415,6 +4478,39 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_report_exit_code_marks_command_failures_only() {
|
||||||
|
let run_start = run_start_summary(&json!({
|
||||||
|
"type": "error",
|
||||||
|
"message": "quota unavailable: resource limit exceeded for api_calls",
|
||||||
|
}));
|
||||||
|
let mut report = json!({
|
||||||
|
"command": "run",
|
||||||
|
"status": run_start["status"].clone(),
|
||||||
|
"run_start": run_start,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(apply_command_report_exit_code(&mut report), Some(22));
|
||||||
|
assert_eq!(
|
||||||
|
report["run_start"]["machine_error"]["process_exit_code_applied"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut task_list = json!({
|
||||||
|
"command": "task list",
|
||||||
|
"tasks": [{
|
||||||
|
"task": "compile",
|
||||||
|
"state": "failed",
|
||||||
|
"machine_error": cli_error_summary_for_category("program", "task exited with status 1"),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert_eq!(apply_command_report_exit_code(&mut task_list), None);
|
||||||
|
assert_eq!(
|
||||||
|
task_list["tasks"][0]["machine_error"]["process_exit_code_applied"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_level_version_is_available() {
|
fn top_level_version_is_available() {
|
||||||
let command = Cli::command();
|
let command = Cli::command();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ node scripts/release-blocker-smoke.js
|
||||||
node scripts/resource-metering-contract-smoke.js
|
node scripts/resource-metering-contract-smoke.js
|
||||||
node scripts/hostile-input-contract-smoke.js
|
node scripts/hostile-input-contract-smoke.js
|
||||||
node scripts/tenant-isolation-contract-smoke.js
|
node scripts/tenant-isolation-contract-smoke.js
|
||||||
|
node scripts/cli-error-exit-smoke.js
|
||||||
scripts/release-source-scan.sh
|
scripts/release-source-scan.sh
|
||||||
cargo test --manifest-path private/hosted-policy/Cargo.toml
|
cargo test --manifest-path private/hosted-policy/Cargo.toml
|
||||||
node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js
|
node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ cargo build --workspace --bins
|
||||||
node scripts/docs-smoke.js
|
node scripts/docs-smoke.js
|
||||||
node scripts/cli-output-mode-smoke.js
|
node scripts/cli-output-mode-smoke.js
|
||||||
node scripts/cli-login-smoke.js
|
node scripts/cli-login-smoke.js
|
||||||
|
node scripts/cli-error-exit-smoke.js
|
||||||
node scripts/cli-browser-login-flow-smoke.js
|
node scripts/cli-browser-login-flow-smoke.js
|
||||||
node scripts/cli-install-smoke.js
|
node scripts/cli-install-smoke.js
|
||||||
node scripts/user-session-token-boundary-smoke.js
|
node scripts/user-session-token-boundary-smoke.js
|
||||||
|
|
|
||||||
93
scripts/cli-error-exit-smoke.js
Executable file
93
scripts/cli-error-exit-smoke.js
Executable file
|
|
@ -0,0 +1,93 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
|
||||||
|
function runDisasmer(args) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args],
|
||||||
|
{
|
||||||
|
cwd: repo,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk;
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk;
|
||||||
|
});
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
resolve({ code, signal, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let request = "";
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.setEncoding("utf8");
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
request += chunk;
|
||||||
|
if (!request.includes("\n")) return;
|
||||||
|
socket.write(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "quota unavailable: resource limit exceeded for api_calls",
|
||||||
|
}) + "\n"
|
||||||
|
);
|
||||||
|
socket.end();
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = await new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve(server.address()));
|
||||||
|
});
|
||||||
|
const coordinator = `http://${address.address}:${address.port}`;
|
||||||
|
|
||||||
|
const result = await runDisasmer([
|
||||||
|
"run",
|
||||||
|
"build",
|
||||||
|
"--project",
|
||||||
|
project,
|
||||||
|
"--coordinator",
|
||||||
|
coordinator,
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.strictEqual(result.signal, null, result.stderr);
|
||||||
|
assert.strictEqual(result.code, 22, result.stderr);
|
||||||
|
assert.match(request, /"type":"start_process"/);
|
||||||
|
const report = JSON.parse(result.stdout);
|
||||||
|
assert.strictEqual(report.status, "coordinator_rejected");
|
||||||
|
assert.strictEqual(report.run_start.machine_error.category, "quota");
|
||||||
|
assert.strictEqual(report.run_start.machine_error.stable_exit_code, 22);
|
||||||
|
assert.strictEqual(
|
||||||
|
report.run_start.machine_error.process_exit_code_applied,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
report.run_start.machine_error.next_actions.includes("disasmer quota status")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => {
|
||||||
|
console.log("CLI error exit smoke passed");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -34,6 +34,7 @@ const criteria = read("cli_acceptance_criteria.md");
|
||||||
const cli = read("crates/disasmer-cli/src/main.rs");
|
const cli = read("crates/disasmer-cli/src/main.rs");
|
||||||
const coordinator = read("crates/disasmer-coordinator/src/service.rs");
|
const coordinator = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
const outputModeSmoke = read("scripts/cli-output-mode-smoke.js");
|
const outputModeSmoke = read("scripts/cli-output-mode-smoke.js");
|
||||||
|
const errorExitSmoke = read("scripts/cli-error-exit-smoke.js");
|
||||||
|
|
||||||
expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m);
|
expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m);
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -116,6 +117,7 @@ for (const [name, pattern] of [
|
||||||
["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/],
|
["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/],
|
||||||
["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/],
|
["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/],
|
||||||
["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/],
|
["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/],
|
||||||
|
["CLI command exit-code coverage", /fn command_report_exit_code_marks_command_failures_only\(\)/],
|
||||||
["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/],
|
["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/],
|
||||||
["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/],
|
["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/],
|
||||||
["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/],
|
["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/],
|
||||||
|
|
@ -240,6 +242,16 @@ expect(
|
||||||
"CLI attaches machine errors to task failures",
|
"CLI attaches machine errors to task failures",
|
||||||
/task_failure_machine_error[\s\S]*cli_error_summary_with_default/
|
/task_failure_machine_error[\s\S]*cli_error_summary_with_default/
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
cli,
|
||||||
|
"CLI applies process exit code after printing command failure report",
|
||||||
|
/fn emit_report<T: Serialize>[\s\S]*apply_command_report_exit_code[\s\S]*std::process::exit\(exit_code\)/
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
cli,
|
||||||
|
"CLI wraps fallible main with classified exit",
|
||||||
|
/fn main\(\)[\s\S]*cli_error_summary\(&message\)[\s\S]*std::process::exit\(exit_code\)/
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
cli,
|
cli,
|
||||||
"CLI parses dangerous capability overrides",
|
"CLI parses dangerous capability overrides",
|
||||||
|
|
@ -269,4 +281,13 @@ for (const [name, pattern] of [
|
||||||
expect(outputModeSmoke, name, pattern);
|
expect(outputModeSmoke, name, pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["fake coordinator quota rejection", /quota unavailable: resource limit exceeded for api_calls/],
|
||||||
|
["run uses JSON mode", /"run"[\s\S]*"--json"/],
|
||||||
|
["actual quota exit code", /assert\.strictEqual\(result\.code, 22/],
|
||||||
|
["exit-code application in JSON", /process_exit_code_applied[\s\S]*true/],
|
||||||
|
]) {
|
||||||
|
expect(errorExitSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("CLI-first contract smoke passed");
|
console.log("CLI-first contract smoke passed");
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,10 @@ for (const script of [publicAcceptance, publicSplit]) {
|
||||||
script.includes("node scripts/cli-login-smoke.js"),
|
script.includes("node scripts/cli-login-smoke.js"),
|
||||||
"public acceptance gates must run cli-login-smoke.js"
|
"public acceptance gates must run cli-login-smoke.js"
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/cli-error-exit-smoke.js"),
|
||||||
|
"public acceptance gates must run cli-error-exit-smoke.js"
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
script.includes("node scripts/acceptance-report-smoke.js"),
|
script.includes("node scripts/acceptance-report-smoke.js"),
|
||||||
"public acceptance gates must run acceptance-report-smoke.js"
|
"public acceptance gates must run acceptance-report-smoke.js"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ fi
|
||||||
(cd "$tmp_dir" && node scripts/docs-smoke.js)
|
(cd "$tmp_dir" && node scripts/docs-smoke.js)
|
||||||
(cd "$tmp_dir" && node scripts/cli-output-mode-smoke.js)
|
(cd "$tmp_dir" && node scripts/cli-output-mode-smoke.js)
|
||||||
(cd "$tmp_dir" && node scripts/cli-login-smoke.js)
|
(cd "$tmp_dir" && node scripts/cli-login-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/cli-error-exit-smoke.js)
|
||||||
(cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js)
|
(cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js)
|
||||||
(cd "$tmp_dir" && node scripts/cli-install-smoke.js)
|
(cd "$tmp_dir" && node scripts/cli-install-smoke.js)
|
||||||
(cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js)
|
(cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue