Skip to content

AI Controls (kill switch) test suite#1508

Open
jsegunosidefox wants to merge 2 commits into
mainfrom
ai-killswitch-port-tests
Open

AI Controls (kill switch) test suite#1508
jsegunosidefox wants to merge 2 commits into
mainfrom
ai-killswitch-port-tests

Conversation

@jsegunosidefox

Copy link
Copy Markdown
Collaborator

Relevant Links

Bugzilla: n/a
TestRail: S71443 (GenAI Settings / AI Controls)

Description of Code / Doc Changes

Ports the AI Controls ("kill switch") tests onto main (the follow-up to the merged minimal PoC #1302; supersedes the stale #1140). 22 test cases under tests/ai_controls/, one file per TestRail case.

  • conftest.py — enterprise-policy support: a policies_list fixture writes distribution/policies.json next to the binary before launch and removes it in teardown (skips gracefully with a clear message if the dir isn't writable, e.g. a SIP-protected app bundle).
  • modules/page_object_prefs.pyAboutPrefs AI helpers: navigate_to_ai_controls, verify_ai_controls_core_elements_visible, set_ai_blocking, get_ai_killswitch_state, translations/chatbot getters+setters, cancel_ai_killswitch_click, get_extensions_ml_enabled. State is read via aria-pressed (the inner input has no .pressed property).
  • modules/data/about_prefs.components.json — added ai-control-link-preview-select, ai-control-smart-tab-groups-select, ai-control-pdf-alt-text-select.
  • manifests/key.yaml — registered ai_controls cases.
  • Addressed prior review feedback: C3341331 now asserts extensions.ml.enabled flips on block (via the UI toggle — a raw pref set doesn't run the killswitch side effects); C3340562 exercises the real "Block all AI enhancements?" Cancel path.

Cases that map to inherently manual/gated scenarios (screen readers, non-English/region builds, about:inference model storage, high-contrast legibility, telemetry pings, enterprise policy) are left disabled/unstable rather than given false-confidence assertions.

Process Changes Required

  • Adds a dependency (rerun pipenv install)
  • Modifies a git hook (rerun ./devsetup.sh)
  • Changes the BasePage
  • Changes or creates a BOM/POM (name the object model): AboutPrefs (modules/page_object_prefs.py)
  • Changes CI flow
  • Changes scheduled Beta or DevEdition
  • Changes Git hooks or Github settings
  • Changes L10n harness

Screenshots or Explanations

Verified locally on Firefox Nightly 154: 18 pass / 7 skip / 0 fail across tests/ai_controls. Skips are the 5 policy tests (SIP-protected build dir; run in CI) and 2 non-English @pytest.mark.skip cases.

Workflow Checklist

  • Reviewers have been requested.
  • Code has been linted and formatted.
  • If this is an unblocker, a message has been posted to #dte-automation in Slack.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Review: AI Controls (kill switch) test suite

Overall the enterprise-policy machinery, POM additions, and test structure are solid. A few issues worth addressing:

Redundant navigation on every test

The about_prefs fixture in tests/ai_controls/conftest.py already opens about:preferences#ai, but every test then calls navigate_to_ai_controls() again. That's two navigations per test. Either remove the ap.open() from the fixture (and leave navigation to each test) or remove the navigate_to_ai_controls() call from the tests that rely on the fixture.

set_ai_blocking bypasses the UI

set_ai_blocking sets the pref directly via Services.prefs.setStringPref. This will not trigger the killswitch's side effects (e.g. flipping extensions.ml.enabled) the way clicking the UI toggle does. Tests like C3298824 and C3310314 that use set_ai_blocking may therefore not exercise the real user-facing behaviour. The tests that actually care about side effects (C3341331) correctly use toggle_ai_killswitch_click — consider consolidating around that approach, or at minimum document in set_ai_blocking's docstring that it is a pref-only shortcut that skips side effects.

Stub tests will silently pass if ever enabled

C3276003 (test_ai_settings_visible_high_contrast.py) and C3276004 (test_ai_settings_link_shortcut.py) are marked disabled in the manifest and carry TODO comments describing what they should test — but their bodies merely navigate to the AI page and call verify_ai_controls_core_elements_visible(). If someone un-disables them they'll pass trivially, giving false confidence. Consider either leaving the body as pytest.skip("not implemented") (with no navigation) or removing the test body to make the incomplete state obvious.

Fragile cancel-button detection in cancel_ai_killswitch_click

The method filters buttons with el.get_attribute("label") != "Block". If the dialog ever gets a third button, or if the "Block" label changes (e.g. localisation), the filter silently picks the wrong element. Prefer selecting by a stable attribute (e.g. aria-label, element ID, or a dedicated component key in the JSON manifest) rather than by label inequality.

Minor: import json inside driver fixture

The local import in the middle of conftest.py's driver fixture is non-idiomatic — Python's import system caches modules, so there's no perf benefit. Moving it to the top of the file alongside the other standard-library imports improves readability.

Comment thread modules/page_object_prefs.py
Comment thread modules/page_object_prefs.py Outdated
""",
select_elem,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Filtering by label != "Block" is fragile: any third button (or a localised "Block" label) will silently select the wrong element. A dedicated component key in the JSON manifest, or a stable attribute like data-l10n-id / element ID, would be more robust.

Comment thread conftest.py Outdated
Comment thread tests/ai_controls/test_c3276003_ai_settings_visible_high_contrast.py Outdated
Comment thread tests/ai_controls/test_c3276004_ai_settings_link_shortcut.py Outdated
@jsegunosidefox
jsegunosidefox force-pushed the ai-killswitch-port-tests branch from b1bf217 to 8ccd654 Compare July 15, 2026 14:41
@jsegunosidefox

Copy link
Copy Markdown
Collaborator Author

Addressed the automated review (pushed as an amend):

  • import json → moved to module top in conftest.py.
  • set_ai_blocking → docstring now states it's a pref-only shortcut that skips UI side effects; points to toggle_ai_killswitch_click (used by C3341331).
  • cancel_ai_killswitch_click → matches label == "Cancel" (symmetric with the merged toggle_ai_killswitch_click's == "Block") instead of != "Block".
  • C3276003 / C3276004 → bodies replaced with explicit pytest.skip("not implemented") so they can't pass trivially if un-disabled.
  • Imports sorted (ruff --select I).

Deferred, with reasoning:

  • Redundant navigate_to_ai_controls() — the about_prefs fixture pre-opens the pane and the merged C3308998 relies on that contract; the extra navigation is harmless, so I left it rather than risk that test.
  • Dialog button via dedicated JSON key / stable attribute — kept label matching to stay consistent with the already-merged toggle_ai_killswitch_click; worth a follow-up if we want to de-fragilise both together.

Re-verified on Firefox Nightly 154: 16 pass / 9 skip / 0 fail.

@github-actions

Copy link
Copy Markdown
Contributor

Review: AI Controls Kill Switch Test Suite

Good overall — clear policy-injection mechanism, well-documented methods, and appropriate use of disabled/unstable for cases that can't be reliably automated. A few issues worth addressing:

Double navigation in every test
The about_prefs fixture in tests/ai_controls/conftest.py already calls ap.open(), which navigates to about:preferences?entrypoint=ai#ai. Every test then calls navigate_to_ai_controls() which navigates again via self.driver.get("about:preferences#ai"). The redundant round-trip is harmless but wasteful. Consider either dropping navigate_to_ai_controls() calls in favour of letting the fixture handle it, or removing ap.open() from the fixture and keeping the explicit call in each test.

set_ai_chatbot_provider missing post-set assertion
Unlike set_ai_translations, which calls self.expect(lambda _: self.get_ai_translations_state() == state) after the JS dispatch, set_ai_chatbot_provider returns immediately after dispatching change. If the moz-select has async update logic the test in C3341325 could pass before the pref actually reflects the new provider.

Silent OSError on policy file cleanup
In the driver fixture teardown, except OSError: pass swallows removal failures silently. A leftover policies.json could leak into the next test run; at minimum the error should be logged.

C3279955 doesn't exercise the "policy removed" scenario
The test's policies_list returns {}, so no policy was ever written — the test is just asserting the default killswitch state. It's disabled in the manifest so it won't run in CI, but if it's ever enabled it won't validate what its name implies.

Comment thread modules/page_object_prefs.py Outdated
)
if not matched:
raise ValueError(f"Provider '{provider}' not found in chatbot dropdown")
return self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing post-set confirmation. set_ai_translations calls self.expect(lambda _: self.get_ai_translations_state() == state) after the JS dispatch; this method should do the same:

Suggested change
return self
if not matched:
raise ValueError(f"Provider '{provider}' not found in chatbot dropdown")
self.expect(lambda _: self.get_ai_chatbot_provider() == provider)
return self

Without it, C3341325 can pass before the moz-select's async update has propagated the new value to the underlying pref.

Comment thread conftest.py Outdated
try:
policy_file.unlink()
logging.info(f"Removed enterprise policies file {policy_file}")
except OSError:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent failure here could leave a policies.json on disk and poison the next test run. At minimum log the error:

Suggested change
except OSError:
except OSError as exc:
logging.warning(f"Failed to remove enterprise policies file {policy_file}: {exc}")

@pytest.fixture()
def policies_list():
"""No policy active — simulates policy being removed/changed."""
return {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This policies_list = {} means no policy is ever written, so the test only asserts the default (policy-free) state. To validate "enable AI features when policy is changed/removed", you'd need to start with a blocking policy active (i.e. return a non-empty dict here) and then confirm the UI reflects no policy once it's removed. As written the test is a no-op for its stated purpose — which is fine since it's disabled in the manifest, but worth noting if it's ever re-enabled.

@jsegunosidefox
jsegunosidefox force-pushed the ai-killswitch-port-tests branch from 8ccd654 to 23e1351 Compare July 15, 2026 15:35
Comment thread conftest.py Outdated
policy_file.write_text(json.dumps({"policies": policies_list}, indent=2))
wrote_policy = True
logging.info(f"Wrote enterprise policies to {policy_file}")
except PermissionError:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The write catches only PermissionError, but the unlink above catches the broader OSError. On some systems (e.g. read-only mount, EROFS) a non-permission OSError is raised for writes too. For consistency and correctness, catch OSError here as well — or at minimum re-raise unexpected OSError subtypes.

Suggested change
except PermissionError:
except OSError:
pytest.skip(f"Cannot write policies to {dist_dir} (permission denied).")

Comment thread conftest.py Outdated
if policies_list:
try:
dist_dir.mkdir(parents=True, exist_ok=True)
policy_file.write_text(json.dumps({"policies": policies_list}, indent=2))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Path.write_text uses the platform default encoding when no encoding is specified, which can differ on Windows (e.g. cp1252). JSON files should always be UTF-8.

Suggested change
policy_file.write_text(json.dumps({"policies": policies_list}, indent=2))
policy_file.write_text(json.dumps({"policies": policies_list}, indent=2), encoding="utf-8")

# TODO: Implement the following to complete this test:
# 1. Navigate to about:inference (or use IndexedDB / profile inspection)
# to list cached AI models before and after blocking.
# 2. Assert that all model entries are removed once the killswitch is on.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test is disabled in the manifest today, but as written it would pass trivially if ever enabled — the TODO steps are the entire substance of the case. Consider adding pytest.skip("Not implemented: …") at the top of the function so enabling it in the manifest can't accidentally produce a false-green.

@github-actions

Copy link
Copy Markdown
Contributor

Good overall structure — the enterprise-policy fixture is a clean addition and the reasoning for each disabled/skip case is well-documented.

A few things worth addressing:

Minor bugs

  • policy_file.write_text(...) in conftest.py does not specify encoding, so on Windows it defaults to the system codepage rather than UTF-8. JSON files should always be written as UTF-8 (see inline comment).
  • The PermissionError catch on the write is narrower than the OSError catch on the unlink above it — other OS-level errors (read-only filesystem, quota exceeded) would bubble up as unexpected exceptions rather than a clean skip (see inline comment).

Double navigation

tests/ai_controls/conftest.py fixture calls ap.open() (which navigates to about:preferences#ai), and then every test body calls about_prefs.navigate_to_ai_controls() — the same URL again. Each test is doing two full page loads. The fixture could drop .open() (return the uninitialised object) and let navigate_to_ai_controls() own navigation, or vice versa.

C3343878 false-green risk

test_c3343878_models_deleted_when_blocked.py is disabled in the manifest, but if it ever gets enabled the function will pass trivially — the only meaningful work (listing/asserting model deletion) is in the TODO. A pytest.skip() at the top of the function would prevent a silent false-pass if the manifest entry is changed before the implementation lands (see inline comment).

@jsegunosidefox
jsegunosidefox force-pushed the ai-killswitch-port-tests branch from 23e1351 to 881e74d Compare July 15, 2026 15:39
@github-actions

Copy link
Copy Markdown
Contributor

Good overall structure and well-reasoned use of disabled/unstable for tests that can't be automated. A few issues worth addressing:

1. Services.prefs called without chrome context

set_ai_blocking and get_extensions_ml_enabled in page_object_prefs.py call Services.prefs via execute_script without @BasePage.context_chrome. Every other Services.prefs call in the codebase (e.g., browser_object_sidebar.py) is decorated with @BasePage.context_chrome. While about:preferences is a privileged page, the lack of the decorator is inconsistent and fragile — if the driver context is not in content mode by coincidence it could fail silently on a future refactor. The same issue appears in the inline Services.prefs calls in test_c3298824_block_all_persists.py.

2. Double navigation in tests

tests/ai_controls/conftest.py defines about_prefs which already calls ap.open() (navigates to about:preferences#ai). Nearly every test then immediately calls about_prefs.navigate_to_ai_controls(), which navigates to the same URL again. The fixture should either drop the open() call and let tests navigate, or call navigate_to_ai_controls() itself and return a ready-to-use page. Currently there's an extra round-trip per test.

3. PermissionError too narrow in conftest.py

except PermissionError:
    pytest.skip(...)

Writing the policy file can fail with other OSError subtypes (e.g., ENOSPC / disk full). The stale-file removal above it correctly catches OSError. This catch should be OSError for consistency and robustness.

4. cancel_ai_killswitch_click uses bare assert

The existing toggle_ai_killswitch_click uses [0] (raising IndexError on failure). cancel_ai_killswitch_click uses assert cancel, ... which can be silently disabled with -O. Align the pattern — either use [0] like the sibling method, or raise ValueError explicitly.

"""
if block != self.get_ai_killswitch_state():
self.driver.execute_script(
"Services.prefs.setStringPref('browser.ai.control.default', arguments[0]);",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Services.prefs is a privileged API. Every other call in the codebase that uses it is decorated with @BasePage.context_chrome (see browser_object_sidebar.py). set_ai_blocking and get_ai_killswitch_state should follow suit, otherwise they rely on about:preferences happening to run privileged — which is correct today but fragile.

Suggested change
"Services.prefs.setStringPref('browser.ai.control.default', arguments[0]);",
if block != self.get_ai_killswitch_state():
with self.driver.context(self.driver.CONTEXT_CHROME):
self.driver.execute_script(
"Services.prefs.setStringPref('browser.ai.control.default', arguments[0]);",
"blocked" if block else "available",
)
self.expect(lambda _: self.get_ai_killswitch_state() == block)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Worth looking at.

self.click_on("ai-controls-toggle")
self.element_visible("ai-controls-disable-dialog-button")
buttons = self.get_elements("ai-controls-disable-dialog-button")
cancel = [el for el in buttons if el.get_attribute("label") == "Cancel"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bare assert can be silently disabled with Python's -O flag, and it's inconsistent with toggle_ai_killswitch_click which lets IndexError propagate on [0]. Prefer an explicit guard:

Suggested change
cancel = [el for el in buttons if el.get_attribute("label") == "Cancel"]
cancel = [el for el in buttons if el.get_attribute("label") == "Cancel"]
if not cancel:
raise ValueError("Cancel button not found in block-AI confirmation dialog")
cancel[0].click()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also worth a look.

@jsegunosidefox jsegunosidefox added the skip-claude-review Skip the Claude Code Reviewer workflow on this PR label Jul 15, 2026
@jsegunosidefox

jsegunosidefox commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Summary (revised)

Per Ben's feedback, this PR now contains only tests that actually run and assert real behavior — no stubs, no pytest.skip, no unstable, no environment-gated cases. The stub/deferred/unautomatable cases from the original port (high-contrast, screen-reader, localization, non-en builds, enterprise policies, sidebar sync, model deletion) have been removed, along with the now-dead harness and selectors they needed.

Result: conftest.py and about_prefs.components.json are back to matching main (no shared-infra changes). The PR is just the AI Controls helpers on AboutPrefs + the tests that use them + the manifest block.

Tests — all pass on Firefox Nightly, en-US (8 collected / 8 passed / 0 skipped)

Case Asserts
c3276002 core AI Controls elements (toggle, chatbot select, translations select) are present/visible
c3298824 Block-all persists after enabling an individual feature
c3310314 an enabled feature remains enabled after blocking + unblocking via the toggle
c3340562 cancelling the Block-all confirmation leaves AI enabled
c3341331 the WebExtensions ML API is disabled while AI is blocked, restored after unblock

Notes for review

  • Kill-switch flows go through the real UI toggle (toggle_ai_killswitch_click) so side effects fire; direct pref writes are used only where the UI control is deliberately hidden, and that trade-off is documented on set_ai_blocking.
  • Dialog buttons are matched by English label to stay consistent with the already-merged toggle_ai_killswitch_click ("Block"); a locale-robust selector should replace both together in a follow-up rather than diverging here.

(Superseded my earlier summary above, which described the pre-trim version.)

@ben-c-at-moz ben-c-at-moz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I see you haven't requested reviewers, which may mean you're not ready with this PR, but I have some comments you should take a look at before you get to that point.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a heads up, we don't need stubs for tests we've determined were unsuitable for automation, so it would be best to remove this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The linked test indicates that each element should be tested with the keyboard, which we can do, but is not present in this test, just FYI

Comment thread conftest.py Outdated
options.binary_location = fx_executable
# options.set_preference("app.update.disabledForTesting", False)

# Enterprise policies live in distribution/policies.json next to the binary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Interesting and potentially very useful. Have we confirmed that automated sessions respect the policy files if they have been written like this?

@jsegunosidefox
jsegunosidefox force-pushed the ai-killswitch-port-tests branch from 881e74d to 21faf3f Compare July 22, 2026 11:14
Ports the automatable AI Controls ("kill switch") tests onto main and
adds the AboutPrefs helpers they need. Per review, the suite contains
ONLY tests that actually run and assert real behavior — no stubs,
skips, or environment-gated cases.

Tests (all pass on Nightly, en-US):
- c3276002: core AI Controls elements are present/visible
- c3298824: Block-all persists after enabling an individual feature
- c3310314: enabled features remain after unblocking via the toggle
- c3340562: cancel the Block-all confirmation leaves AI enabled
- c3341331: WebExtensions ML API is disabled while AI is blocked

Removed the stub/skipped/unautomatable cases (high-contrast, screen
reader, localization, non-en builds, enterprise policies, sidebar sync,
model deletion) and their now-dead harness/selectors, so conftest.py and
about_prefs.components.json are back to matching main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jsegunosidefox
jsegunosidefox force-pushed the ai-killswitch-port-tests branch from 21faf3f to d68f354 Compare July 22, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-claude-review Skip the Claude Code Reviewer workflow on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants