diff --git a/.config/nextest.toml b/.config/nextest.toml index 4481ebc6ba..1f9f3d156f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -32,6 +32,10 @@ max-threads = 1 [[profile.postgres-ci.overrides]] filter = "test(/cluster_global_/)" test-group = "postgres-cluster-global" +# The group only serializes these tests with each other. They read unfiltered, +# cluster-wide `pg_stat_activity`, so any overlapping test's session can mask +# activity; claim every worker so they run alone. +threads-required = "num-test-threads" [[profile.postgres-ci.scripts]] # Script filters are separate from default-filter: they attach the setup and diff --git a/crates/buzz-db/src/store/usage.rs b/crates/buzz-db/src/store/usage.rs index f8ad1facbf..ea626c4ad6 100644 --- a/crates/buzz-db/src/store/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -928,14 +928,24 @@ mod postgres_tests { ); drop(leader); - assert!( - second + // Dropping closes the socket; the server releases the session lock only + // once its backend observes the disconnect, so poll briefly. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + let successor = loop { + let attempt = second .try_lock_usage_metrics(key) .await - .expect("lock attempt after leader drop") - .is_some(), + .expect("lock attempt after leader drop"); + if attempt.is_some() || tokio::time::Instant::now() >= deadline { + break attempt; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + assert!( + successor.is_some(), "dropping the detached session releases its advisory lock" ); + drop(successor); // Release any remaining session state before DROP DATABASE. drop(first); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 559f668c95..a50ae14dd7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -612,21 +612,36 @@ fn update_time_override_preserves_pin_for_persona_less_agent() { mod effort_clear; mod forced_discovery; mod managed_path_resolution; + +/// Write an executable script without opening it for writing in this process. +/// +/// Linux refuses to exec a file while any process holds it open for writing +/// (`ETXTBSY`). A sibling test that spawns between our open and close copies +/// that descriptor into its child until the child execs, so an in-process +/// `fs::write` can make the probe's spawn fail intermittently. A short-lived +/// shell writes the file instead, and its descriptor is gone once it exits. +#[cfg(unix)] +fn write_executable_script(path: &std::path::Path, contents: impl AsRef) { + let status = std::process::Command::new("/bin/sh") + .args(["-c", r#"printf '%s' "$2" > "$1" && chmod 755 "$1""#, "sh"]) + .arg(path) + .arg(contents.as_ref()) + .status() + .expect("spawn script writer"); + assert!(status.success(), "write script {}", path.display()); +} + #[cfg(unix)] #[test] fn probe_codex_acp_version_parses_full_semver_output() { - use std::os::unix::fs::PermissionsExt; - // Simulate a current `@agentclientprotocol/codex-acp` output. let dir = std::env::temp_dir().join(format!("buzz-probe-1x-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); - std::fs::write( + write_executable_script( &bin, "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + ); let version = probe_codex_acp_version(&bin); let _ = std::fs::remove_dir_all(dir); @@ -643,14 +658,11 @@ mod codex_version; #[cfg(unix)] #[test] fn probe_codex_acp_version_returns_none_for_nonzero_exit() { - use std::os::unix::fs::PermissionsExt; - // Simulate old 0.16.x adapter: `--version` is unrecognised, exits non-zero let dir = std::env::temp_dir().join(format!("buzz-probe-0x-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); - std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + write_executable_script(&bin, "#!/bin/sh\nexit 1\n"); let version = probe_codex_acp_version(&bin); let _ = std::fs::remove_dir_all(dir); @@ -677,17 +689,13 @@ fn probe_codex_acp_version_returns_none_for_missing_binary() { #[cfg(unix)] #[test] fn codex_adapter_availability_available_for_minimum_supported_binary() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("buzz-avail-1x-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); - std::fs::write( + write_executable_script( &bin, "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.10.0'\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + ); let status = codex_adapter_availability(&bin); let _ = std::fs::remove_dir_all(dir); @@ -702,14 +710,11 @@ fn codex_adapter_availability_available_for_minimum_supported_binary() { #[cfg(unix)] #[test] fn codex_adapter_availability_outdated_for_0x_binary() { - use std::os::unix::fs::PermissionsExt; - // Simulate old 0.16.x: `--version` exits non-zero (unrecognised flag) let dir = std::env::temp_dir().join(format!("buzz-avail-0x-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); - std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + write_executable_script(&bin, "#!/bin/sh\nexit 1\n"); let status = codex_adapter_availability(&bin); let _ = std::fs::remove_dir_all(dir); @@ -726,18 +731,13 @@ fn codex_adapter_availability_outdated_for_0x_binary() { #[cfg(unix)] #[test] fn codex_adapter_availability_outdated_for_uncomparable_version() { - use std::os::unix::fs::PermissionsExt; - for version in ["1.2", "1.2.0-rc1"] { let dir = tempfile::tempdir().expect("temp dir"); let bin = dir.path().join("codex-acp"); - std::fs::write( + write_executable_script( &bin, format!("#!/bin/sh\necho '@agentclientprotocol/codex-acp {version}'\nexit 0\n"), - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod script"); + ); assert_eq!( codex_adapter_availability(&bin), @@ -766,7 +766,6 @@ fn codex_adapter_availability_outdated_for_missing_binary() { #[cfg(unix)] #[test] fn probe_codex_acp_version_returns_none_for_hung_direct_child() { - use std::os::unix::fs::PermissionsExt; use std::time::Instant; // Simulate a process that writes version to stdout then blocks forever. @@ -775,12 +774,10 @@ fn probe_codex_acp_version_returns_none_for_hung_direct_child() { let dir = std::env::temp_dir().join(format!("buzz-probe-hung-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); - std::fs::write( + write_executable_script( &bin, "#!/bin/sh\nprintf '@agentclientprotocol/codex-acp 1.1.2\\n'\nexec sleep 300\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + ); let start = Instant::now(); let version = probe_codex_acp_version(&bin); @@ -801,7 +798,6 @@ fn probe_codex_acp_version_returns_none_for_hung_direct_child() { #[cfg(unix)] #[test] fn probe_codex_acp_version_returns_version_when_descendant_holds_pipe_open() { - use std::os::unix::fs::PermissionsExt; use std::time::Instant; // Simulate a process that forks a background child which inherits stdout @@ -817,12 +813,10 @@ fn probe_codex_acp_version_returns_version_when_descendant_holds_pipe_open() { let dir = std::env::temp_dir().join(format!("buzz-probe-descendant-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); - std::fs::write( + write_executable_script( &bin, "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nsleep 60 &\nexit 0\n", - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + ); let start = Instant::now(); let version = probe_codex_acp_version(&bin); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs index 19053865cb..f7e335b517 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs @@ -1,10 +1,11 @@ use super::super::probe_codex_acp_version_with_path; +#[cfg(unix)] +use super::write_executable_script; #[cfg(unix)] #[test] fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { use std::fs; - use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let script_dir = temp.path().join("script-bin"); let interpreter_dir = temp.path().join("interpreter-bin"); @@ -14,17 +15,13 @@ fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { fs::create_dir_all(&empty_path_dir).expect("empty path dir"); let interpreter_path = interpreter_dir.join("node"); - fs::write( + write_executable_script( &interpreter_path, "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n", - ) - .expect("write interpreter"); - fs::set_permissions(&interpreter_path, fs::Permissions::from_mode(0o755)) - .expect("chmod interpreter"); + ); let shim_path = script_dir.join("codex-acp"); - fs::write(&shim_path, "#!/usr/bin/env node\n").expect("write shim"); - fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755)).expect("chmod shim"); + write_executable_script(&shim_path, "#!/usr/bin/env node\n"); let scrubbed_path = std::env::join_paths([empty_path_dir.as_path()]) .expect("join scrubbed PATH") @@ -52,18 +49,14 @@ fn probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter() { fn codex_adapter_availability_outdated_for_older_1x_binary() { use super::super::{codex_adapter_availability, codex_adapter_is_outdated}; use crate::managed_agents::AcpAvailabilityStatus; - use std::os::unix::fs::PermissionsExt; for version in ["1.1.5", "1.1.7", "1.6.2", "1.9.0"] { let dir = tempfile::tempdir().expect("temp dir"); let bin = dir.path().join("codex-acp"); - std::fs::write( + write_executable_script( &bin, format!("#!/bin/sh\necho '@agentclientprotocol/codex-acp {version}'\nexit 0\n"), - ) - .expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod script"); + ); assert_eq!( codex_adapter_availability(&bin), diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index 3c4fec6804..b492b40ee2 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -2,11 +2,14 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; -// The mock identity's own pre-seeded message in #general (authored by -// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own +// A message the mock identity authors in #general. Editing/deleting one's own // message is exactly Sam's workflow: "delete a message by clearing its edit." -const OWN_MESSAGE_ID = "mock-general-welcome"; -const RENDERED_ORIGINAL_CONTENT = "Welcome to general"; +// It is emitted per test rather than reusing the seeded `mock-general-welcome`: +// edits and deletions only overlay a target with a real 64-hex event id, so +// that seed keeps its edit only until the post-subscribe window refetch. +const ORIGINAL_CONTENT = "Ship notes for #general"; +const RENDERED_ORIGINAL_CONTENT = "Ship notes for general"; +let OWN_MESSAGE_ID: string; // Open the more-actions menu for a message row and wait for the menu to mount. async function openMoreActionsMenu( @@ -46,6 +49,40 @@ test.beforeEach(async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind: number; + exactChannel: boolean; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 9, + exactChannel: true, + }) ?? false, + ), + ) + .toBe(true); + const emitted = await page.evaluate( + (content) => + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + }) => { id: string } | undefined; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content }), + ORIGINAL_CONTENT, + ); + if (!emitted?.id) throw new Error("mock message emit did not return an id"); + OWN_MESSAGE_ID = emitted.id; }); test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({ diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index e6c58bed8d..622392989e 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -850,6 +850,10 @@ test("assigns distinct agent voices and exposes compact per-agent controls", asy }); expect(new Set(assignedVoices).size).toBe(2); + // Let the huddle chat finish mounting and autofocus before opening an + // overlay; a late composer focus would dismiss the popover. + await expect(page.getByTestId("message-input")).toBeFocused(); + await page.getByRole("button", { name: "Voice settings for alice" }).click(); await waitForAnimations(page); const voiceMenu = page.locator( diff --git a/desktop/tests/e2e/voice-note.spec.ts b/desktop/tests/e2e/voice-note.spec.ts index eadab504f5..c9e6ee1775 100644 --- a/desktop/tests/e2e/voice-note.spec.ts +++ b/desktop/tests/e2e/voice-note.spec.ts @@ -943,6 +943,11 @@ test("starting a duplicate voice-note player pauses the other instance", async ( const cards = page.getByTestId("audio-message-attachment"); await expect(cards).toHaveCount(2); + // The ping fixture lasts about one second, so it can finish before the + // playing state is observed. Loop it so only exclusivity pauses a player. + await cards.locator("audio").evaluateAll((audios) => { + for (const audio of audios) (audio as HTMLAudioElement).loop = true; + }); const firstAudio = cards.nth(0).locator("audio"); const secondAudio = cards.nth(1).locator("audio"); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index baf7e29a9c..d954858939 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -96,7 +96,13 @@ async function createWorkflow( await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + // The inspector swaps from the trigger to the new step after the click; an + // unscoped fill can land in the trigger's message-text condition instead. + const stepInspector = dialog.getByTestId("workflow-node-inspector"); + await expect( + stepInspector.getByRole("button", { name: "Action", exact: true }), + ).toBeVisible(); + await stepInspector.getByLabel("Message text").fill("Workflow notification"); if (options?.stepName) { await dialog.getByRole("button", { name: "Step details" }).click(); await dialog.getByLabel("Name (optional)").fill(options.stepName);