Skip to content

fix: recording on macos #273#426

Open
InlinePizza wants to merge 3 commits intodoc-detective:mainfrom
Promptless:main
Open

fix: recording on macos #273#426
InlinePizza wants to merge 3 commits intodoc-detective:mainfrom
Promptless:main

Conversation

@InlinePizza
Copy link

@InlinePizza InlinePizza commented Feb 27, 2026

I ran into #273, ran into the undefined error in stop step, and saw a blank tab with the title RECORD_ME next to the other tab.

driver.execute() sends a script to the browser and waits for it to return synchronously. But getDisplayMedia() returns a Promise [1] . So when the original code called captureAndDownload() without await on line 164, here's what happened:

  1. driver.execute() sends the script to Chrome
  2. Chrome starts executing it, hits captureAndDownload(), which kicks off a promise
  3. The function body ends — no return value, no await — so Chrome reports back "done, result: undefined"
  4. driver.execute() resolves on the Node.js side
  5. Node.js immediately switches back to the original tab (line 167)
  6. Meanwhile, in the recorder tab, getDisplayMedia() is still waiting to resolve

That tab switch is the killer on macOS. The --auto-select-desktop-capture-source=RECORD_ME flag tells Chrome to auto-pick the tab titled "RECORD_ME," but the code has already moved focus away. On macOS specifically, this race seems to consistently cause getDisplayMedia() to reject or return nothing, so window.recorder never gets created.

driver.executeAsync() fixes this because it doesn't resolve on the Node.js side until the browser code explicitly calls the done callback. So the sequence becomes:

  1. driver.executeAsync() sends the script to Chrome
  2. Chrome calls getDisplayMedia(), awaits it, creates the MediaRecorder, calls .start()
  3. Only then does it call done(true)
  4. driver.executeAsync() resolves on the Node.js side
  5. Now Node.js switches tabs — recording is already running

Checking recorderStarted before populating config.recording follows directly from this. If getDisplayMedia() rejects (permissions not granted, API failure, etc.), the done(false) path fires, and startRecording returns FAIL immediately instead of pretending everything worked and letting stopRecording crash later.

Summary by CodeRabbit

  • Bug Fixes
    • Improved recording reliability by switching to an asynchronous start so screen capture is confirmed before proceeding.
    • Added clearer failure handling and messaging when recording cannot start, with proper cleanup and UI/title restoration.
    • Prevented attempts to stop a recorder that was never initialized by validating recorder state before stopping.
    • Ensured download and cleanup flows run on both success and failure.

@CLAassistant
Copy link

CLAassistant commented Feb 27, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1f48890 and ceb02c2.

📒 Files selected for processing (1)
  • src/tests/startRecording.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/tests/startRecording.js

Walkthrough

startRecording moved from a synchronous path to an asynchronous driver.executeAsync flow with explicit success/failure signaling, expanded cleanup and error reporting. stopRecording now validates window.recorder after switching to the recorder tab and fails early with cleanup if the recorder is absent.

Changes

Cohort / File(s) Summary
Recording Start
src/tests/startRecording.js
Replaced synchronous driver.execute with driver.executeAsync. Browser-side IIFE requests getDisplayMedia, creates MediaRecorder, collects blobs, and signals success (done(true)) or failure (done(false)). On start failure, resets config.recording, sets result to FAIL with description, logs error, closes recorder tab, restores title, and returns.
Recording Stop & Validation
src/tests/stopRecording.js
After switching to the recording tab, added runtime check for window.recorder. If missing, mark result FAIL, clear config.recording, close the recorder window, and return early; if present, proceed to call stop() as before.

Sequence Diagram(s)

