use insta::assert_snapshot;
use rstest::rstest;
use v_fixtures::FixtureRenderer;
use crate::common::{
FixtureIssuesExt, Seed, TestContext,
are_you_sure::{UnsafePathExt, read_issue_file, write_to_path},
parse_virtual, render_fixture,
};
struct DivergedBodiesFixture {
ctx: TestContext,
local: tedi::Issue,
}
impl DivergedBodiesFixture {
async fn new(consensus_seed: i64, local_seed: i64, remote_seed: i64) -> Self {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let consensus_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
consensus body
"#,
);
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote changed body
"#,
);
ctx.consensus(&consensus_vi, Some(Seed::new(consensus_seed))).await;
let local = ctx.local(&local_vi, Some(Seed::new(local_seed))).await;
ctx.remote(&remote_vi, Some(Seed::new(remote_seed)));
Self { ctx, local }
}
}
#[rstest]
#[case::remote_wins(-50, 40, 45, "remote changed body")]
#[case::local_wins(-70, 60, 65, "local body")]
#[tokio::test]
async fn test_both_diverged_merge_winner(#[case] consensus_seed: i64, #[case] local_seed: i64, #[case] remote_seed: i64, #[case] expected_body: &str) {
let f = DivergedBodiesFixture::new(consensus_seed, local_seed, remote_seed).await;
let out = f.ctx.open_issue(&f.local).run();
let rendered = render_fixture(FixtureRenderer::try_new(&f.ctx).unwrap(), &out);
assert!(rendered.contains(expected_body), "Expected body '{expected_body}' not found in:\n{rendered}");
}
#[tokio::test]
async fn test_only_remote_changed_takes_remote_with_pull() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let consensus_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
consensus body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote changed body
"#,
);
let consensus = ctx.consensus(&consensus_vi, Some(Seed::new(-45))).await;
ctx.remote(&remote_vi, Some(Seed::new(90)));
let out = ctx.open_issue(&consensus).args(&["--pull"]).run();
assert!(
out.status.success() && (out.stdout.contains("Syncing") || out.stdout.contains("pre-open sync")),
"Should succeed with sync activity. stdout: {}, stderr: {}",
out.stdout,
out.stderr
);
}
#[tokio::test]
async fn test_only_local_changed_pushes_local() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let consensus_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
consensus body
"#,
);
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local changed body
"#,
);
ctx.consensus(&consensus_vi, Some(Seed::new(-100))).await;
let local = ctx.local(&local_vi, Some(Seed::new(100))).await;
ctx.remote(&consensus_vi, Some(Seed::new(-100)));
let out = ctx.open_issue(&local).run();
assert!(out.status.success(), "Should succeed when only local changed. stderr: {}", out.stderr);
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().redact_timestamps(&[10]), &out), @r#"
//- /o/r/.meta.json
{
"virtual_project": false,
"next_virtual_issue_number": 0,
"issues": {
"1": {
"user": "mock_user",
"timestamps": {
"title": "2001-09-12T11:20:39Z",
[REDACTED - non-deterministic timestamp]
"labels": "2001-09-12T01:55:52Z",
"state": "2001-09-12T00:39:25Z",
"comments": []
}
}
}
}
//- /o/r/1_-_Test_Issue.md
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local changed body
"#);
}
#[tokio::test]
async fn test_reset_with_local_source_skips_sync() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let consensus_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
consensus body
"#,
);
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote changed body
"#,
);
ctx.consensus(&consensus_vi, Some(Seed::new(-30))).await;
let local = ctx.local(&local_vi, Some(Seed::new(20))).await;
ctx.remote(&remote_vi, Some(Seed::new(25)));
let out = ctx.open_issue(&local).args(&["--reset"]).run();
eprintln!("stdout: {}", out.stdout);
eprintln!("stderr: {}", out.stderr);
eprintln!("status: {:?}", out.status);
assert!(out.status.success(), "Should succeed with --reset. stderr: {}", out.stderr);
let issue_path = ctx.resolve_issue_path(&local);
let content = read_issue_file(&issue_path);
assert!(content.contains("local body"), "Local changes should be preserved with --reset");
}
#[tokio::test]
async fn test_url_open_creates_local_file_from_remote() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body content
"#,
);
ctx.remote(&remote_vi, Some(Seed::new(15)));
let expected_path = ctx.flat_issue_path(("o", "r").into(), 1, "Test Issue");
assert!(!expected_path.exists(), "Local file should not exist before open");
let out = ctx.open_url(("o", "r").into(), 1).run();
eprintln!("stdout: {}", out.stdout);
eprintln!("stderr: {}", out.stderr);
assert!(out.status.success(), "Should succeed creating from URL. stderr: {}", out.stderr);
assert!(expected_path.exists(), "Local file should be created");
let content = read_issue_file(&expected_path);
assert!(content.contains("remote body content"), "Should have remote content. Got: {content}");
}
#[tokio::test]
async fn test_reset_with_remote_url_nukes_local_state() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local body that should be nuked
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body wins
"#,
);
let local = ctx.consensus(&local_vi, Some(Seed::new(-40))).await;
ctx.remote(&remote_vi, Some(Seed::new(80)));
let out = ctx.open_url(("o", "r").into(), 1).args(&["--reset"]).run();
eprintln!("stdout: {}", out.stdout);
eprintln!("stderr: {}", out.stderr);
assert!(out.status.success(), "Should succeed with --reset via URL. stderr: {}", out.stderr);
let issue_path = ctx.resolve_issue_path(&local);
let content = read_issue_file(&issue_path);
assert!(content.contains("remote body wins"), "Local should be replaced with remote. Got: {content}");
assert!(!content.contains("local body that should be nuked"), "Local content should be gone");
}
#[tokio::test]
async fn test_reset_with_remote_url_skips_merge_on_divergence() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let consensus_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
consensus body
"#,
);
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local diverged body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote diverged body
"#,
);
ctx.consensus(&consensus_vi, Some(Seed::new(-60))).await;
let local = ctx.local(&local_vi, Some(Seed::new(30))).await;
ctx.remote(&remote_vi, Some(Seed::new(35)));
let out = ctx.open_url(("o", "r").into(), 1).args(&["--reset"]).run();
eprintln!("stdout: {}", out.stdout);
eprintln!("stderr: {}", out.stderr);
assert!(out.status.success(), "Should succeed without merge conflict. stderr: {}", out.stderr);
assert!(!out.stderr.contains("Conflict"), "Should not mention conflict with --reset");
assert!(!out.stdout.contains("Merging"), "Should not attempt merge with --reset");
let issue_path = ctx.resolve_issue_path(&local);
let content = read_issue_file(&issue_path);
assert!(content.contains("remote diverged body"), "Should have remote content. Got: {content}");
}
#[tokio::test]
async fn test_pull_fetches_before_editor() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body from github
"#,
);
let local = ctx.consensus(&local_vi, Some(Seed::new(-20))).await;
ctx.remote(&remote_vi, Some(Seed::new(70)));
let out = ctx.open_issue(&local).args(&["--pull"]).run();
assert!(out.stderr.contains("pre-open sync"), "Should show fetch/pull activity with --pull. stdout: {}", out.stderr);
assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().skip_meta(), &out), @"
//- /o/r/1_-_Test_Issue.md
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body from github
");
}
#[tokio::test]
async fn test_pull_with_divergence_runs_sync_before_editor() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let consensus_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
consensus body
"#,
);
let local_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local diverged body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote diverged body
"#,
);
ctx.consensus(&consensus_vi, Some(Seed::new(-100))).await;
let local = ctx.local(&local_vi, Some(Seed::new(100))).await;
ctx.remote(&remote_vi, Some(Seed::new(100)));
let out = ctx.open_issue(&local).args(&["--pull"]).run();
assert!(
out.stderr.contains("pre-open sync"), "Should attempt sync/merge with --pull before editor; stderr:\n{}",
out.stderr
);
assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap(), &out), @r#"
//- /o/__conflict.md
<<<<<<< HEAD
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local diverged body
||||||| [hash]
=======
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote diverged body
>>>>>>> remote-state
//- /o/r/.meta.json
{
"virtual_project": false,
"next_virtual_issue_number": 0,
"issues": {
"1": {
"user": "mock_user",
"timestamps": {
"title": "2001-09-12T11:20:39Z",
"description": "2001-09-12T10:04:12Z",
"labels": "2001-09-12T01:55:52Z",
"state": "2001-09-12T00:39:25Z",
"comments": []
}
}
}
}
//- /o/r/1_-_Test_Issue.md
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
local diverged body
"#)
}
#[tokio::test]
async fn test_closing_issue_syncs_state_change() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let open_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
body
"#,
);
let open_issue = ctx.consensus(&open_vi, Some(Seed::new(5))).await;
ctx.remote(&open_vi, Some(Seed::new(5)));
let mut closed_issue = open_vi.clone();
closed_issue.contents.state = tedi::CloseState::Closed;
let out = ctx.open_issue(&open_issue).edit(&closed_issue).run();
let result_str = render_fixture(FixtureRenderer::try_new(&ctx).unwrap().redact_timestamps(&[12]), &out);
insta::assert_snapshot!(result_str, @r#"
//- /o/r/.meta.json
{
"virtual_project": false,
"next_virtual_issue_number": 0,
"issues": {
"1": {
"user": "mock_user",
"timestamps": {
"title": "2001-09-11T09:15:20Z",
"description": "2001-09-11T04:30:34Z",
"labels": "2001-09-11T06:38:10Z",
[REDACTED - non-deterministic timestamp]
"comments": []
}
}
}
}
//- /o/r/1_-_Test_Issue.md.bak
- [x] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
body
"#);
}
#[tokio::test]
async fn test_duplicate_sub_issues_filtered_from_remote() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let parent_vi = parse_virtual(
r#"- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
- [x] Normal Closed Sub <!--sub @mock_user https://github.com/o/r/issues/2 -->
sub body
- [2] Duplicate Sub <!--sub @mock_user https://github.com/o/r/issues/3 -->
duplicate body
"#,
);
ctx.remote(&parent_vi, Some(Seed::new(-10)));
let out = ctx.open_url(("o", "r").into(), 1).run();
eprintln!("stdout: {}", out.stdout);
eprintln!("stderr: {}", out.stderr);
assert!(out.status.success(), "Should succeed. stderr: {}", out.stderr);
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().skip_meta(), &out), @"
//- /o/r/1_-_Parent_Issue/2_-_Normal_Closed_Sub.md.bak
- [x] Normal Closed Sub <!-- @mock_user https://github.com/o/r/issues/2 -->
sub body
//- /o/r/1_-_Parent_Issue/__main__.md
- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
");
}
#[tokio::test]
async fn test_open_unchanged_succeeds() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
"#,
);
let issue = ctx.remote(&vi, Some(Seed::new(10)));
let out = ctx.open_url(("o", "r").into(), 1).run();
assert!(out.status.success(), "First open should succeed. stderr: {}", out.stderr);
let out = ctx.open_issue(&issue).run();
assert!(out.status.success(), "Second open (unchanged) should succeed. stderr: {}", out.stderr);
}
#[tokio::test]
async fn test_open_by_number_unchanged_succeeds() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
"#,
);
ctx.remote(&vi, None);
let out = ctx.open_url(("o", "r").into(), 1).args(&["--reset"]).run();
eprintln!("First open stdout: {}", out.stdout);
eprintln!("First open stderr: {}", out.stderr);
assert!(out.status.success(), "First open should succeed. stderr: {}", out.stderr);
let out = ctx.open_url(("o", "r").into(), 1).run();
eprintln!("Second open stdout: {}", out.stdout);
eprintln!("Second open stderr: {}", out.stderr);
assert!(out.status.success(), "Second open (unchanged) should succeed. stderr: {}", out.stderr);
}
#[tokio::test]
async fn test_reset_syncs_changes_after_editor() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body
"#,
);
ctx.remote(&remote_vi, None);
let mut modified_issue = remote_vi.clone();
modified_issue.contents.state = tedi::CloseState::Closed;
let out = ctx.open_url(("o", "r").into(), 1).args(&["--reset"]).edit(&modified_issue).run();
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().redact_timestamps(&[12]), &out), @r#"
//- /o/r/.meta.json
{
"virtual_project": false,
"next_virtual_issue_number": 0,
"issues": {
"1": {
"user": "mock_user",
"timestamps": {
"title": null,
"description": null,
"labels": null,
[REDACTED - non-deterministic timestamp]
"comments": []
}
}
}
}
//- /o/r/1_-_Test_Issue.md.bak
- [x] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body
"#);
}
#[tokio::test]
async fn test_comment_shorthand_creates_comment() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
"#,
);
let issue = ctx.consensus(&vi, None).await;
ctx.remote(&vi, None);
let edited_content = r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
!c
My new comment content
"#;
let issue_path = ctx.resolve_issue_path(&issue);
write_to_path(&issue_path, edited_content);
let out = ctx.open_issue(&issue).run();
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().skip_meta(), &out), @"
//- /o/__conflict.md
<<<<<<< HEAD
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
<!-- new comment -->
My new comment content
||||||| [hash]
=======
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
>>>>>>> remote-state
//- /o/r/1_-_Test_Issue.md
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
!c
My new comment content
");
}
#[rstest]
#[case::prefer_local(&["--force"], true)]
#[case::prefer_remote(&["--pull", "--force"], false)]
#[tokio::test]
async fn test_force_merge_preserves_both_sub_issues(#[case] args: &[&str], #[case] expect_local_description: bool) {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let local_vi = parse_virtual(
r#"- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
extra line from local
- [ ] Local Sub <!--sub @mock_user https://github.com/o/r/issues/2 -->
local sub body
"#,
);
let remote_vi = parse_virtual(
r#"- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
- [ ] Remote Sub <!--sub @mock_user https://github.com/o/r/issues/3 -->
remote sub body
"#,
);
let consensus_vi = parse_virtual(
r#"- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
"#,
);
ctx.consensus(&consensus_vi, Some(Seed::new(-100))).await;
let local = ctx.local(&local_vi, Some(Seed::new(100))).await;
ctx.remote(&remote_vi, Some(Seed::new(100)));
let out = ctx.open_issue(&local).args(args).run();
if expect_local_description {
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().skip_meta(), &out), @"
//- /o/r/1_-_Parent_Issue/2_-_Local_Sub.md
- [ ] Local Sub <!-- @mock_user https://github.com/o/r/issues/2 -->
local sub body
//- /o/r/1_-_Parent_Issue/3_-_Remote_Sub.md
- [ ] Remote Sub <!-- @mock_user https://github.com/o/r/issues/3 -->
remote sub body
//- /o/r/1_-_Parent_Issue/__main__.md
- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
extra line from local
");
} else {
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().skip_meta(), &out), @"
//- /o/r/1_-_Parent_Issue/2_-_Local_Sub.md
- [ ] Local Sub <!-- @mock_user https://github.com/o/r/issues/2 -->
local sub body
//- /o/r/1_-_Parent_Issue/3_-_Remote_Sub.md
- [ ] Remote Sub <!-- @mock_user https://github.com/o/r/issues/3 -->
remote sub body
//- /o/r/1_-_Parent_Issue/__main__.md
- [ ] Parent Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
parent body
");
}
}
#[tokio::test]
async fn test_undo_shorthand_aborts_sync() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
"#,
);
let issue = ctx.consensus(&vi, None).await;
ctx.remote(&vi, None);
let (vpath, paused) = ctx.open_issue(&issue).args(&["--offline"]).break_to_edit();
let content = std::fs::read_to_string(&vpath).unwrap();
std::fs::write(&vpath, format!("{content} some random edits\n another line of changes\n!u\n")).unwrap();
let out = paused.resume();
assert!(out.status.success(), "Should succeed. stderr: {}", out.stderr);
assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().skip_meta(), &out), @"
//- /o/r/1_-_Test_Issue.md
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
issue body
");
}
#[tokio::test]
async fn test_consensus_sink_writes_meta_json_with_timestamps() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let remote_vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body
---
<!-- comment 1001 @commenter -->
A test comment
"#,
);
ctx.remote(&remote_vi, None);
let out = ctx.open_url(("o", "r").into(), 1).run();
eprintln!("stdout: {}", out.stdout);
eprintln!("stderr: {}", out.stderr);
assert!(out.status.success(), "Fetch should succeed. stderr: {}", out.stderr);
insta::assert_snapshot!(render_fixture(FixtureRenderer::try_new(&ctx).unwrap().redact_timestamps(&[10]), &out), @r#"
//- /o/r/.meta.json
{
"virtual_project": false,
"next_virtual_issue_number": 0,
"issues": {
"1": {
"user": "mock_user",
"timestamps": {
"title": null,
[REDACTED - non-deterministic timestamp]
"labels": null,
"state": null,
"comments": []
}
}
}
}
//- /o/r/1_-_Test_Issue.md
- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
remote body
\---<!-- comment 1001 @commenter -->
A test comment
"#);
}
#[tokio::test]
async fn test_adding_labels_syncs_to_remote() {
let ctx = TestContext::build_with_preexisting_state_unsafe("");
let vi = parse_virtual(
r#"- [ ] Test Issue <!-- @mock_user https://github.com/o/r/issues/1 -->
body
"#,
);
let issue = ctx.consensus(&vi, Some(Seed::new(5))).await;
ctx.remote(&vi, Some(Seed::new(5)));
let mut labeled_vi = vi.clone();
labeled_vi.contents.labels = vec!["bug".to_string(), "urgent".to_string()];
let out = ctx.open_issue(&issue).edit(&labeled_vi).run();
assert!(out.status.success(), "Should succeed. stderr: {}", out.stderr);
assert!(out.stdout.contains("Updating issue #1 labels"), "Should push labels to remote. stdout: {}", out.stdout);
let issue_path = ctx.resolve_issue_path(&issue);
let content = read_issue_file(&issue_path);
assert!(content.contains("(bug, urgent)"), "Labels should be in file. Got: {content}");
}