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
72 changes: 67 additions & 5 deletions packages/functional-tests/lib/android-supplicant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const KEY_PAIRING_URL = 'pref_key_sync_debug_pairing_url';
interface UiNode {
text: string;
desc: string;
/** Empty for GeckoView web content; set for native Android widgets. */
resourceId: string;
cx: number;
cy: number;
}
Expand Down Expand Up @@ -163,6 +165,20 @@ export class AndroidSupplicant {
this.swipe(width / 2, height * 0.5, width / 2, height * 0.48, 100);
}

/**
* Wipe all app data so pairing starts from a signed-out, first-run state.
*
* `forceStop` only kills the process; the FxA account in `shared_prefs`
* survives it, and a Fenix that already has an account takes a re-auth web
* flow instead of the pairing flow. Call this before `ensureReady`, which
* re-grants the permissions and rewrites the server override that
* `pm clear` removes.
*/
resetToColdState(): void {
this.adb(['shell', 'pm', 'clear', this.pkg]);
debug('Cleared app data; supplicant is in a cold, signed-out state');
}

/**
* Verify a device is attached and the Fenix debug build is installed. Grants
* camera/notification permissions and points the FxA server override at the
Expand Down Expand Up @@ -246,6 +262,43 @@ export class AndroidSupplicant {
return url;
}

/**
* Open the pairing URL as a normal tab, the way a native QR scan does on a
* v2 device: `VIEW <pair url>` with no OAuth params in the URL. The page then
* asks the browser for them over the web channel (`fxaccounts:pair_oauth_start`).
*
* The Sync Debug hook takes the other branch - app-services runs OAuth-start
* itself and hands the page a URL that already carries the params - so this
* is the path that exercises the v2 web-channel command. The web channel is
* live in normal tabs: `FxaWebChannelIntegration` is installed by
* `BaseBrowserFragment`, the parent of both the browser and custom-tab
* fragments.
*
* Returns the pairing URL as seen in logcat once the tab has loaded it.
*/
async openPairingUrl(
pairingUrl: string,
timeoutMs = 60_000
): Promise<string> {
this.forceStop();
this.adb(['logcat', '-c']);
// `adb shell` re-tokenizes argv and the URL carries `#` and `&`, so the
// device command goes over as one single-quoted string.
this.adb([
'shell',
`am start -a android.intent.action.VIEW -d '${pairingUrl}' ${this.pkg}`,
]);
await this.waitForProcess(30_000);
await sleep(6_000); // first-run init before dialogs are dismissable
this.dismissBlockingDialogs();

const line = await this.waitForLog(/url=[^,]*\/pair(\?|#|,|\s)/, timeoutMs);
debug(
`Supplicant opened the pairing URL in a normal tab: ${line.slice(0, 120)}`
);
return line;
}

/**
* Wait until the supplicant custom tab has loaded its pairing page and is
* connecting to the channel. Distinguishes the real pairing flow (/pair/supp
Expand All @@ -272,12 +325,15 @@ export class AndroidSupplicant {
* authority approves the new device, then the supplicant confirms here.
* (uiautomator can read GeckoView web content.)
*/
async confirmPairing(timeoutMs = 45_000): Promise<void> {
async confirmPairing(
timeoutMs = 45_000,
// v1 uses "Confirm"/"Confirm pairing"; the v2 supplicant card uses "Connect".
re = /^Confirm pairing$|^Confirm$/i
): Promise<void> {
// The confirm button lives in GeckoView web content, read via uiautomator's
// accessibility tree — which GeckoView populates lazily, so a dump can miss
// it transiently. Poll, and periodically nudge the page with a tiny scroll
// to force the a11y tree to repopulate.
const re = /^Confirm pairing$|^Confirm$/i;
const deadline = Date.now() + timeoutMs;
let attempt = 0;
while (Date.now() < deadline) {
Expand Down Expand Up @@ -418,10 +474,15 @@ export class AndroidSupplicant {
];
for (let pass = 0; pass < 3; pass++) {
const nodes = this.dumpUi();
const hit = nodes.find((n) => dismissers.some((re) => re.test(n.text)));
// Native widgets only. uiautomator also reads GeckoView web content, and
// the v2 supplicant card has its own Cancel button - tapping that would
// close the pairing channel and abort the flow on both sides.
const hit = nodes.find(
(n) => n.resourceId && dismissers.some((re) => re.test(n.text))
);
if (!hit) return;
this.tap(hit.cx, hit.cy);
debug(`Dismissed dialog button: ${hit.text}`);
debug(`Dismissed dialog button: ${hit.text} (${hit.resourceId})`);
}
}

Expand Down Expand Up @@ -471,12 +532,13 @@ export class AndroidSupplicant {
const tag = m[0];
const text = attr(tag, 'text');
const desc = attr(tag, 'content-desc');
const resourceId = attr(tag, 'resource-id');
const bounds = attr(tag, 'bounds');
const b = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!b) continue;
const cx = Math.floor((Number(b[1]) + Number(b[3])) / 2);
const cy = Math.floor((Number(b[2]) + Number(b[4])) / 2);
nodes.push({ text, desc, cx, cy });
nodes.push({ text, desc, resourceId, cx, cy });
}
return nodes;
}
Expand Down
50 changes: 50 additions & 0 deletions packages/functional-tests/lib/firefox-binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
* Resolves the desktop Firefox that pairing tests drive over Marionette.
*
* The v2 chrome commands ship in Nightly, so the v2 specs need it. Playwright
* launches only its own build and never comes through here.
*/

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { firefox } from 'playwright';

/** Default install locations for Firefox Nightly, by platform. */
const NIGHTLY_PATHS: Record<string, string[]> = {
darwin: [
'/Applications/Firefox Nightly.app/Contents/MacOS/firefox',
path.join(
os.homedir(),
'Applications/Firefox Nightly.app/Contents/MacOS/firefox'
),
],
linux: [
'/usr/bin/firefox-nightly',
'/usr/local/bin/firefox-nightly',
'/opt/firefox-nightly/firefox',
path.join(os.homedir(), 'firefox-nightly/firefox'),
],
win32: [
'C:\\Program Files\\Firefox Nightly\\firefox.exe',
'C:\\Program Files (x86)\\Firefox Nightly\\firefox.exe',
],
};

function findFirefoxNightly(): string | undefined {
return (NIGHTLY_PATHS[process.platform] || []).find((p) => fs.existsSync(p));
}

/** A v2-capable Firefox, or undefined. FIREFOX_BINARY wins, so a local build works. */
export function findV2AuthorityBinary(): string | undefined {
return process.env.FIREFOX_BINARY || findFirefoxNightly();
}

/** Binary for the `marionetteAuthority` fixture. The v1 specs run on either. */
export function resolveAuthorityBinary(): string {
return findV2AuthorityBinary() || firefox.executablePath();
}
7 changes: 2 additions & 5 deletions packages/functional-tests/lib/fixtures/pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* (desktop) side of the pairing flow.
*/

import { firefox } from 'playwright';
import { resolveAuthorityBinary } from '../firefox-binary';
import { MarionetteFirefox } from '../marionette-firefox';
import { test as standardTest, TestOptions } from './standard';

Expand All @@ -20,10 +20,7 @@ export type PairingTestOptions = TestOptions & {

export const test = standardTest.extend<PairingTestOptions>({
marionetteAuthority: async ({ target }, use, testInfo) => {
// Use Playwright's bundled Firefox by default — it's already downloaded
// in CI and locally. Override with FIREFOX_BINARY env if needed.
const firefoxBinary =
process.env.FIREFOX_BINARY || firefox.executablePath();
const firefoxBinary = resolveAuthorityBinary();
const channelServerUri =
process.env.CHANNEL_SERVER_URI ||
(await fetchChannelServerUri(target.contentServerUrl));
Expand Down
21 changes: 19 additions & 2 deletions packages/functional-tests/lib/marionette-firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,18 @@ export class MarionetteFirefox {
args.push('--headless');
}

// Diagnostic: set MARIONETTE_LOG to capture the authority's stdout (content
// console.log, via devtools.console.stdout.content) to a file.
const logPath = process.env.MARIONETTE_LOG;
const proc = spawn(firefoxBinary, args, {
stdio: 'ignore',
stdio: logPath ? ['ignore', 'pipe', 'pipe'] : 'ignore',
detached: false,
});
if (logPath && proc.stdout && proc.stderr) {
const ws = fs.createWriteStream(logPath, { flags: 'a' });
proc.stdout.pipe(ws);
proc.stderr.pipe(ws);
}

try {
// Wait for Marionette port to become available
Expand Down Expand Up @@ -216,10 +224,19 @@ function buildPrefs(
// Auto-handle unexpected dialogs (dismiss by default)
'marionette.prefs.recommended': true,

// Pairing
// Pairing. version=2 lets chrome accept the v2 pair_oauth_start /
// pair_oauth_finish web-channel commands (gated by _ensurePairingEnabled).
'identity.fxaccounts.pairing.enabled': true,
'identity.fxaccounts.pairing.version': 2,
'identity.fxaccounts.remote.pairing.uri': channelServerUri,

// Diagnostic: route content console.log to the process stdout so the
// authority's [pair2] logs can be captured (see MARIONETTE_LOG).
'devtools.console.stdout.content': true,
// Diagnostic: FxAccounts chrome logs (pairing + oauth) to dump/stdout.
'identity.fxaccounts.loglevel': 'Trace',
'identity.fxaccounts.log.appender.dump': 'Trace',

// Browser chrome — suppress UI that interferes with automation
'datareporting.policy.dataSubmissionEnabled': false,
'toolkit.telemetry.reportingpolicy.firstRun': false,
Expand Down
10 changes: 6 additions & 4 deletions packages/functional-tests/lib/marionette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,14 @@ export class MarionetteClient {
}

/**
* Take a screenshot of the current page.
* Returns the screenshot as a base64-encoded PNG string.
* Screenshot a single element, returned as a base64-encoded PNG.
*
* Scoped to the element rather than the viewport so the caller gets just
* that node's pixels, which is what an image decoder needs.
*/
async takeScreenshot(): Promise<string> {
async screenshotElement(elementId: string): Promise<string> {
const result = await this.sendCommandWithRetry('WebDriver:TakeScreenshot', {
full: true,
id: elementId,
});
return this.extractValue(result) as string;
}
Expand Down
29 changes: 29 additions & 0 deletions packages/functional-tests/lib/pairing-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ export const TIMEOUTS = {
POLL_INTERVAL_MAX: 2_000,
} as const;

// ---- v2 pairing (FXA-12855) ----
// The v2 flow moves the authority into FxA web content. These constants cover
// the v2 routes and QR URL format; v1 constants above are unchanged.
//
// Route source of truth is App/index.tsx (code takes precedence over the ticket
// prose, which uses "/pair2/..." and "approve_sign_in"). NOTE: the content-server
// route list currently misspells the timeout page as "timeout_and_cacnel", so the
// two timeout routes 404 until that is fixed (tracked with the negative-path work).
Comment on lines +30 to +33
export const PAIR_V2_ROUTES = {
AUTHORITY_SCAN_QR: '/pair/authority/scan_qr',
AUTHORITY_APPROVE_SIGNIN: '/pair/authority/approve_signin',
AUTHORITY_CONTINUE_ON_MOBILE: '/pair/authority/continue_on_mobile',
AUTHORITY_SYNC_SUCCESS: '/pair/authority/sync_success',
AUTHORITY_TIMEOUT_AND_CANCEL: '/pair/authority/timeout_and_cancel',
SUPPLICANT_APPROVE_SIGNIN: '/pair/supplicant/approve_signin',
SUPPLICANT_CONNECT_THIS_DEVICE: '/pair/supplicant/connect_this_device',
SUPPLICANT_READY_TO_SCAN: '/pair/supplicant/ready_to_scan',
SUPPLICANT_SYNC_SUCCESS: '/pair/supplicant/sync_success',
SUPPLICANT_TIMEOUT_AND_CANCEL: '/pair/supplicant/timeout_and_cancel',
} as const;

// v2 QR URL format, confirmed by FXA-13868 AC:
// https://<host>/pair#channel_id=<id>&channel_key=<key>&v=2
// i.e. the v1 fragment plus the v2 marker.
export const PAIR_V2_URL_MARKER = 'v=2';

/** Browser pref that decides the pairing version Firefox reports and accepts. */
export const PAIRING_VERSION_PREF = 'identity.fxaccounts.pairing.version';

export const SELECTORS = {
EMAIL_INPUT: [
'input[type="email"]',
Expand Down
Loading