sequenceDiagram
    participant Test as Test Runner
    participant Driver as WebDriver
    participant Browser as Browser Context
    participant API as getDisplayMedia API
    participant Recorder as MediaRecorder
    participant Callback as Async Callback

    rect rgba(100,150,200,0.5)
    Note over Test,Callback: startRecording (driver.executeAsync)
    Test->>Driver: executeAsync(start script)
    Driver->>Browser: run async IIFE
    Browser->>API: request getDisplayMedia()
    API-->>Browser: return MediaStream / error
    alt stream acquired
        Browser->>Recorder: new MediaRecorder(stream)
        Recorder->>Recorder: collect data, setup onstop/ondataavailable
        Recorder->>Callback: done(true)
    else failure
        Browser->>Callback: done(false)
        Browser->>Browser: cleanup (close tab, restore title)
    end
    Callback-->>Driver: resolve boolean
    Driver-->>Test: result
    end

    rect rgba(150,100,200,0.5)
    Note over Test,Browser: stopRecording (validation)
    Test->>Driver: switch to recorder tab
    Driver->>Browser: evaluate window.recorder ?
    alt recorder exists
        Browser->>Recorder: call stop()
        Recorder-->>Browser: onstop -> finalize/download
        Browser-->>Test: success
    else missing
        Browser-->>Test: mark FAIL, clear config, close tab
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped to async, brave and spry,

I asked the screen to share the sky,
If no recorder greets my tune,
I close the tab beneath the moon,
Then nibble logs and hum goodbye.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: recording on macos #273' directly corresponds to the PR's main objective: fixing a macOS recording issue (#273). It is concise, specific, and clearly summarizes the primary change.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tests/startRecording.js (1)

182-187: ⚠️ Potential issue | 🔴 Critical

Critical: Recording config not being set correctly.

This sets result.recording instead of config.recording. The stopRecording.js handler relies on config.recording.type, config.recording.downloadPath, and config.recording.targetPath (see lines 34, 56, 63-65 in stopRecording.js), but these values won't be available because they're on result instead of config.

This will cause stopRecording to fail with undefined property errors or incorrect behavior since config.recording will only contain { tab: ... } from line 100.

🐛 Proposed fix
     // Set recorder
