diff --git a/.github/workflows/instant-reliability.yml b/.github/workflows/instant-reliability.yml index a0655c46e4..af99f5174a 100644 --- a/.github/workflows/instant-reliability.yml +++ b/.github/workflows/instant-reliability.yml @@ -6,7 +6,8 @@ name: Instant reliability (real app) # followed by Recover. Each case checks what reached the mock server and what # was left on disk, so a regression that loses a recording, deletes one with # media, or leaves an empty video behind fails here. The Linux job runs the -# same cases on Xvfb, recording another app's window. +# same cases on Xvfb, recording another app's window, and a hosted job records +# Instant with a virtual camera drawn into the video. on: pull_request: @@ -20,9 +21,14 @@ on: - "apps/desktop-gpui/src/main_window.rs" - "apps/desktop-gpui/src/auth.rs" - "apps/desktop-gpui/src/store.rs" + - "apps/desktop-gpui/src/feeds.rs" + - "apps/desktop-gpui/src/camera_window.rs" - "crates/recording/src/upload_resume.rs" - "crates/recording/src/upload_verification.rs" - "crates/recording/src/instant_recording.rs" + - "crates/recording/src/instant_recording/**" + - "crates/camera/**" + - "crates/camera-ffmpeg/**" - "crates/recording/src/recovery.rs" - "crates/enc-ffmpeg/src/mux/segmented_stream.rs" - "crates/enc-ffmpeg/src/mux/dash_audio.rs" @@ -63,7 +69,7 @@ jobs: echo "run=true" >> "$GITHUB_OUTPUT"; exit 0 fi gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename' > "$RUNNER_TEMP/changed.txt" - instant='^(apps/desktop-gpui/src/(upload\.rs|upload/.*|recording\.rs|session\.rs|library\.rs|main_window\.rs|auth\.rs|store\.rs)|crates/recording/src/(upload_resume|upload_verification|instant_recording|recovery)\.rs|crates/enc-ffmpeg/src/mux/(segmented_stream|dash_audio)\.rs)$' + instant='^(apps/desktop-gpui/src/(upload\.rs|upload/.*|recording\.rs|session\.rs|library\.rs|main_window\.rs|auth\.rs|store\.rs|feeds\.rs|camera_window\.rs)|crates/recording/src/(upload_resume|upload_verification|instant_recording|recovery)\.rs|crates/recording/src/instant_recording/.*|crates/camera(-ffmpeg)?/.*|crates/enc-ffmpeg/src/mux/(segmented_stream|dash_audio)\.rs)$' if grep -qE "$instant" "$RUNNER_TEMP/changed.txt"; then echo "run=true" >> "$GITHUB_OUTPUT" else @@ -236,6 +242,20 @@ jobs: print(m["executable"])' > "$RUNNER_TEMP/test-binary" test -s "$RUNNER_TEMP/test-binary" + - name: Package the build for the camera job + run: | + mkdir -p "$RUNNER_TEMP/build/libs" + cp apps/desktop-gpui/target/debug/cap-gpui "$RUNNER_TEMP/build/cap-gpui" + cp "$(cat "$RUNNER_TEMP/test-binary")" "$RUNNER_TEMP/build/real-app-tests" + strip --strip-debug "$RUNNER_TEMP/build/cap-gpui" "$RUNNER_TEMP/build/real-app-tests" + cp -a target/native-deps/lib/. "$RUNNER_TEMP/build/libs/" + + - uses: actions/upload-artifact@v4 + with: + name: instant-reliability-linux-build + path: ${{ runner.temp }}/build + retention-days: 1 + # X11 captures without a portal. Display and area recordings wait for # the Stop shortcut to start, so the cases record another app's window. # The runner has no audio device: a PulseAudio null sink plays the cue. @@ -271,3 +291,82 @@ jobs: name: instant-reliability-sandboxes-linux path: ${{ runner.temp }}/real-app retention-days: 7 + + # Linux draws the camera into the Instant screen video. The virtual camera, + # v4l2loopback, builds against the hosted runner's kernel; Blacksmith's + # kernel cannot load it, so this job runs the build from the job above. + real-app-linux-camera: + name: Instant with a camera on Linux (real app) + needs: real-app-linux + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: instant-reliability-linux-build + path: ${{ runner.temp }}/build + + - name: Install Linux desktop dependencies + uses: ./.github/actions/install-desktop-deps + + - name: Instant with a camera on Xvfb + run: | + set -euo pipefail + sudo apt-get update -qq + # v4l2loopback needs videodev, which is in the extra kernel modules. + sudo apt-get install -y -qq "linux-modules-extra-$(uname -r)" v4l2loopback-dkms xvfb x11-utils xdotool \ + dbus ffmpeg pulseaudio pulseaudio-utils libasound2-plugins >/dev/null + chmod +x "$RUNNER_TEMP/build/cap-gpui" "$RUNNER_TEMP/build/real-app-tests" + sudo modprobe v4l2loopback video_nr=42 card_label="Cap Test Camera" exclusive_caps=1 + sudo udevadm settle --timeout=10 + sudo chmod 666 /dev/video42 + ffmpeg -hide_banner -loglevel error -nostdin -re -f lavfi -i testsrc2=size=1280x720:rate=30 \ + -pix_fmt yuyv422 -f v4l2 /dev/video42 >"$RUNNER_TEMP/feeder.log" 2>&1 & + pulse_dir=$(mktemp -d) + pulseaudio -n --daemonize=yes --exit-idle-time=-1 \ + -L "module-native-protocol-unix socket=$pulse_dir/native auth-anonymous=1" \ + -L "module-null-sink sink_name=cap_output" -L "module-always-sink" + export PULSE_SERVER="unix:$pulse_dir/native" + for _ in $(seq 40); do pactl info >/dev/null 2>&1 && break; sleep 0.25; done + pactl set-default-sink cap_output + Xvfb :78 -screen 0 1920x1080x24 >/dev/null 2>&1 & + sleep 1 + # The recorded window covers the screen, so the camera preview is + # inside the capture wherever it opens; Instant refuses one outside. + DISPLAY=:78 xmessage -geometry 1920x1080+0+0 "A window to record" >/dev/null 2>&1 & + env -u WAYLAND_DISPLAY DISPLAY=:78 XDG_SESSION_TYPE=x11 \ + LD_LIBRARY_PATH="$RUNNER_TEMP/build/libs" \ + CAP_GPUI_REAL_APP_BIN="$RUNNER_TEMP/build/cap-gpui" \ + CAP_GPUI_REAL_APP_ARTIFACTS="$RUNNER_TEMP/real-app" CAP_GPUI_REAL_APP_CAMERA=1 \ + CAP_GPUI_AUTO_OVERLAY=window CAP_GPUI_AUTO_WINDOW=xmessage \ + dbus-run-session -- "$RUNNER_TEMP/build/real-app-tests" --ignored --test-threads=1 --nocapture \ + real_app_instant_recording_with_camera_uploads_and_verifies + + # The recorded window is grey and the test pattern is saturated colour, + # so a frame without colour is a frame without the camera. + - name: Camera picture reached the video + run: | + set -euo pipefail + display=$(find "$RUNNER_TEMP/real-app" -type d -path "*content/display" | head -1) + test -n "$display" + ls -la "$display" + joined=$RUNNER_TEMP/joined.mp4 + cat "$display"/init.mp4 "$display"/segment_*.m4s > "$joined" + ffprobe -v error -show_entries format=duration -of csv=p=0 "$joined" + ffmpeg -hide_banner -nostdin -i "$joined" -vf signalstats,metadata=print:key=lavfi.signalstats.SATMAX \ + -f null - 2>&1 | grep -o 'SATMAX=[0-9.]*' > "$RUNNER_TEMP/saturation.txt" + awk -F= '{ n++; if ($2 + 0 > 40) c++ } END { printf "%d of %d frames show the camera\n", c, n; exit !(n >= 60 && c >= n * 0.95) }' \ + "$RUNNER_TEMP/saturation.txt" + + - name: Upload sandboxes + if: failure() + uses: actions/upload-artifact@v4 + with: + name: instant-reliability-sandboxes-linux-camera + path: | + ${{ runner.temp }}/real-app + ${{ runner.temp }}/feeder.log + retention-days: 7 diff --git a/apps/desktop-gpui/src/upload/real_app_tests.rs b/apps/desktop-gpui/src/upload/real_app_tests.rs index da505c78d5..b39699d696 100644 --- a/apps/desktop-gpui/src/upload/real_app_tests.rs +++ b/apps/desktop-gpui/src/upload/real_app_tests.rs @@ -310,6 +310,94 @@ async fn real_app_instant_recording_uploads_and_verifies() { assert!(sandbox.journal().is_empty(), "{:?}", sandbox.journal()); } +/// An Instant recording with the camera on uploads and verifies. On Linux the +/// camera is drawn into the screen video, so a camera that stalls or sends a +/// bad frame stops the recording. Runs only with `CAP_GPUI_REAL_APP_CAMERA=1`, +/// since it needs a camera attached; the CI job feeds a virtual one and checks +/// its picture reached the video. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +async fn real_app_instant_recording_with_camera_uploads_and_verifies() { + if std::env::var("CAP_GPUI_REAL_APP_CAMERA").is_err() { + eprintln!("skipped: set CAP_GPUI_REAL_APP_CAMERA=1 where a camera is attached"); + return; + } + let mock = MockCap::start(Vec::new()).await; + let sandbox = Sandbox::new(&mock.url, 0); + let app = sandbox.launch( + "camera", + &[ + ("CAP_GPUI_AUTO_CAMERA", "1"), + ("CAP_GPUI_AUTO_RECORD", "instant:60"), + ], + ); + // On X11 a window recorded with the camera hides Cap's controls, and the + // recording waits for the Stop shortcut to start; the same shortcut stops + // it, well before the automatic stop. + #[cfg(target_os = "linux")] + { + let press = || { + let status = Command::new("xdotool") + .args(["key", "ctrl+shift+F9"]) + .status() + .expect("install xdotool"); + assert!(status.success()); + }; + let sandbox = &sandbox; + let logged = |line: &'static str| move || sandbox.log("camera").contains(line); + let start = Duration::from_secs(60); + wait_for_app( + sandbox, + &app, + "the start press", + start, + logged("overlay start pressed"), + ) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + press(); + wait_for_app( + sandbox, + &app, + "the recording to start", + start, + logged("recording started"), + ) + .await; + tokio::time::sleep(Duration::from_secs(8)).await; + press(); + } + wait_for_app( + &sandbox, + &app, + "a verified upload", + Duration::from_secs(180), + || { + sandbox + .projects() + .first() + .and_then(|project| state(project)) + .is_some_and(|state| state["phase"] == "Verified") + }, + ) + .await; + kill(app); + let log = sandbox.log("camera"); + assert!(log.contains("auto-selecting camera"), "no camera selected"); + let video = mock.created(); + assert_eq!(video.len(), 1, "{video:?}"); + assert!( + mock.confirmations() + .iter() + .any(|confirmation| confirmation["videoId"] == video[0].as_str()) + ); + let project = sandbox.projects().remove(0); + assert!(finished_segments(&project) >= 3, "{}", project.display()); + assert!(mock.deleted().is_empty(), "{:?}", mock.deleted()); + assert!(sandbox.journal().is_empty(), "{:?}", sandbox.journal()); + assert!(!log.contains("panicked at"), "the app panicked"); +} + /// The After an Instant recording outcome decides whether the main window, /// hidden at start, comes back once the recording finishes: only Show Cap /// takes the reshow, so opening the link never leaves it on screen behind the