Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/eighty-pugs-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@browserbasehq/stagehand-python": patch
"@browserbasehq/stagehand": patch
---

merge repeated --disable-features and --enable-features into one switch each so caller flags no longer discard the local browser defaults.
52 changes: 50 additions & 2 deletions packages/sdk-python/src/stagehand/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,7 @@ def _local_browser_flags(
)
window_size_flag = f"--window-size={viewport.width},{viewport.height}"

return [
return _merge_feature_flags([
*(
[flag for flag in _DEFAULT_CHROME_FLAGS if flag not in ignored_flags]
if include_defaults
Expand Down Expand Up @@ -1209,7 +1209,55 @@ def _local_browser_flags(
*(["--ignore-certificate-errors"] if options.ignore_https_errors is True else []),
*(options.args or []),
"about:blank",
]
])


_MERGEABLE_FEATURE_FLAGS = ("--disable-features", "--enable-features")


def _merge_feature_flags(flags: list[str]) -> list[str]:
"""Emit each of the feature switches once, carrying every value it was given.

Chrome parses ``--disable-features`` and ``--enable-features`` as a single
value each. A second occurrence of either switch does not add to the first,
it replaces it whole, taking every feature name the first one carried with
it.

Since the defaults are emitted before ``options.args``, without this a caller
passing any ``--disable-features`` of their own would silently drop all ten
of the default names, and a caller passing ``--enable-features`` would drop
the WebMCP switch that the extension surface relies on.

Merge instead: keep one switch of each kind, at the position of its first
occurrence, carrying the de-duplicated union of every value in list order.
"""
merged: list[str] = []
slot_for: dict[str, int] = {}
values_for: dict[str, dict[str, None]] = {}

for flag in flags:
name = next(
(
candidate
for candidate in _MERGEABLE_FEATURE_FLAGS
if flag.startswith(f"{candidate}=")
),
None,
)
if name is None:
merged.append(flag)
continue
values = values_for.setdefault(name, {})
for value in flag[len(name) + 1 :].split(","):
if value:
values[value] = None
if name not in slot_for:
slot_for[name] = len(merged)
merged.append(flag)

for name, slot in slot_for.items():
merged[slot] = f"{name}={','.join(values_for.get(name, {}))}"
return merged


def _should_disable_chromium_sandbox(
Expand Down
48 changes: 48 additions & 0 deletions packages/sdk-python/tests/test_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,54 @@ async def launch(options: LocalBrowserLaunchOptions) -> FakeSource:
await handle.close()


# Chrome parses --disable-features and --enable-features as a single value each:
# a second occurrence replaces the first outright rather than adding to it. Since
# the defaults are emitted before options.args, these switches have to be merged
# or a caller passing one of their own silently drops every default feature name.
_WEBMCP_CHROME_FLAG = next(f for f in _DEFAULT_CHROME_FLAGS if f.startswith("--enable-features="))


def _feature_values(flags: list[str], name: str) -> list[str]:
return [flag for flag in flags if flag.startswith(f"{name}=")]


def _flags_for(tmp_path: Path, **overrides: object) -> list[str]:
options = LocalBrowserLaunchOptions(**overrides) # ty: ignore[missing-argument]
return _local_browser_flags(options, port=9222, user_data_dir=tmp_path, disable_sandbox=False)


def test_feature_flags_merge_caller_values_into_the_defaults(tmp_path: Path) -> None:
flags = _flags_for(tmp_path, args=["--disable-features=ExampleFeature"])
assert _feature_values(flags, "--disable-features") == [
f"{_DEFAULT_CHROME_FLAGS[0]},ExampleFeature"
]

flags = _flags_for(tmp_path, args=["--enable-features=ExampleFeature"])
assert _feature_values(flags, "--enable-features") == [f"{_WEBMCP_CHROME_FLAG},ExampleFeature"]


def test_feature_flags_merge_in_order_without_duplicating_a_value(tmp_path: Path) -> None:
flags = _flags_for(
tmp_path,
args=["--disable-features=Translate,First", "--mute-audio", "--disable-features=Second"],
)

assert flags[0] == f"{_DEFAULT_CHROME_FLAGS[0]},First,Second"
assert len(_feature_values(flags, "--disable-features")) == 1
assert "--mute-audio" in flags


def test_feature_flags_leave_the_caller_alone_when_defaults_are_ignored(tmp_path: Path) -> None:
flags = _flags_for(
tmp_path,
ignore_default_args=True,
args=["--disable-features=ExampleFeature"],
)

assert _feature_values(flags, "--disable-features") == ["--disable-features=ExampleFeature"]
assert _feature_values(flags, "--enable-features") == []


@pytest.mark.parametrize("extension_id", [None, "", "existing", "wrong-id"])
async def test_local_connect_ignores_id_and_defers_packaged_extension(
fake_cdp: type[FakeCDPClient],
Expand Down
50 changes: 48 additions & 2 deletions packages/sdk-ts/src/browser/localBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function localBrowserChromeFlags(
const viewport = options.viewport ?? { width: 1280, height: 800 };
const windowSizeFlag = `--window-size=${viewport.width},${viewport.height}`;

return [
return mergeFeatureFlags([
...(includeDefaults ? selectedChromeFlags(DEFAULT_CHROME_FLAGS, ignoredFlags) : []),
...(options.viewport !== undefined || (includeDefaults && !ignoredFlags.has(windowSizeFlag))
? [windowSizeFlag]
Expand All @@ -214,7 +214,7 @@ export function localBrowserChromeFlags(
...(options.ignoreHTTPSErrors === true ? ["--ignore-certificate-errors"] : []),
...(options.args ?? []),
"about:blank",
];
]);
}

function selectedChromeFlags(
Expand All @@ -224,6 +224,52 @@ function selectedChromeFlags(
return flags.filter((flag) => !ignoredFlags.has(flag));
}

const MERGEABLE_FEATURE_FLAGS = ["--disable-features", "--enable-features"] as const;

/**
* Chrome parses `--disable-features` and `--enable-features` as a single value
* each. A second occurrence of either switch does not add to the first, it
* replaces it whole, taking every feature name the first one carried with it.
*
* Since the defaults above are emitted before `options.args`, without this a
* caller passing any `--disable-features` of their own would silently drop all
* ten of the default names, and a caller passing `--enable-features` would drop
* the WebMCP switch that the extension surface relies on.
*
* Merge instead: keep one switch of each kind, at the position of its first
* occurrence, carrying the de-duplicated union of every value in list order.
*/
function mergeFeatureFlags(flags: string[]): string[] {
const merged: string[] = [];
const slotFor = new Map<string, number>();
const valuesFor = new Map<string, Set<string>>();

for (const flag of flags) {
const name = MERGEABLE_FEATURE_FLAGS.find((candidate) => flag.startsWith(`${candidate}=`));
if (name === undefined) {
merged.push(flag);
continue;
}
const values = valuesFor.get(name) ?? new Set<string>();
for (const value of flag.slice(name.length + 1).split(",")) {
if (value !== "") {
values.add(value);
}
}
valuesFor.set(name, values);
const slot = slotFor.get(name);
if (slot === undefined) {
slotFor.set(name, merged.length);
merged.push(flag);
}
}

for (const [name, slot] of slotFor) {
merged[slot] = `${name}=${[...(valuesFor.get(name) ?? [])].join(",")}`;
}
return merged;
}

function validateLocalBrowserOptions(options: LocalBrowserLaunchOptions): void {
if (
options.port !== undefined &&
Expand Down
61 changes: 61 additions & 0 deletions packages/sdk-ts/tests/browser/localBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,67 @@ describe("local browser Chrome flags", () => {
),
).not.toContain("--window-size=1280,800");
});

// Chrome parses --disable-features and --enable-features as a single value
// each: a second occurrence replaces the first outright rather than adding to
// it. Since the defaults are emitted before options.args, these switches have
// to be merged or a caller passing one of their own silently drops every
// default feature name.
const featureValues = (flags: string[], name: string): string[] =>
flags.filter((flag) => flag.startsWith(`${name}=`));

it("emits a single --disable-features carrying both the defaults and the caller's", () => {
const flags = localBrowserChromeFlags(
{ args: ["--disable-features=ExampleFeature"] },
9_222,
"/tmp/profile",
false,
);

expect(featureValues(flags, "--disable-features")).toStrictEqual([
`${DEFAULT_CHROME_FLAGS[0]},ExampleFeature`,
]);
});

it("keeps the WebMCP flag when the caller passes their own --enable-features", () => {
const flags = localBrowserChromeFlags(
{ args: ["--enable-features=ExampleFeature"] },
9_222,
"/tmp/profile",
false,
);

expect(featureValues(flags, "--enable-features")).toStrictEqual([
`${WEBMCP_CHROME_FLAG},ExampleFeature`,
]);
});

it("merges repeated feature switches in order without duplicating a value", () => {
const flags = localBrowserChromeFlags(
{ args: ["--disable-features=Translate,First", "--mute-audio", "--disable-features=Second"] },
9_222,
"/tmp/profile",
false,
);

expect(flags[0]).toBe(`${DEFAULT_CHROME_FLAGS[0]},First,Second`);
expect(featureValues(flags, "--disable-features")).toHaveLength(1);
expect(flags).toContain("--mute-audio");
});

it("leaves the caller's switch alone when the defaults are ignored entirely", () => {
const flags = localBrowserChromeFlags(
{ ignoreDefaultArgs: true, args: ["--disable-features=ExampleFeature"] },
9_222,
"/tmp/profile",
false,
);

expect(featureValues(flags, "--disable-features")).toStrictEqual([
"--disable-features=ExampleFeature",
]);
expect(featureValues(flags, "--enable-features")).toStrictEqual([]);
});
});

describe("local browser launch lifecycle", () => {
Expand Down
Loading