Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 101 additions & 2 deletions .github/workflows/instant-reliability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/**"
Comment thread
richiemcilroy marked this conversation as resolved.
Resolved
- "crates/recording/src/recovery.rs"
- "crates/enc-ffmpeg/src/mux/segmented_stream.rs"
- "crates/enc-ffmpeg/src/mux/dash_audio.rs"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
88 changes: 88 additions & 0 deletions apps/desktop-gpui/src/upload/real_app_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading