DEV Community

Cover image for Playwright's new_page() Pulled Chrome to the Front. A Background Tab Alone Didn't Fix It.
John
John

Posted on Originally published at hexisteme.github.io

Playwright's new_page() Pulled Chrome to the Front. A Background Tab Alone Didn't Fix It.

Originally published on hexisteme notes.

A blog publishing job ran every 15 minutes on the Mac I work on, and each run opened a browser tab through Playwright. Any of those runs could pull the browser over the app I was using. A stats job at 09:20 could do the same. I have no count of how many runs actually took the screen.

The browsers themselves made this hard to avoid. There were two, both headed and always on, both driven over CDP by scheduled jobs: Chrome for Testing on port 9333 and Google Chrome on port 9334. launchd kept both alive with KeepAlive, so closing one didn't help. It relaunched within 30 seconds and its window came to the front. On October 30, at 18:54 and 21:05, both browsers exited normally (exit 0) and relaunched. My notes record the exit code and not the reason.

That made two separate ways onto my screen: a job opening a tab, and a browser coming back after an exit. The fix has one layer for each, and the second layer is needed because the first one un-hides the browser.

Everything here is Mac-only. open, launchd and NSRunningApplication are macOS pieces, and every result below comes from one Mac. The tab layer is Playwright and CDP, but I only ran it there too.

What I measured

The first row comes from a probe on October 29.

What ran What happened
context.new_page() on a headed browser The browser came to the front and took the focus
CDP Target.createTarget with background: true Focus stayed where it was, but a hidden browser got un-hidden
open -j, which launches hidden No effect, because Chrome shows itself when it opens its first window
open -g on a relaunched Google Chrome with an existing profile The browser came to the front anyway
bring_to_front() in one other job Called on purpose, because that job needs the clipboard

The two open rows ruled out a launch flag as the whole answer. -g is the flag meant to keep a launched app in the background, and it didn't hold for that relaunch. I can't say whether the existing profile is the cause or only the condition I saw it in, because I didn't check why.

The second row shaped the design. background: true solves the focus problem, and a background tab by itself leaves a hidden browser visible again.

The last row was never a bug. That job pulls the browser forward deliberately, and I decided to leave it that way.

Layer one: open tabs behind the app

BackgroundTabs replaces new_page() with the CDP call and hands everything else to the real context.

class BackgroundTabs:
    def __init__(self, context):
        self._context = context
        self._cdp = None

    def new_page(self):
        if self._cdp is None:
            self._cdp = self._context.browser.new_browser_cdp_session()
        with self._context.expect_page() as opened:
            self._cdp.send('Target.createTarget', {'url': 'about:blank', 'background': True})
        return opened.value

    def __getattr__(self, name):
        return getattr(self._context, name)
Enter fullscreen mode Exit fullscreen mode

Reading it from the top:

  • The CDP session is browser-level. It's created on the first tab and reused after that.
  • expect_page() wraps the send, so the caller gets a Playwright page back, the same kind of object new_page() returns.
  • The tab starts at about:blank, so the caller still has to navigate it.
  • __getattr__ forwards every other attribute to the wrapped context.

I wrapped the context instead of editing the browser module because a deployment review pins that module's bytes. Session pages and the re-login tab go through the wrapper too, since any path left on new_page() would still take the focus.

If your browser isn't hidden and you only mind the focus change on new tabs, this layer is all my table calls for. It does nothing for the relaunch path.

Layer two: a launch wrapper that keeps hiding

launchd started each browser through a shell wrapper. This is its launch line:

open -n -g -a "$APP" --args --user-data-dir=... --remote-debugging-port=... --remote-debugging-address=127.0.0.1
Enter fullscreen mode Exit fullscreen mode

-g stays on that line, but the wrapper doesn't depend on it. What keeps the browser out of the way is a hide loop that starts after launch:

  • For the first 20 seconds, it hides unconditionally.
  • After that, every 2 seconds, it hides only when the browser is not the frontmost app.

The frontmost check is what leaves me a way in. If I switch to the browser with Cmd+Tab or the Dock, it's frontmost, so the loop leaves it visible. By the same rule, a browser un-hidden by a background tab is visible but not frontmost, so the next pass hides it again. That's how the two layers fit together.

The hide is NSRunningApplication.hide, called through the JXA ObjC bridge with osascript -l JavaScript. It's not an Apple Event, so there's no automation permission prompt.

The rest of the wrapper keeps launchd's view of the process correct:

  • The shell waits until the browser exits, so launchd sees the browser's exit and restarts it.
  • If launchd ends the shell, the browser ends too.
  • If a browser with the same profile is already running, the shell waits on that one rather than starting another.

What I checked

  • A one-hour watch from 18:57 to 19:57 covered 3 publish runs. Tabs opened in the background each time, and either browser came to the front 0 times.
  • On 2 temporary profiles, screenshots, typing and clicking inside the page all worked while the browser was hidden. When the wrapper got a TERM, the browser stopped with it.
  • On the real browser, I simulated Cmd+Q with a TERM. The relaunch came up with hidden=true and active=false, the frontmost app stayed where it was, and the login persisted.

Each path to the screen maps to a different check:

Path to the screen Layer that handles it Check
A job opens a tab BackgroundTabs One-hour watch
A browser relaunches Launch wrapper Simulated Cmd+Q on the real browser
A background tab un-hides a hidden browser Hide loop No direct measurement, only the rule

The temporary-profile check sits outside that table. It's there because a hidden browser is only useful if the jobs can still work in its pages.

What this doesn't show

The evidence is one hour, 3 runs and one Mac. The publishing job was paused at 21:37 that evening by my decision, so this hour is the only watch on record. Some specific gaps follow from that:

  • A 15-minute schedule should fit more than 3 runs into an hour. My record says 3 and doesn't say why.
  • The stats job ran at 09:20, outside the watch, so the watch says nothing about it.
  • The watch counted browsers coming to the front. It didn't measure how long a browser stays visible between a background tab un-hiding it and the next hide pass.
  • By the frontmost rule, a browser pulled forward by the bring_to_front() job would stay there, the same as if I had switched to it. I have no measurement of that case. It would also trip the falsifier below as written, so any such event has to be checked against that job first.
  • I don't know why the browsers exited on October 30, or why that relaunch ignored -g.

The falsifier is specific: either browser comes to the front with no human switching to it, or a hidden browser produces empty screenshots or timeouts. Before calling this settled I'd want a longer watch that includes a 09:20 stats run and a run of the bring_to_front() job, because nothing in that hour's record shows either one.

Email list for these notes: hexisteme.beehiiv.com — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.

More notes at hexisteme.github.io/notes.

Top comments (0)