-    result.recording = {
+    config.recording = {
+      ...config.recording,
       type: "MediaRecorder",
       tab: recorderTab.handle,
       downloadPath: path.join(os.tmpdir(), `${baseName}.webm`), // Where the recording will be downloaded.
       targetPath: filePath, // Where the recording will be saved.
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/startRecording.js` around lines 182 - 187, The code incorrectly
assigns the recording metadata to result.recording instead of config.recording
so stopRecording.js (which reads config.recording.type,
config.recording.downloadPath, config.recording.targetPath) can't find those
fields; update the assignment so that config.recording is set to an object
containing type: "MediaRecorder", tab: recorderTab.handle, downloadPath:
path.join(os.tmpdir(), `${baseName}.webm`), and targetPath: filePath (remove or
stop using result.recording) so stopRecording's references to config.recording.*
resolve correctly.
🧹 Nitpick comments (2)
src/tests/startRecording.js (1)

191-314: Remove unreachable dead code.

The return result; at line 193 makes all code from line 195 onwards unreachable. This appears to be leftover implementation code for non-Chrome contexts. Consider removing this dead code to improve maintainability, or if this functionality is planned for future implementation, move it to a separate commented block or issue tracker.

♻️ Proposed cleanup
     // Other context

     result.status = "SKIPPED";
     result.description = `Recording is not supported for this context.`;
     return result;
-
-    const dimensions = await driver.execute(() => {
-      return {
-        outerHeight: window.outerHeight,
-        outerWidth: window.outerWidth,
...
-      result.recording = ffmpegProcess;
-    } catch (error) {
-      // Couldn't save screenshot
-      result.status = "FAIL";
-      result.description = `Couldn't start recording. ${error}`;
-      return result;
-    }
   }

   // PASS
   return result;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/startRecording.js` around lines 191 - 314, The early "return
result;" makes everything after it unreachable; remove the dead block starting
immediately after that return (the dimensions calculation, recordingSettings,
ffmpeg args, instantiateCursor/ffmpeg spawn logic, and the try/catch around
them) or move it into a clearly marked alternative branch (e.g., a separate
function like startRecordingForNonChrome) if you intend to implement non-Chrome
recording later; update references to symbols used in that removed block
(dimensions, recordingSettings, ffmpegPath, spawn, instantiateCursor,
ffmpegProcess, driver, step.record) so no unused imports/variables remain.
src/tests/stopRecording.js (1)

56-58: Consider adding a timeout to prevent infinite waiting.

This pre-existing loop could hang indefinitely if the file download fails or takes too long. While not part of this PR's changes, consider adding a timeout with a maximum retry count to improve reliability.

💡 Optional improvement
+      const maxWaitTime = 30000; // 30 seconds
+      const startTime = Date.now();
       // Wait for file to be in download path
-      while (!fs.existsSync(config.recording.downloadPath)) {
+      while (!fs.existsSync(config.recording.downloadPath)) {
+        if (Date.now() - startTime > maxWaitTime) {
+          result.status = "FAIL";
+          result.description = "Recording file download timed out.";
+          await driver.closeWindow();
+          config.recording = null;
+          return result;
+        }
         await new Promise((r) => setTimeout(r, 1000));
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/stopRecording.js` around lines 56 - 58, The waiting loop in
src/tests/stopRecording.js that repeatedly checks
fs.existsSync(config.recording.downloadPath) can hang forever; replace it with a
bounded wait using either a maxRetries counter or a maxTimeout (e.g., track
elapsed time) and a small sleep between attempts, and throw or return an error
when the limit is reached; update the code that currently awaits new Promise((r)
=> setTimeout(r, 1000)) to use a reusable sleep/delay helper and enforce the
timeout so callers of the stopRecording logic get a deterministic failure
instead of waiting infinitely.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/tests/startRecording.js`:
- Around line 182-187: The code incorrectly assigns the recording metadata to
result.recording instead of config.recording so stopRecording.js (which reads
config.recording.type, config.recording.downloadPath,
config.recording.targetPath) can't find those fields; update the assignment so
that config.recording is set to an object containing type: "MediaRecorder", tab:
recorderTab.handle, downloadPath: path.join(os.tmpdir(), `${baseName}.webm`),
and targetPath: filePath (remove or stop using result.recording) so
stopRecording's references to config.recording.* resolve correctly.

---

Nitpick comments:
In `@src/tests/startRecording.js`:
- Around line 191-314: The early "return result;" makes everything after it
unreachable; remove the dead block starting immediately after that return (the
dimensions calculation, recordingSettings, ffmpeg args, instantiateCursor/ffmpeg
spawn logic, and the try/catch around them) or move it into a clearly marked
alternative branch (e.g., a separate function like startRecordingForNonChrome)
if you intend to implement non-Chrome recording later; update references to
symbols used in that removed block (dimensions, recordingSettings, ffmpegPath,
spawn, instantiateCursor, ffmpegProcess, driver, step.record) so no unused
imports/variables remain.

In `@src/tests/stopRecording.js`:
- Around line 56-58: The waiting loop in src/tests/stopRecording.js that
repeatedly checks fs.existsSync(config.recording.downloadPath) can hang forever;
replace it with a bounded wait using either a maxRetries counter or a maxTimeout
(e.g., track elapsed time) and a small sleep between attempts, and throw or
return an error when the limit is reached; update the code that currently awaits
new Promise((r) => setTimeout(r, 1000)) to use a reusable sleep/delay helper and
enforce the timeout so callers of the stopRecording logic get a deterministic
failure instead of waiting infinitely.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a196b95 and 4155f54.

📒 Files selected for processing (2)
  • src/tests/startRecording.js
  • src/tests/stopRecording.js

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tests/startRecording.js (1)

102-173: ⚠️ Potential issue | 🟠 Major

Use try/finally for tab/title cleanup across all failure modes.

Between Line 102 and Line 173, multiple awaited driver calls can throw outside the explicit !recorderStarted path. That can strand the "RECORDER" tab or leave the original title unrecovered. Wrap this section in try/finally and centralize restoration there.

Suggested structure
-    const documentTitle = await driver.getTitle();
-    const originalTab = await driver.getWindowHandle();
-    await driver.execute(() => (document.title = "RECORD_ME"));
-    await instantiateCursor(driver, { position: "center" });
-    const recorderTab = await driver.createWindow("tab");
-    await driver.switchToWindow(recorderTab.handle);
-    ...
-    if (!recorderStarted) {
-      ...
-      await driver.closeWindow();
-      await driver.switchToWindow(originalTab);
-      await driver.execute((documentTitle) => {
-        document.title = documentTitle;
-      }, documentTitle);
-      return result;
-    }
-    await driver.switchToWindow(originalTab);
-    await driver.execute((documentTitle) => {
-      document.title = documentTitle;
-    }, documentTitle);
+    const documentTitle = await driver.getTitle();
+    const originalTab = await driver.getWindowHandle();
+    let recorderTab;
+    let openedRecorderTab = false;
+    try {
+      await driver.execute(() => (document.title = "RECORD_ME"));
+      await instantiateCursor(driver, { position: "center" });
+      recorderTab = await driver.createWindow("tab");
+      openedRecorderTab = true;
+      await driver.switchToWindow(recorderTab.handle);
+      ...
+      if (!recorderStarted) {
+        config.recording = null;
+        result.status = "FAIL";
+        ...
+        return result;
+      }
+    } finally {
+      if (result.status === "FAIL" && openedRecorderTab) {
+        await driver.closeWindow();
+      }
+      await driver.switchToWindow(originalTab);
+      await driver.execute((title) => {
+        document.title = title;
+      }, documentTitle);
+    }

As per coding guidelines, "Always handle driver cleanup in try/finally blocks in browser automation code".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/startRecording.js` around lines 102 - 173, The code that starts
recording (the driver.executeAsync call that sets window.recorder) and the
subsequent recorderStarted handling can throw between awaits and currently only
cleans up when !recorderStarted; wrap the entire sequence from the executeAsync
call through the post-start logic in a try/finally so tab/title cleanup always
runs; move the calls that close the recorder tab and restore the original
tab/title (the driver.closeWindow, driver.switchToWindow(originalTab), and
driver.execute restoring documentTitle) into the finally block and ensure
config.recording/result status handling remains correct (e.g., set
config.recording = null and result when recorder fails) while leaving the
navigator/getDisplayMedia error path behavior intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/tests/startRecording.js`:
- Around line 153-154: Replace the direct console.error call in the
startRecording flow with the project logger and propagate the error via the done
callback: when executeAsync resolves with an error (the err variable used on
line 153), call log(config, "error", `Error starting recording: ${err}`) and
then invoke done(err) (or done(false) only if the codebase expects a boolean
failure indicator—prefer passing the error object). Update the block that
currently does console.error(`Error starting recording: ${err}`); done(false);
to use log(config, "error", ...) and to return the error through done(...) so
failures are both logged via log(config, ...) and propagated by the
done/executeAsync handling.

---

Outside diff comments:
In `@src/tests/startRecording.js`:
- Around line 102-173: The code that starts recording (the driver.executeAsync
call that sets window.recorder) and the subsequent recorderStarted handling can
throw between awaits and currently only cleans up when !recorderStarted; wrap
the entire sequence from the executeAsync call through the post-start logic in a
try/finally so tab/title cleanup always runs; move the calls that close the
recorder tab and restore the original tab/title (the driver.closeWindow,
driver.switchToWindow(originalTab), and driver.execute restoring documentTitle)
into the finally block and ensure config.recording/result status handling
remains correct (e.g., set config.recording = null and result when recorder
fails) while leaving the navigator/getDisplayMedia error path behavior intact.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4155f54 and 1f48890.

📒 Files selected for processing (1)
  • src/tests/startRecording.js

Comment on lines 153 to 154
console.error(`Error starting recording: ${err}`);
done(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid console.error; route failures through project logger.

Line 153 uses direct console logging. Return the error details through done(...) and log in Node with log(config, "error", ...) after executeAsync resolves.

As per coding guidelines, "Use log(config, level, message) for all logging, where level = "debug"|"info"|"warning"|"error"".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/startRecording.js` around lines 153 - 154, Replace the direct
console.error call in the startRecording flow with the project logger and
propagate the error via the done callback: when executeAsync resolves with an
error (the err variable used on line 153), call log(config, "error", `Error
starting recording: ${err}`) and then invoke done(err) (or done(false) only if
the codebase expects a boolean failure indicator—prefer passing the error
object). Update the block that currently does console.error(`Error starting
recording: ${err}`); done(false); to use log(config, "error", ...) and to return
the error through done(...) so failures are both logged via log(config, ...) and
propagated by the done/executeAsync handling.

@hawkeyexl
Copy link
Contributor

Hey @InlinePizza! Thanks for the PR! Unfortunately, we're going through a rather large refactor at the moment, and core is getting decommissioned. All associated code is getting directly bundled into doc-detective/doc-detective (and has been converted to TypeScript) starting with the next release. I'll port this change over to the new code base for now and tag you in the PR. You can run the new version at with the beta tag: npx doc-detective@beta

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants