#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, ResponseTemplate};
mod harness;
use harness::{Harness, fixture};
async fn tracker_answers(harness: &Harness) {
let json = |name: &str| ResponseTemplate::new(200).set_body_json(fixture(name));
for (route, body) in [
("/v3/issues/PROJ-1", "issue.json"),
("/v3/issues/PROJ-1/links", "issue_links.json"),
("/v3/issues/PROJ-1/comments", "issue_comments.json"),
("/v3/issues/PROJ-1/changelog", "changelog.json"),
("/v3/issues/PROJ-1/remotelinks", "issue_remotelinks.json"),
("/v3/queues", "queues.json"),
("/v3/queues/PROJ", "queue.json"),
("/v3/queues/PROJ/fields", "queue_fields.json"),
("/v3/queues/PROJ/versions", "queue_versions.json"),
("/v3/queues/PROJ/tags", "queue_tags.json"),
("/v3/queues/PROJ/macros", "queue_macros.json"),
("/v3/queues/PROJ/permissions", "queue_permissions.json"),
("/v3/queues/PROJ/access", "queue_access.json"),
("/v3/myself", "user.json"),
(
"/v3/bulkchange/6a92d90773c59502bc8e028a",
"bulkchange_complete.json",
),
("/v3/queues/PROJ/localFields", "queue_local_fields.json"),
("/v3/sprints", "all_sprints.json"),
("/v3/sprints/21", "sprint.json"),
("/v3/queues/PROJ/autoactions", "queue_autoactions.json"),
("/v3/queues/PROJ/triggers", "queue_triggers.json"),
("/v3/fields", "fields.json"),
("/v3/components", "components.json"),
("/v3/linktypes", "linktypes.json"),
("/v3/fields/storyPoints", "field.json"),
("/v3/issueTemplates", "issue_templates.json"),
("/v3/boards", "boards.json"),
("/v3/boards/6", "board.json"),
("/v3/boards/9/sprints", "sprints.json"),
("/v3/issuetypes", "issuetypes.json"),
("/v3/priorities", "priorities.json"),
("/v3/statuses", "statuses.json"),
("/v3/resolutions", "resolutions.json"),
("/v3/users/ilubenets", "user.json"),
] {
Mock::given(method("GET"))
.and(path(route))
.respond_with(json(body))
.mount(&harness.server)
.await;
}
Mock::given(method("POST"))
.and(path("/v3/issues/_search"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(fixture("issue_search.json"))
.append_header("X-Total-Count", "2"),
)
.mount(&harness.server)
.await;
entity_answers(harness).await;
Mock::given(method("GET"))
.and(path("/v3/boards/6/sprints"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"errors": {},
"errorMessages": ["A board of this type cannot have sprints."],
"statusCode": 400
})))
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/worklog"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": 1,
"issue": {"key": "PROJ-1", "display": "Attachments are lost on move"},
"duration": "PT1H30M",
"start": "2026-08-24T09:00:00.000+0300",
"createdBy": {"id": "1", "login": "ilubenets", "display": "Ilya Lubenets"},
"comment": "pairing"
}
])))
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/users"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(fixture("users.json"))
.append_header("X-Total-Count", "4"),
)
.mount(&harness.server)
.await;
Mock::given(method("POST"))
.and(path("/v3/issues/_count"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(2)))
.mount(&harness.server)
.await;
}
async fn entity_answers(harness: &Harness) {
let json = |name: &str| ResponseTemplate::new(200).set_body_json(fixture(name));
let parent = serde_json::json!({
"filter": {"parentEntity": "655a1d0c5f1b2c0011223344"}
});
for (kind, listing, contained) in [
(
"portfolio",
"entities_portfolio.json",
"entities_in_portfolio.json",
),
(
"project",
"entities_project.json",
"projects_in_portfolio.json",
),
] {
Mock::given(method("POST"))
.and(path(format!("/v3/entities/{kind}/_search")))
.and(body_json(parent.clone()))
.respond_with(json(contained))
.mount(&harness.server)
.await;
Mock::given(method("POST"))
.and(path(format!("/v3/entities/{kind}/_search")))
.and(body_json(serde_json::json!({})))
.respond_with(json(listing))
.mount(&harness.server)
.await;
}
Mock::given(method("POST"))
.and(path("/v3/entities/goal/_search"))
.respond_with(json("entities_goal.json"))
.mount(&harness.server)
.await;
for (route, body) in [
(
"/v3/entities/portfolio/655a1d0c5f1b2c0011223355",
"entity_portfolio.json",
),
(
"/v3/entities/project/655a1d0c5f1b2c0011223366",
"entity_project.json",
),
(
"/v3/entities/goal/655a1d0c5f1b2c0011223388",
"entity_goal.json",
),
] {
Mock::given(method("GET"))
.and(path(route))
.respond_with(json(body))
.mount(&harness.server)
.await;
}
}
#[tokio::test]
async fn the_documented_examples_still_produce_the_documented_output() {
let harness = Harness::new().await;
tracker_answers(&harness).await;
trycmd::TestCases::new()
.default_bin_name("ytcli")
.env("YTCLI_CONFIG", harness.config_path().display().to_string())
.env("YTCLI_BASE_URL", harness.server.uri())
.env("YTCLI_TOKEN", "test-token")
.env("YTCLI_PROFILE", "test")
.case("tests/docs/*.md")
.run();
}
const UNRUNNABLE: &[(&str, &str)] = &[
("auth login", "interactive; asks for a token"),
("auth logout", "would touch the keychain"),
("auth refresh", "reads the keychain before anything else"),
(
"auth status",
"several requests per profile, and prints identity",
),
("auth list", "prints the profiles of whoever runs it"),
("auth use", "rewrites the config of whoever runs it"),
("auth edit", "rewrites the config of whoever runs it"),
("auth remove", "rewrites the config of whoever runs it"),
("issue create", "a write"),
("queue create", "a write"),
("project create", "a write"),
("project update", "a write"),
("project delete", "a write"),
("issue update", "a write"),
("issue comment", "a write"),
("issue transition", "a write"),
("issue move", "a write"),
("issue worklogs", "no recorded worklog fixture yet"),
("issue checklist", "no recorded checklist fixture yet"),
("issue worklog", "a write"),
("issue timer", "a write, and it keeps state between runs"),
(
"issue timers",
"reports whatever the machine running the docs happens to be timing",
),
("issue check", "a write"),
("issue link", "a write"),
("portfolio place", "a write"),
("project place", "a write"),
(
"sprint get",
"how far through a sprint is depends on today's date",
),
("attachment list", "no recorded attachment fixture yet"),
(
"attachment show",
"draws pixels, or names a download command with a real id",
),
("attachment download", "writes a file"),
("attachment delete", "a write, and the one with no undo"),
("attachment upload", "a write"),
(
"wiki get",
"its fixture is borrowed from the API reference, and an example would pin a guess",
),
("wiki list", "the same borrowed fixtures as wiki get"),
("wiki find", "the same borrowed fixtures as wiki get"),
("wiki comments", "the same borrowed fixtures as wiki get"),
("wiki attachments", "the same borrowed fixtures as wiki get"),
("wiki download", "writes a file"),
("wiki grids", "the same borrowed fixtures as wiki get"),
("wiki grid", "the same borrowed fixtures as wiki get"),
("wiki resources", "the same borrowed fixtures as wiki get"),
("wiki create", "a write"),
("wiki update", "a write"),
("wiki append", "a write"),
("wiki delete", "a write"),
("wiki restore", "a write"),
("wiki comment", "a write"),
("wiki access", "the same borrowed fixtures as wiki get"),
("wiki grant", "a write"),
("wiki regrant", "a write"),
("wiki revoke", "a write"),
("wiki clone", "a write"),
("wiki upload", "a write"),
("wiki operation", "names an operation only a real clone has"),
];
#[test]
fn every_documented_command_is_either_run_or_declared_unrunnable() {
let cases = read("tests/docs/readme.md") + &read("tests/docs/cheatsheet.md");
for (source, text) in documentation() {
for command in commands_in(&text) {
if UNRUNNABLE.iter().any(|(name, _)| *name == command) {
continue;
}
assert!(
cases.contains(&format!("$ ytcli {command}")),
"{source} documents `ytcli {command}`, which no case runs. \
Add one to tests/docs/, or add it to UNRUNNABLE with a reason."
);
}
}
}
fn read(relative: &str) -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("{relative} is readable"))
}
fn documentation() -> Vec<(&'static str, String)> {
vec![
("README.md", read("README.md")),
("docs/cheatsheet.txt", read("docs/cheatsheet.txt")),
]
}
fn commands_in(text: &str) -> Vec<String> {
let mut found: Vec<String> = text
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("ytcli ")?;
let path: Vec<&str> = rest
.split_whitespace()
.take_while(|word| word.chars().all(|c| c.is_ascii_lowercase()))
.take(2)
.collect();
(path.len() == 2).then(|| path.join(" "))
})
.collect();
found.sort();
found.dedup();
found
}