#[cfg(windows)]
use github_copilot_sdk::CliProgram;
use github_copilot_sdk::SessionLifecycleEventType;
use serde_json::json;
use super::support::{wait_for_lifecycle_event, with_e2e_context};
#[tokio::test]
async fn should_receive_session_created_lifecycle_event() {
with_e2e_context(
"client_lifecycle",
"should_receive_session_created_lifecycle_event",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let client = ctx.start_client().await;
let created = client.subscribe_lifecycle();
let session = client
.create_session(ctx.approve_all_session_config())
.await
.expect("create session");
let event =
wait_for_lifecycle_event(created, "session.created lifecycle event", |event| {
event.event_type == SessionLifecycleEventType::Created
})
.await;
assert_eq!(event.event_type, SessionLifecycleEventType::Created);
assert_eq!(&event.session_id, session.id());
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
})
},
)
.await;
}
#[tokio::test]
async fn should_filter_session_lifecycle_events_by_type() {
with_e2e_context(
"client_lifecycle",
"should_filter_session_lifecycle_events_by_type",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let client = ctx.start_client().await;
let created = client.subscribe_lifecycle();
let session = client
.create_session(ctx.approve_all_session_config())
.await
.expect("create session");
let event = wait_for_lifecycle_event(
created,
"filtered session.created lifecycle event",
|event| event.event_type == SessionLifecycleEventType::Created,
)
.await;
assert_eq!(&event.session_id, session.id());
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
})
},
)
.await;
}
#[tokio::test]
async fn disposing_lifecycle_subscription_stops_receiving_events() {
with_e2e_context(
"client_lifecycle",
"disposing_lifecycle_subscription_stops_receiving_events",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let client = ctx.start_client().await;
drop(client.subscribe_lifecycle());
let created = client.subscribe_lifecycle();
let session = client
.create_session(ctx.approve_all_session_config())
.await
.expect("create session");
let event = wait_for_lifecycle_event(
created,
"active session.created lifecycle event",
|event| event.event_type == SessionLifecycleEventType::Created,
)
.await;
assert_eq!(event.session_id, *session.id());
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
})
},
)
.await;
}
#[tokio::test]
async fn dispose_disconnects_client_and_disposes_rpc_surface_async() {
with_e2e_context(
"client_lifecycle",
"dispose_disconnects_client_and_disposes_rpc_surface_async_true",
|ctx| {
Box::pin(async move {
let client = ctx.start_client().await;
client.stop().await.expect("stop client");
assert!(
client.call("rpc.ping", Some(json!({}))).await.is_err(),
"stopped client should reject RPC calls"
);
})
},
)
.await;
}
#[tokio::test]
async fn dispose_disconnects_client_and_disposes_rpc_surface_drop() {
with_e2e_context(
"client_lifecycle",
"dispose_disconnects_client_and_disposes_rpc_surface_async_false",
|ctx| {
Box::pin(async move {
let client = ctx.start_client().await;
client.force_stop();
assert!(
client.call("rpc.ping", Some(json!({}))).await.is_err(),
"force-stopped client should reject RPC calls"
);
})
},
)
.await;
}
#[cfg(windows)]
#[tokio::test]
async fn abrupt_host_termination_still_kills_cli_via_job_object() {
with_e2e_context(
"client_lifecycle",
"abrupt_host_termination_still_kills_cli_via_job_object",
|ctx| {
Box::pin(async move {
let options = ctx.client_options();
let program = match &options.program {
CliProgram::Path(path) => path
.to_str()
.expect("CLI program path is valid UTF-8")
.to_owned(),
CliProgram::Resolve => {
panic!("E2E client options should resolve to an explicit CLI path")
}
};
let prefix_args: Vec<String> = options
.prefix_args
.iter()
.map(|arg| arg.to_str().expect("prefix arg is valid UTF-8").to_owned())
.collect();
let env_pairs: Vec<(String, String)> = options
.env
.iter()
.map(|(k, v)| {
(
k.to_str().expect("env key is valid UTF-8").to_owned(),
v.to_str().expect("env value is valid UTF-8").to_owned(),
)
})
.collect();
let cwd = options
.working_directory
.to_str()
.expect("cwd is valid UTF-8")
.to_owned();
let pid_file = ctx.work_dir().join("host-crash-fixture-cli.pid");
let mut host =
std::process::Command::new(env!("CARGO_BIN_EXE_copilot-host-crash-fixture"))
.env("HOST_CRASH_FIXTURE_PROGRAM", &program)
.env(
"HOST_CRASH_FIXTURE_PREFIX_ARGS_JSON",
serde_json::to_string(&prefix_args).expect("serialize prefix args"),
)
.env("HOST_CRASH_FIXTURE_CWD", &cwd)
.env(
"HOST_CRASH_FIXTURE_ENV_JSON",
serde_json::to_string(&env_pairs).expect("serialize env pairs"),
)
.env("HOST_CRASH_FIXTURE_PID_FILE", &pid_file)
.spawn()
.expect("spawn host-crash fixture process");
let cli_pid = wait_for_pid_file_windows(&pid_file).await;
assert!(
process_alive_windows(cli_pid),
"CLI should be alive before its host process is terminated"
);
host.kill().expect("terminate host-crash fixture process");
host.wait().expect("reap host-crash fixture process");
let cli_exited = wait_for_process_exit_windows(cli_pid).await;
if !cli_exited {
kill_process_windows(cli_pid);
}
assert!(
cli_exited,
"CLI survived its abruptly terminated host process; Job Object \
kill-on-close did not terminate it"
);
})
},
)
.await;
}
#[cfg(windows)]
async fn wait_for_pid_file_windows(path: &std::path::Path) -> u32 {
super::support::wait_for_condition("host-crash fixture CLI pid file", || async {
path.exists()
})
.await;
std::fs::read_to_string(path)
.expect("read host-crash fixture CLI pid")
.trim()
.parse()
.expect("parse host-crash fixture CLI pid")
}
#[cfg(windows)]
async fn wait_for_process_exit_windows(pid: u32) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while process_alive_windows(pid) {
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
true
}
#[cfg(windows)]
fn process_alive_windows(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
unsafe {
let process = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if process.is_null() {
return false;
}
let alive = WaitForSingleObject(process, 0) == WAIT_TIMEOUT;
CloseHandle(process);
alive
}
}
#[cfg(windows)]
fn kill_process_windows(pid: u32) {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};
unsafe {
let process = OpenProcess(PROCESS_TERMINATE, 0, pid);
if !process.is_null() {
TerminateProcess(process, 1);
CloseHandle(process);
}
}
}
#[tokio::test]
async fn should_receive_session_updated_lifecycle_event_for_non_ephemeral_activity() {
with_e2e_context(
"client_lifecycle",
"should_receive_session_updated_lifecycle_event_for_non_ephemeral_activity",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let client = ctx.start_client().await;
let session = client
.create_session(ctx.approve_all_session_config())
.await
.expect("create session");
let updated = client.subscribe_lifecycle();
session
.client()
.call(
"session.mode.set",
Some(json!({
"sessionId": session.id().as_str(),
"mode": "plan",
})),
)
.await
.expect("set session mode");
let event =
wait_for_lifecycle_event(updated, "session.updated lifecycle event", |event| {
event.event_type == SessionLifecycleEventType::Updated
&& event.session_id == *session.id()
})
.await;
assert_eq!(event.event_type, SessionLifecycleEventType::Updated);
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
})
},
)
.await;
}
#[tokio::test]
async fn should_receive_session_deleted_lifecycle_event_when_deleted() {
with_e2e_context(
"client_lifecycle",
"should_receive_session_deleted_lifecycle_event_when_deleted",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let client = ctx.start_client().await;
let session = client
.create_session(ctx.approve_all_session_config())
.await
.expect("create session");
let session_id = session.id().clone();
session
.send_and_wait("Say SESSION_DELETED_OK exactly.")
.await
.expect("send");
let deleted = client.subscribe_lifecycle();
client
.delete_session(&session_id)
.await
.expect("delete session");
let event =
wait_for_lifecycle_event(deleted, "session.deleted lifecycle event", |event| {
event.event_type == SessionLifecycleEventType::Deleted
&& event.session_id == session_id
})
.await;
assert_eq!(event.event_type, SessionLifecycleEventType::Deleted);
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
})
},
)
.await;
}