Skip to content

Commit e38f535

Browse files
committed
feat(fxa-settings): React pairing pages with dual-mode E2E coverage and CMS theming
Because: The Backbone /pair flow is being migrated to React in fxa-settings, and we need parity in functionality, telemetry, copy, relying-party CMS theming, and unsupported-browser messaging while keeping regression coverage on both stacks during the transition. This commit: - Implements React pages for /pair, /pair/supp[/allow|/wait_for_auth], /pair/auth/[allow|totp|wait_for_supp|complete], /pair/failure, and /pair/unsupported, including the choice screen and Glean parity with the Backbone equivalents - Wires the React pair routes through fxa-content-server with the showReactApp.pairRoutes feature flag and fullProdRollout - Adds Marionette-driven E2E happy-path and 2FA tests against React pairing (pairingFlow.spec.ts) and a parallel Backbone suite (pairingFlowBackbone.spec.ts) gated on the same flag - Adds negative-path E2E coverage for both stacks: supplicant cancel and channel-server WebSocket disconnect (pairingFlow{,Backbone}Negative.spec.ts) - Adds a non-Firefox unsupported-browser E2E test that spoofs the UA and runs against either stack - Aligns React Pair/Failure copy with the Backbone wording via -v2 FTL ids so Fluent falls back to the new English strings until upstream l10n catches up - Rewrites Pair/Unsupported branch logic to mirror the Backbone template one-for-one: adds the missing mobile unsupportedPairHtml instructions ("Connecting your mobile device with your Mozilla account... visit firefox.com/pair") for both mobile Firefox and mobile non-Firefox users, fixes the desktop Firefox fallback to show "Oops! Something went wrong" instead of the stray system-camera message, and keeps the Unsupported page stack-agnostic - Adds CMS theming to Pair/Index in strict parity with the Backbone pair/index.js fetchCmsConfig path: wires useCmsInfoState into the component, passes cmsInfo to AppLayout for background, header logo, favicon, and page title, and swaps the three primary CTAs for CmsButtonWithFallback. Other React pair pages remain unthemed to match Backbone - Adds a ChoiceScreenWithCmsTheming Storybook variant and five CMS unit tests in Pair/Index; five new UA-spoofing unit tests for Pair/Unsupported covering every branch (desktop non-Firefox, mobile Firefox, mobile non-Firefox, mobile with system camera, desktop Firefox fallback) - Hardens PairingSupplicantIntegration by routing handleClose and handleChannelError through a shared fail helper that guards against duplicate terminal-state transitions and normalizes error shapes, fixing a race the new disconnect tests would otherwise hit - Addresses reviewer feedback: tightens Pair/Supp/Allow selectors to #supp-approve-btn (now set on both React and Backbone Confirm buttons with the same id, proving both page render and interactivity in one check); expands the page.addInitScript WebSocket wrapper comments with the WHY (Playwright 1.44.1 has no routeWebSocket) and a no-leak note (page-scoped, torn down with the fixture); rewrites the misleading "1006" comment in the disconnect helper with accurate close semantics; documents the query-fix.js hash-preservation line specifically for the pair supplicant flow; adds an inline note on Config.version being optional so the appDisplayVersion fallback is justified - Fixes a pre-existing race in completeSupplicantApproval by waiting for /oauth/success directly instead of polling "URL is not /pair/supp/allow", which could catch the intermediate /pair/supp/wait_for_auth state - Simplifies several pair pages as a follow-up cleanup: extracted MobileChoice type in Pair/Index, removed single-use helpers in Pair/Supp, collapsed empty branches and redundant renames in SuppAllow and SuppWaitForAuth, narrowed sessionToken and reused the existing error helper in AuthAllow - Adds shared test helpers and selectors in packages/functional-tests/lib/pairing-{constants,helpers}.ts and a Marionette + Playwright pairing fixture
1 parent ed4693b commit e38f535

File tree

88 files changed

+7094
-1560
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+7094
-1560
lines changed

