Skip to content

feat(functional-tests): add pairing E2E test with marionette authority#20119

Open
vbudhram wants to merge 1 commit intomainfrom
react-pair
Open

feat(functional-tests): add pairing E2E test with marionette authority#20119
vbudhram wants to merge 1 commit intomainfrom
react-pair

Conversation

@vbudhram
Copy link
Contributor

@vbudhram vbudhram commented Feb 27, 2026

Because

  • We have no way currently of testing the pairing flow locally
  • We used to have a functional test that verified the basic flow worked via desktop

This pull request

  • Adds a minimal Marionette TCP protocol client (lib/marionette.ts) to drive a real Firefox instance for the authority side of pairing
  • Adds a Firefox process manager (lib/marionette-firefox.ts) that launches Playwright's bundled Firefox with Marionette enabled, manages temp profiles, and cleans up on teardown
  • Adds a Playwright fixture (lib/fixtures/pairing.ts) that wires the Marionette authority into the test lifecycle using firefox.executablePath()
  • Adds the pairing E2E spec (tests/pairing/pairingFlow.spec.ts) exercising: OAuth sign-in via beginOAuthFlow → start pairing → channel handshake → authority approval → supplicant confirm → both sides complete
  • Extracts shared constants (lib/pairing-constants.ts) and helpers (lib/pairing-helpers.ts) with condition-based waits (no hardcoded sleeps)
  • Injects CI_WAF_TOKEN bypass header in CI for stage/production runs

Issue that this pull request solves

Closes: https://mozilla-hub.atlassian.net/browse/FXA-9501

Checklist

Put an x in the boxes that apply

  • My commit is GPG signed.
  • If applicable, I have modified or added tests which pass locally.
  • I have added necessary documentation (if appropriate).
  • I have verified that my changes render correctly in RTL (if appropriate).

Other information (Optional)

How to test:

# 1. Start the FXA stack
yarn start

# 2. Run pairing test against local (uses Playwright's bundled Firefox)
npx playwright test tests/pairing/pairingFlow.spec.ts --project=local

# 3. Run headed (watch the Marionette Firefox + Playwright browser)
MARIONETTE_HEADLESS=false \
  npx playwright test tests/pairing/pairingFlow.spec.ts --project=local --headed

# 4. Run against stage (requires WAF token from 1Password)
CI_WAF_TOKEN=<token> \
  npx playwright test tests/pairing/pairingFlow.spec.ts --project=stage

# 5. Run against stage, headed
CI_WAF_TOKEN=<token> MARIONETTE_HEADLESS=false \
  npx playwright test tests/pairing/pairingFlow.spec.ts --project=stage --headed

# 6. Override Firefox binary (optional)
FIREFOX_BINARY="/Applications/Firefox Nightly.app/Contents/MacOS/firefox" \
  npx playwright test tests/pairing/pairingFlow.spec.ts --project=local

# 7. Flakiness check (run 5x)
for i in {1..5}; do
  npx playwright test tests/pairing/pairingFlow.spec.ts --project=local
done

Note: Expect ~30-35s per run. MARIONETTE_HEADLESS=false shows the authority Firefox window; --headed shows the Playwright supplicant browser.est.

@vbudhram vbudhram force-pushed the react-pair branch 2 times, most recently from de2e451 to 23dda8b Compare February 27, 2026 18:59
expect(pairUrl).toBeTruthy();
expect(pairUrl).toContain('/pair#');
expect(pairUrl).toContain('channel_id=');
if (!pairUrl) throw new Error('pairUrl is null');
Copy link
Contributor

Choose a reason for hiding this comment

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

remove?

@vbudhram vbudhram force-pushed the react-pair branch 4 times, most recently from 4a66af3 to ee5412b Compare February 27, 2026 19:54
@vbudhram vbudhram self-assigned this Feb 27, 2026
@vbudhram vbudhram force-pushed the react-pair branch 3 times, most recently from a9e9176 to a1dc25e Compare February 28, 2026 00:42
Add end-to-end test for the Firefox device pairing flow using a
Marionette-driven Firefox authority and Playwright supplicant.

- Minimal Marionette TCP client (marionette.ts)
- Firefox process manager with temp profile lifecycle (marionette-firefox.ts)
- Playwright fixture using bundled Firefox (125+) as Marionette authority
- Uses beginOAuthFlow for proper PKCE/keys_jwk registration
- Condition-based waits replacing hardcoded sleeps
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
* End-to-end pairing flow test.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the most important comment of the PR, we need marionette to access FF internals to get the QR code url. While iterating it turns out you don't need to take a picture and decode the url, you can use the internal libs to get the url.

Once you have the url (channel_id & channel_key) you can build the pairing url and get the flow connected.

} from '../../lib/pairing-helpers';

