Skip to content

Commit 29d5df6

Browse files
authored
Merge pull request #20126 from mozilla/react-pair-conversion
feat(fxa-settings): implement React pairing pages with choice screen, Glean parity, and E2E tests
2 parents b20513e + 7fd0cf7 commit 29d5df6

87 files changed

Lines changed: 6920 additions & 1564 deletions

File tree

Some content is hidden

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

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)