packages/functional-tests/lib/fixtures/pairing.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ export const test = standardTest.extend<PairingTestOptions>({
2727
const channelServerUri =
2828
process.env.CHANNEL_SERVER_URI ||
2929
(await fetchChannelServerUri(target.contentServerUrl));
30-
const marionettePort = parseInt(process.env.MARIONETTE_PORT || '2828', 10);
31-
if (isNaN(marionettePort)) {
30+
const basePort = parseInt(process.env.MARIONETTE_PORT || '2828', 10);
31+
if (isNaN(basePort)) {
3232
throw new Error(
3333
`Invalid MARIONETTE_PORT: ${process.env.MARIONETTE_PORT}`
3434
);
3535
}
36+
// Offset port by parallelIndex so parallel workers don't collide
37+
const marionettePort = basePort + testInfo.parallelIndex;
3638
const headless = process.env.MARIONETTE_HEADLESS !== 'false';
3739

3840
const authority = await MarionetteFirefox.launch({

packages/functional-tests/lib/marionette.ts

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,59 @@ export class MarionetteClient {
180180
return data[3];
181181
}
182182

183+
/**
184+
* Commands that are safe to retry on transient errors (read-only operations).
185+
*/
186+
private static readonly RETRYABLE_COMMANDS = new Set([
187+
'WebDriver:GetCurrentURL',
188+
'WebDriver:GetTitle',
189+
'WebDriver:FindElement',
190+
'WebDriver:FindElements',
191+
'Marionette:SetContext',
192+
'WebDriver:TakeScreenshot',
193+
]);
194+
195+
/**
196+
* Transient error types that warrant a retry.
197+
*/
198+
private static readonly TRANSIENT_ERRORS = new Set([
199+
'no such window',
200+
'unknown error',
201+
'timeout',
202+
]);
203+
204+
/**
205+
* Send a command with automatic retry for read-only commands on transient errors.
206+
* Write commands (click, sendKeys) are never retried.
207+
* 2 retries with linear backoff (500ms, 1000ms).
208+
*/
209+
private async sendCommandWithRetry(
210+
name: string,
211+
params: Record<string, unknown> = {},
212+
maxRetries = 2
213+
): Promise<unknown> {
214+
if (!MarionetteClient.RETRYABLE_COMMANDS.has(name)) {
215+
return this.sendCommand(name, params);
216+
}
217+
218+
let lastError: Error | undefined;
219+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
220+
try {
221+
return await this.sendCommand(name, params);
222+
} catch (err) {
223+
lastError = err instanceof Error ? err : new Error(String(err));
224+
const isTransient =
225+
err instanceof MarionetteError &&
226+
MarionetteClient.TRANSIENT_ERRORS.has(err.errorType);
227+
if (!isTransient || attempt === maxRetries) {
228+
throw lastError;
229+
}
230+
await this.sleep(500 * (attempt + 1));
231+
}
232+
}
233+
throw lastError;
234+
}
235+
183236
async newSession(): Promise<unknown> {
184237
return this.sendCommand('WebDriver:NewSession', {
185238
capabilities: {
@@ -197,7 +250,9 @@ export class MarionetteClient {
197250
}
198251

199252
async setContext(context: MarionetteContext): Promise<void> {
200-
await this.sendCommand('Marionette:SetContext', { value: context });
253+
await this.sendCommandWithRetry('Marionette:SetContext', {
254+
value: context,
255+
});
201256
}
202257

203258
async executeScript(
@@ -243,12 +298,12 @@ export class MarionetteClient {
243298
}
244299

245300
async getUrl(): Promise<string> {
246-
const result = await this.sendCommand('WebDriver:GetCurrentURL');
301+
const result = await this.sendCommandWithRetry('WebDriver:GetCurrentURL');
247302
return this.extractValue(result) as string;
248303
}
249304

250305
async getTitle(): Promise<string> {
251-
const result = await this.sendCommand('WebDriver:GetTitle');
306+
const result = await this.sendCommandWithRetry('WebDriver:GetTitle');
252307
return this.extractValue(result) as string;
253308
}
254309

@@ -261,15 +316,15 @@ export class MarionetteClient {
261316
}
262317

263318
async findElement(using: string, value: string): Promise<string> {
264-
const result = await this.sendCommand('WebDriver:FindElement', {
319+
const result = await this.sendCommandWithRetry('WebDriver:FindElement', {
265320
using,
266321
value,
267322
});
268323
return this.extractElementId(result);
269324
}
270325

271326
async findElements(using: string, value: string): Promise<string[]> {
272-
const result = await this.sendCommand('WebDriver:FindElements', {
327+
const result = await this.sendCommandWithRetry('WebDriver:FindElements', {
273328
using,
274329
value,
275330
});
@@ -315,7 +370,7 @@ export class MarionetteClient {
315370
* Returns the screenshot as a base64-encoded PNG string.
316371
*/
317372
async takeScreenshot(): Promise<string> {
318-
const result = await this.sendCommand('WebDriver:TakeScreenshot', {
373+
const result = await this.sendCommandWithRetry('WebDriver:TakeScreenshot', {
319374
full: true,
320375
});
321376
return this.extractValue(result) as string;

packages/functional-tests/lib/pairing-constants.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ export const PAIRING_REDIRECT_URI =
1616
export const TIMEOUTS = {
1717
ELEMENT_FIND: 15_000,
1818
ASYNC_SCRIPT: 15_000,
19-
SIGNED_IN_CHECK: 10_000,
19+
SIGNED_IN_CHECK: 15_000,
2020
SUPPLICANT_ALLOW: 30_000,
2121
AUTHORITY_COMPLETE: 15_000,
2222
POLL_INTERVAL: 500,
23+
POLL_INTERVAL_MAX: 2_000,
2324
} as const;
2425

2526
export const SELECTORS = {
@@ -30,10 +31,36 @@ export const SELECTORS = {
3031
],
3132
PASSWORD_INPUT: ['input[type="password"]', 'input[name="password"]'],
3233
SUBMIT_BUTTON: ['button[type="submit"]'],
33-
AUTHORITY_APPROVE: ['#auth-approve-btn', 'button[type="submit"]'],
34+
AUTHORITY_APPROVE: [
35+
'[data-testid="pair-auth-approve-btn"]',
36+
'#auth-approve-btn',
37+
'button[type="submit"]',
38+
],
39+
// Backbone supplicant cancel is an anchor `<a href="#" id="cancel">` that fires
40+
// a click handler calling replaceCurrentPage('pair/failure'). React uses
41+
// `<Link to="/pair/failure">` with no stable id — we match it by role/text.
42+
SUPP_CANCEL_BACKBONE: ['a#cancel'],
3443
TOTP_INPUT: [
3544
'input.totp-code',
3645
'input[name="code"]',
3746
'input[type="text"][maxlength="6"]',
3847
],
48+
// /pair index choice screen — IDs are identical between Backbone and React,
49+
// only the React templates add data-testid attributes.
50+
PAIR_CHOICE_HEADER: ['[data-testid="pair-header"]', '#pair-header'],
51+
PAIR_RADIO_HAS_MOBILE: ['[data-testid="has-mobile"]', '#has-mobile'],
52+
PAIR_RADIO_NEEDS_MOBILE: ['[data-testid="needs-mobile"]', '#needs-mobile'],
53+
PAIR_CONTINUE_BUTTON: [
54+
'[data-testid="pair-continue-btn"]',
55+
'#set-needs-mobile',
56+
],
57+
} as const;
58+
59+
// Copy shown on the /pair/failure page. Both stacks render the same wording
60+
// now (React was updated to match Backbone). The body uses a U+2019 right
61+
// single quotation mark in "couldn’t"; the `.` in the regex accepts either
62+
// a straight or curly apostrophe without hard-coding the codepoint.
63+
export const FAILURE_COPY = {
64+
heading: /Device pairing failed/i,
65+
body: /The setup couldn.t be completed/i,
3966
} as const;

0 commit comments

Comments
 (0)