// Increase timeout — pairing involves launching a separate Firefox + channel negotiation
test.setTimeout(120_000);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Locally this test takes about 60s to complete,=

* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
* Minimal Marionette protocol client over raw TCP.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I probably would have never thought of doing it this way, but I like it. This introduces zero deps and just talks to marionette via tcp.

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting... Can you get Claude to write some specific tests for it? There's enough here I think it should be under test. It'd be confusing if something in this layer started failing unexpectedly.

export { expect } from '@playwright/test';

/**
* Fetch the pairing channel server URI from the target's well-known config.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Lots of tokens were spent trying to figure this out. Turns out that the channel server on local/stage/prod are all different 🤷🏽

Copy link
Contributor

Choose a reason for hiding this comment

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

Really? What does that even mean? Are they actually different versions, do they just behave differently? Can we get local aligned with prod?

@vbudhram vbudhram marked this pull request as ready for review February 28, 2026 01:50
@vbudhram vbudhram requested a review from a team as a code owner February 28, 2026 01:50
this.buffer = Buffer.alloc(0);

await this.rawConnect();
const helloRaw = await this.recvPacket();
Copy link
Contributor

Choose a reason for hiding this comment

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

Funny variable name

headless?: boolean;
}

export class MarionetteFirefox {
Copy link
Contributor

Choose a reason for hiding this comment

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

Add comment explaining what "MarionetteFirefox" is.

await client.connect(10, 2000);
for (let attempt = 0; ; attempt++) {
try {
await client.newSession();
Copy link
Contributor

@dschom dschom Mar 3, 2026

Choose a reason for hiding this comment

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

Is this loop really needed? It seems kind of odd that you have to do this for a connection (line 100) and then again for a session...

killProcessOnPort(marionettePort);

// Create temp profile with FXA + Marionette prefs
const profileDir = fs.mkdtempSync(
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we have to worry about file permissions here? Guessing not since its using os.tempdir()?

resolve();
}, 5000)
);
await Promise.race([exited, timeout]);
Copy link
Contributor

@dschom dschom Mar 3, 2026

Choose a reason for hiding this comment

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

I don't get it... So it's trying to kill the process again, if just respond fast enough the first time? If the first kill didn't work, why would the second one?


// Create temp profile with FXA + Marionette prefs
const profileDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'fx-pairing-test-')
Copy link
Contributor

Choose a reason for hiding this comment

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

You probably want to add a postfix here. This will always return the same directory, which could cause problems if two processes try to run this code concurrently.

): Promise<MarionetteFirefox> {
const {
firefoxBinary,
marionettePort = 2828,
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it always run on this port? What if there's a port conflict?

const parsedPid = parseInt(pid.trim(), 10);
if (isNaN(parsedPid) || parsedPid <= 0) continue;
try {
process.kill(parsedPid, 'SIGKILL');
Copy link
Contributor

Choose a reason for hiding this comment

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

It's hard to know what's running on the port provided here... Should you there be some kinda guard rail here. Like checking for 'marionette' in the process path something?

try {
await this.sendCommand('WebDriver:DeleteSession');
} catch {
// Ignore errors during session deletion
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we log them at least...

* Try to extract a complete packet from the buffer.
* Packet format: "LENGTH:JSON_BODY"
*/
private tryDeliverPacket(): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not so sure about this function... Seems like it works, but I can see it being flaky. I'll outline why...

if (!this.pendingResolve) return;

const colonIdx = this.buffer.indexOf(0x3a); // ':'
if (colonIdx === -1) return;
Copy link
Contributor

@dschom dschom Mar 3, 2026

Choose a reason for hiding this comment

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

Are we sure the buffer will always contains this? What if the buffer is big, and the ':' is in the next/prev chunk. Maybe this isn't a big deal... and the message format is always small enough to fit in the buffer... just checking though.

): Promise<Record<string, unknown>> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
if (this.sock) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This implies we don't want two active connections, which makes sense... But multiple MarionetteClient instnaces can be created which means you could have multiple sockets created and bound to the marionette port... If marinonette doesn't support multiple clients, then we should make class a singleton to avoid any future issues.

const expectedId = this.msgId;
const msg = JSON.stringify([0, expectedId, name, params]);
const packet = `${msg.length}:${msg}`;
this.sock.write(packet, 'utf-8');
Copy link
Contributor

Choose a reason for hiding this comment

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

This isn't checking if the write went through. It could have been partial / not fully drained...

const packet = `${msg.length}:${msg}`;
this.sock.write(packet, 'utf-8');

const raw = await this.recvPacket();
Copy link
Contributor

Choose a reason for hiding this comment

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

AFAICT, if the buffer hasn't recieved data yet, this will just return... I'm not sure how this ensures synchronization. I bet this works most the time, but I can also see it failing if marionnette is slow or laggy to respond to the command.

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.

2 participants