-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathpageHelpers.js
More file actions
876 lines (735 loc) · 34.4 KB
/
pageHelpers.js
File metadata and controls
876 lines (735 loc) · 34.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
const {expect} = require('@playwright/test')
const config = require('../config')
const {getCreditCardExpiry, runAccessibilityTest} = require('../scripts/utils.js')
const crypto = require('crypto')
/**
* Note: As a best practice, we should await the network call and assert on the network response rather than waiting for pageLoadState()
* to avoid race conditions from lock in pageLoadState being released before network call resolves.
*
* This is a best practice for tests that are dependent on the network call. Eg.: Shopper login, registration, etc.
*/
/**
* Give an answer to the consent tracking form.
*
* Note: the consent tracking form hovers over some elements in the app. This can cause a test to fail.
* Run this function after a page.goto to release the form from view.
*
* @param {Object} page - Object that represents a tab/window in the browser provided by playwright
* @param {Boolean} dnt - Do Not Track value to answer the form. False to enable tracking, True to disable tracking.
*/
export const answerConsentTrackingForm = async (page, dnt = false) => {
try {
const consentFormVisible = await page
.locator('text=Tracking Consent')
.isVisible()
.catch(() => false)
if (!consentFormVisible) {
return
}
const buttonText = dnt ? 'Decline' : 'Accept'
await page
.getByRole('button', {name: new RegExp(buttonText, 'i')})
.first()
.waitFor({timeout: 3000})
// Find and click consent buttons (handles both mobile and desktop versions existing in the DOM)
const clickSuccess = await page.evaluate((targetText) => {
// Try aria-label first, then fallback to text content
let buttons = Array.from(
document.querySelectorAll(`button[aria-label="${targetText} tracking"]`)
)
if (buttons.length === 0) {
buttons = Array.from(document.querySelectorAll('button')).filter(
(btn) =>
btn.textContent &&
btn.textContent.trim().toLowerCase() === targetText.toLowerCase()
)
}
let clickedCount = 0
buttons.forEach((button) => {
// Only click visible buttons
if (button.offsetParent !== null) {
button.click()
clickedCount++
}
})
return clickedCount
}, buttonText)
// after clicking an answering button, the tracking consent should not stay in the DOM
if (clickSuccess > 0) {
await page.waitForTimeout(2000)
await page
.locator('text=Tracking Consent')
.isHidden({timeout: 5000})
.catch(() => {})
}
} catch (error) {
// Silently continue - consent form handling should not break tests
}
}
/**
* Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on mobile
* with the black variant selected
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
*/
export const navigateToPDPMobile = async ({page}) => {
// Home page
await page.goto(config.RETAIL_APP_HOME)
await answerConsentTrackingForm(page)
await page.getByLabel('Menu', {exact: true}).click()
// SSR nav loads top level categories as direct links so we wait till all sub-categories load in the accordion
const categoryAccordion = page.locator(
"#category-nav .chakra-accordion__button svg+:text('Womens')"
)
await categoryAccordion.waitFor()
await page.getByRole('button', {name: 'Womens'}).click()
const clothingNav = page.getByRole('button', {name: 'Clothing'})
await clothingNav.waitFor()
await clothingNav.click()
const topsLink = page.getByLabel('Womens').getByRole('link', {name: 'Tops'})
await topsLink.click()
// Wait for the nav menu to close first
await topsLink.waitFor({state: 'hidden'})
await expect(page.getByRole('heading', {name: 'Tops'})).toBeVisible()
// PLP
const productTile = page.getByRole('link', {
name: /Cotton Turtleneck Sweater/i
})
await productTile.scrollIntoViewIfNeeded()
// selecting swatch
const productTileImg = productTile.locator('img')
await productTileImg.waitFor({state: 'visible'})
const initialSrc = await productTileImg.getAttribute('src')
await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible()
await productTile.getByLabel(/Black/, {exact: true}).click()
// Make sure the image src has changed
await expect(async () => {
const newSrc = await productTileImg.getAttribute('src')
expect(newSrc).not.toBe(initialSrc)
}).toPass()
await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible()
await productTile.click()
}
/**
* Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on Desktop
* with the black variant selected.
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
*/
export const navigateToPDPDesktop = async ({page}) => {
await page.goto(config.RETAIL_APP_HOME)
await answerConsentTrackingForm(page)
await page.getByRole('link', {name: 'Womens'}).hover()
const topsNav = await page.getByRole('link', {name: 'Tops', exact: true})
await expect(topsNav).toBeVisible()
await topsNav.click()
// PLP
const productTile = page.getByRole('link', {
name: /Cotton Turtleneck Sweater/i
})
// selecting swatch
const productTileImg = productTile.locator('img')
await productTileImg.waitFor({state: 'visible'})
const initialSrc = await productTileImg.getAttribute('src')
await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible()
await productTile.getByLabel(/Black/, {exact: true}).hover()
// Make sure the image src has changed
await expect(async () => {
const newSrc = await productTileImg.getAttribute('src')
expect(newSrc).not.toBe(initialSrc)
}).toPass()
await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible()
await productTile.click()
}
/**
* Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on Desktop
* with the black variant selected.
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
*/
export const navigateToPDPDesktopSocial = async ({
page,
productName,
productColor,
productPrice
}) => {
await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME)
await answerConsentTrackingForm(page)
await page.getByRole('link', {name: 'Womens'}).hover()
const topsNav = await page.getByRole('link', {name: 'Tops', exact: true})
await expect(topsNav).toBeVisible()
await topsNav.click()
// PLP
const productTile = page.getByRole('link', {
name: RegExp(productName, 'i')
})
// selecting swatch
const productTileImg = productTile.locator('img')
await productTileImg.waitFor({state: 'visible'})
await expect(productTile.getByText(RegExp(`From \\${productPrice}`, 'i'))).toBeVisible()
await productTile.getByLabel(RegExp(productColor, 'i'), {exact: true}).hover()
await productTile.click()
}
/**
* Adds the `Cotton Turtleneck Sweater` product to the cart with the variant:
* Color: Black
* Size: L
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @param {Boolean} options.isMobile - Flag to indicate if device type is mobile or not, defaulted to false
*/
export const addProductToCart = async ({page, isMobile = false}) => {
// Navigate to Cotton Turtleneck Sweater with Black color variant selected
if (isMobile) {
await navigateToPDPMobile({page})
} else {
await navigateToPDPDesktop({page})
}
// PDP
await expect(page.getByRole('heading', {name: /Cotton Turtleneck Sweater/i})).toBeVisible()
await page.getByRole('radio', {name: 'L', exact: true}).click()
await page.locator("button[data-testid='quantity-increment']").click()
// Selected Size and Color texts are broken into multiple elements on the page.
// So we need to look at the page URL to verify selected variants
const updatedPageURL = await page.url()
const params = updatedPageURL.split('?')[1]
expect(params).toMatch(/size=9LG/i)
expect(params).toMatch(/color=JJ169XX/i)
await page.getByRole('button', {name: /Add to Cart/i}).click()
const addedToCartModal = page.getByText(/2 items added to cart/i)
await addedToCartModal.waitFor()
await page.getByLabel('Close').click()
}
/**
* Registers a shopper with provided user credentials
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @param {Object} options.userCredentials - Object containing user credentials with the following properties:
* - firstName
* - lastName
* - email
* - password
* @param {Boolean} options.isMobile - flag to indicate if device type is mobile or not, defaulted to false
*/
export const registerShopper = async ({page, userCredentials}) => {
// Create Account and Sign In
await page.goto(config.RETAIL_APP_HOME + '/registration')
await answerConsentTrackingForm(page)
await page.waitForLoadState()
// Skip registration if user is already logged in
const initialUrl = page.url()
if (initialUrl.includes('/account')) {
return
}
const registrationFormHeading = page.getByText(/Let's get started!/i)
try {
await registrationFormHeading.waitFor({timeout: 10000})
} catch (error) {
// Check if user was redirected to account page during wait
const urlAfterWait = page.url()
if (urlAfterWait.includes('/account')) {
return
}
throw new Error(`Registration form not found. Current URL: ${urlAfterWait}`)
}
await page.locator('input#firstName').fill(userCredentials.firstName)
await page.locator('input#lastName').fill(userCredentials.lastName)
await page.locator('input#email').fill(userCredentials.email)
await page.locator('input#password').fill(userCredentials.password)
// Best Practice: await the network call and assert on the network response rather than waiting for pageLoadState()
// to avoid race conditions from lock in pageLoadState being released before network call resolves
const tokenResponsePromise = page.waitForResponse(
'**/shopper/auth/v1/organizations/**/oauth2/token'
)
await page.getByRole('button', {name: /Create Account/i}).click()
const tokenResponse = await tokenResponsePromise
expect(tokenResponse.status()).toBe(200)
await page.waitForURL(/.*\/account.*/, {timeout: 10000})
await expect(page.getByText(userCredentials.email)).toBeVisible()
}
/**
* Validates that the `Cotton Turtleneck Sweater` product appears in the Order History page
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
*/
export const validateOrderHistory = async ({page, a11y = {}}) => {
const {checkA11y = false, snapShotName} = a11y
await page.goto(config.RETAIL_APP_HOME + '/account/orders')
await answerConsentTrackingForm(page)
await expect(page.getByRole('heading', {name: /Order History/i})).toBeVisible()
await page.getByRole('link', {name: 'View details'}).click()
await expect(page.getByRole('heading', {name: /Order Details/i})).toBeVisible()
await expect(page.getByRole('heading', {name: /Cotton Turtleneck Sweater/i})).toBeVisible()
await expect(page.getByText(/Color: Black/i)).toBeVisible()
await expect(page.getByText(/Size: L/i)).toBeVisible()
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'order-history-a11y-violations.json'])
}
}
/**
* Validates that the `Cotton Turtleneck Sweater` product appears in the Wishlist page
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
*/
export const validateWishlist = async ({page, a11y = {}}) => {
const {checkA11y = false, snapShotName} = a11y
await page.goto(config.RETAIL_APP_HOME + '/account/wishlist')
await answerConsentTrackingForm(page)
await expect(page.getByRole('heading', {name: /Wishlist/i})).toBeVisible()
await expect(page.getByRole('heading', {name: /Cotton Turtleneck Sweater/i})).toBeVisible()
await expect(page.getByText(/Color: Black/i)).toBeVisible()
await expect(page.getByText(/Size: L/i)).toBeVisible()
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'wishlist-violations.json'])
}
}
/**
* Attempts to log in a shopper with provided user credentials.
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @param {Object} options.userCredentials - Object containing user credentials with the following properties:
* - firstName
* - lastName
* - email
* - password
*
* @return {Boolean} - denotes whether or not login was successful
*/
export const loginShopper = async ({page, userCredentials}) => {
try {
await page.goto(config.RETAIL_APP_HOME + '/login')
await answerConsentTrackingForm(page)
await page.locator('input#email').fill(userCredentials.email)
await page.locator('input#password').fill(userCredentials.password)
const loginResponsePromise = page.waitForResponse(
'**/shopper/auth/v1/organizations/**/oauth2/login'
)
const tokenResponsePromise = page.waitForResponse(
'**/shopper/auth/v1/organizations/**/oauth2/token'
)
await page.getByRole('button', {name: /Sign In/i}).click()
const loginResponse = await loginResponsePromise
expect(loginResponse.status()).toBe(303) // Login returns a 303 redirect to /callback with authCode and usid
const tokenResponse = await tokenResponsePromise
expect(tokenResponse.status()).toBe(200)
await page.waitForURL(/.*\/account.*/, {timeout: 10000})
await expect(page.getByText(userCredentials.email)).toBeVisible()
return true
} catch (error) {
return false
}
}
/**
* Attempts to log in a shopper with provided user credentials.
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @return {Boolean} - denotes whether or not login was successful
*/
export const socialLoginShopper = async ({page}) => {
try {
await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login')
await page.getByText(/Google/i).click()
await expect(page.getByText(/Sign in with Google/i)).toBeVisible({timeout: 10000})
await page.waitForSelector('input[type="email"]')
// Fill in the email input
await page.fill('input[type="email"]', config.PWA_E2E_USER_EMAIL)
await page.click('#identifierNext')
await page.waitForSelector('input[type="password"]')
// Fill in the password input
await page.fill('input[type="password"]', config.PWA_E2E_USER_PASSWORD)
await page.click('#passwordNext')
await page.waitForLoadState()
await expect(page.getByRole('heading', {name: /Account Details/i})).toBeVisible({
timeout: 20000
})
await expect(page.getByText(/e2e.pwa.kit@gmail.com/i)).toBeVisible()
// Password card should be hidden for social login user
await expect(page.getByRole('heading', {name: /Password/i})).toBeHidden()
return true
} catch {
return false
}
}
/**
* Search for products by query string that takes you to the PLP
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @param {String} options.query - Product name other product related descriptors to search for
* @param {Object} options.isMobile - Flag to indicate if device type is mobile or not, defaulted to false
*/
export const searchProduct = async ({page, query, isMobile = false}) => {
await page.goto(config.RETAIL_APP_HOME)
await answerConsentTrackingForm(page)
// For accessibility reasons, we have two search bars
// one for desktop and one for mobile depending on your device type
const searchInputs = page.locator('input[aria-label="Search for products..."]')
let searchInput = isMobile ? searchInputs.nth(1) : searchInputs.nth(0)
await searchInput.fill(query)
await page.waitForTimeout(1000)
await searchInput.press('Enter')
await page.waitForLoadState()
}
/**
* Checkout products that are in the cart
*
* @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright
* @param {Object} options.userCredentials - Object containing user credentials with the following properties:
* - firstName
* - lastName
* - email
* - password
*/
export const checkoutProduct = async ({page, userCredentials, a11y = {checkA11y: false}}) => {
const {checkA11y, snapShotName} = a11y
await page.getByRole('link', {name: 'Proceed to Checkout'}).click()
await expect(page.getByRole('heading', {name: /Contact Info/i})).toBeVisible()
await page.locator('input#email').fill('test@gmail.com')
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-0.json'])
}
await page.getByRole('button', {name: /Checkout as guest/i}).click()
// Confirm the email input toggles to show edit button on clicking "Checkout as guest"
const step0Card = page.locator("div[data-testid='sf-toggle-card-step-0']")
await expect(step0Card.getByRole('button', {name: /Edit/i})).toBeVisible()
await expect(page.getByRole('heading', {name: /Shipping Address/i})).toBeVisible()
await page.locator('input#firstName').fill(userCredentials.firstName)
await page.locator('input#lastName').fill(userCredentials.lastName)
await page.locator('input#phone').fill(userCredentials.phone)
await page.locator('input#address1').fill(userCredentials.address.street)
await page.locator('input#city').fill(userCredentials.address.city)
await page.locator('select#stateCode').selectOption(userCredentials.address.state)
await page.locator('input#postalCode').fill(userCredentials.address.zipcode)
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-1.json'])
}
await page.getByRole('button', {name: /Continue to Shipping Method/i}).click()
// Confirm the shipping details form toggles to show edit button on clicking "Checkout as guest"
const step1Card = page.locator("div[data-testid='sf-toggle-card-step-1']")
await expect(step1Card.getByRole('button', {name: /Edit/i})).toBeVisible()
await expect(page.getByRole('heading', {name: /Shipping & Gift Options/i})).toBeVisible()
try {
// sometimes the shipping & gifts section gets skipped
// so there is no 'Continue to payment' button available
const continueToPayment = page.getByRole('button', {
name: /Continue to Payment/i
})
await expect(continueToPayment).toBeVisible({timeout: 2000})
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-2.json'])
}
await continueToPayment.click()
} catch (error) {
// Silently continue - consent form handling should not break tests
}
await expect(page.getByRole('heading', {name: /Payment/i})).toBeVisible()
const creditCardExpiry = getCreditCardExpiry()
await page.locator('input#number').fill('4111111111111111')
await page.locator('input#holder').fill('John Doe')
await page.locator('input#expiry').fill(creditCardExpiry)
await page.locator('input#securityCode').fill('213')
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-3.json'])
}
await page.getByRole('button', {name: /Review Order/i}).click()
page.getByRole('button', {name: /Place Order/i})
.first()
.click()
// order confirmation
const orderConfirmationHeading = page.getByRole('heading', {
name: /Thank you for your order!/i
})
if (checkA11y) {
await runAccessibilityTest(page, [
snapShotName,
'checkout-a11y-violations-step-4-order-confirmation.json'
])
}
await orderConfirmationHeading.waitFor()
}
export const registeredUserHappyPath = async ({page, registeredUserCredentials, a11y = {}}) => {
const {checkA11y = false, snapShotName} = a11y
// Since we're re-using the same account, we need to check if the user is already registered.
// This ensures the tests are independent and not dependent on the order they are run in.
const isLoggedIn = await loginShopper({
page,
userCredentials: registeredUserCredentials
})
if (!isLoggedIn) {
await registerShopper({
page,
userCredentials: registeredUserCredentials
})
}
await answerConsentTrackingForm(page)
await page.waitForLoadState()
// Verify we're on account page and user is logged in
const currentUrl = page.url()
expect(currentUrl).toMatch(/\/account/)
await expect(page.getByText(registeredUserCredentials.email)).toBeVisible()
// Shop for items as registered user
await addProductToCart({page})
// cart
await page.getByLabel(/My cart/i).click()
await expect(page.getByRole('link', {name: /Cotton Turtleneck Sweater/i})).toBeVisible()
await page.getByRole('link', {name: 'Proceed to Checkout'}).click()
// Confirm the email input toggles to show sign out button on clicking "Checkout as guest"
const step0Card = page.locator("div[data-testid='sf-toggle-card-step-0']")
await expect(step0Card.getByRole('button', {name: /Sign Out/i})).toBeVisible()
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-0.json'])
}
await expect(page.getByRole('heading', {name: /Shipping Address/i})).toBeVisible()
await page.locator('input#firstName').fill(registeredUserCredentials.firstName)
await page.locator('input#lastName').fill(registeredUserCredentials.lastName)
await page.locator('input#phone').fill(registeredUserCredentials.phone)
await page.locator('input#address1').fill(registeredUserCredentials.address.street)
await page.locator('input#city').fill(registeredUserCredentials.address.city)
await page.locator('select#stateCode').selectOption(registeredUserCredentials.address.state)
await page.locator('input#postalCode').fill(registeredUserCredentials.address.zipcode)
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-1.json'])
}
await page.getByRole('button', {name: /Continue to Shipping Method/i}).click()
// Confirm the shipping details form toggles to show edit button on clicking "Checkout as guest"
const step1Card = page.locator("div[data-testid='sf-toggle-card-step-1']")
await expect(step1Card.getByRole('button', {name: /Edit Shipping Address/i})).toBeVisible()
await expect(page.getByRole('heading', {name: /Shipping & Gift Options/i})).toBeVisible()
await page.waitForLoadState()
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-2.json'])
}
const continueToPayment = page.getByRole('button', {name: /Continue to Payment/i})
// If the Continue to Payment button is not visible, the payment details form is already being shown, so we can skip this step.
if ((await continueToPayment.count()) > 0 && (await continueToPayment.isEnabled())) {
await continueToPayment.click()
}
const step2Card = page.locator("div[data-testid='sf-toggle-card-step-2']")
await expect(step2Card.getByRole('button', {name: /Edit Shipping Options/i})).toBeVisible()
await expect(page.getByRole('heading', {name: /Payment/i})).toBeVisible()
const creditCardExpiry = getCreditCardExpiry()
await page.locator('input#number').fill('4111111111111111')
await page.locator('input#holder').fill('John Doe')
await page.locator('input#expiry').fill(creditCardExpiry)
await page.locator('input#securityCode').fill('213')
if (checkA11y) {
await runAccessibilityTest(page, [snapShotName, 'checkout-a11y-violations-step-3.json'])
}
await page.getByRole('button', {name: /Review Order/i}).click()
const step3Card = page.locator("div[data-testid='sf-toggle-card-step-3']")
await expect(step3Card.getByRole('button', {name: /Edit Payment Info/i})).toBeVisible()
page.getByRole('button', {name: /Place Order/i})
.first()
.click()
const orderConfirmationHeading = page.getByRole('heading', {
name: /Thank you for your order!/i
})
await orderConfirmationHeading.waitFor()
await expect(page.getByRole('heading', {name: /Order Summary/i})).toBeVisible()
await expect(page.getByText(/2 Items/i)).toBeVisible()
await expect(page.getByRole('link', {name: /Cotton Turtleneck Sweater/i})).toBeVisible()
if (checkA11y) {
await runAccessibilityTest(page, [
'registered',
'checkout-a11y-violations-step-4-order-confirmation.json'
])
}
// order history
await validateOrderHistory({page, a11y})
}
/**
* Executes the wishlist flow for a registered user.
*
* Includes robust authentication handling with fallback mechanisms.
*
* @param {Object} options.page - Playwright page object representing a browser tab/window
* @param {Object} options.registeredUserCredentials - User credentials for authentication
* @param {Object} options.a11y - Accessibility testing configuration (optional)
*/
export const wishlistFlow = async ({page, registeredUserCredentials, a11y = {}}) => {
const isLoggedIn = await loginShopper({
page,
userCredentials: registeredUserCredentials
})
if (!isLoggedIn) {
try {
await registerShopper({
page,
userCredentials: registeredUserCredentials
})
} catch (error) {
// If registration fails attempt to log in
const secondLoginAttempt = await loginShopper({
page,
userCredentials: registeredUserCredentials
})
if (!secondLoginAttempt) {
throw new Error('Authentication failed: Both login and registration unsuccessful')
}
}
}
// The consent form does not stick after registration
await answerConsentTrackingForm(page)
await page.waitForLoadState()
const currentUrl = page.url()
if (!currentUrl.includes('/account')) {
await page.goto(config.RETAIL_APP_HOME + '/account')
await page.waitForLoadState()
}
// Navigate to PDP
await navigateToPDPDesktop({page})
// add product to wishlist
await expect(page.getByRole('heading', {name: /Cotton Turtleneck Sweater/i})).toBeVisible()
await page.getByRole('radio', {name: 'L', exact: true}).click()
await page.getByRole('button', {name: /Add to Wishlist/i}).click()
// wishlist
await validateWishlist({page, a11y})
}
/**
* Navigates to a PLP and opens the store inventory filter to select a store.
*
* This helper function demonstrates the store inventory filtering functionality by:
* 1. Navigating to the Womens > Tops category PLP
* 2. Opening the store locator modal
* 3. Searching for stores by postal code
* 4. Returning the available store selection options
*
* This is useful for testing store inventory features and BOPIS (Buy Online, Pick Up In Store) functionality.
*
* @param {Object} options.page - Playwright page object representing a browser tab/window
*/
export const selectStoreFromPLP = async ({page}) => {
// Navigate to a product category (Womens > Tops)
await page.getByRole('link', {name: 'Womens'}).hover()
const topsNav = await page.getByRole('link', {name: 'Tops', exact: true})
await expect(topsNav).toBeVisible()
await topsNav.click()
// Verify we're on the PLP
await expect(page.getByRole('heading', {name: 'Tops'})).toBeVisible()
const productTile = page.getByRole('link', {
name: /Cotton Turtleneck Sweater/i
})
const productTileImg = productTile.locator('img')
await productTileImg.waitFor({state: 'visible'})
// Look for the store inventory filter component
const storeInventoryFilter = page.getByTestId('sf-store-inventory-filter')
await expect(storeInventoryFilter).toBeVisible()
// Verify the filter shows "Select Store" initially
await expect(page.getByText('Select Store')).toBeVisible()
await expect(page.getByText('Shop by Availability')).toBeVisible()
// Click on the store inventory filter checkbox to open store locator
const inventoryCheckbox = page.getByTestId('sf-store-inventory-filter-checkbox')
await inventoryCheckbox.click()
// Verify store locator modal opens and select a store
await expect(page.getByText('Find a Store')).toBeVisible()
await page.locator('select[name="countryCode"]').selectOption({label: 'United States'})
await page.locator('input[name="postalCode"]').fill('01803')
const searchStoreButton = page.getByRole('button', {name: 'Find'})
await expect(searchStoreButton).toBeVisible()
const storeSearchResponsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/shopper-stores/v1/organizations/') &&
resp.url().includes('/store-search')
)
await searchStoreButton.click()
const storeSearchResponse = await storeSearchResponsePromise
expect(storeSearchResponse.status()).toBe(200)
// Select the first available store (if any stores are available)
await expect(page.getByText(/Burlington Retail Store/i)).toBeVisible()
// Find and click the first available store label
const storeRadioLabels = page.locator(
'label.chakra-radio:has(input[aria-describedby^="store-info-"])'
)
const storeCount = await storeRadioLabels.count()
if (storeCount > 0) {
// Select the first store
await storeRadioLabels.first().click()
// Close the store locator modal
await page.locator('button[aria-label="Close"]').click()
await page.waitForLoadState()
await expect(page.getByText('Find a Store')).not.toBeVisible()
} else {
// If no stores are available, verify the appropriate message is shown
await expect(page.getByText('Sorry, there are no locations in this area.')).toBeVisible()
// Close the modal
await page.getByRole('button', {name: 'Close'}).click()
}
}
/**
* Validates that a passkey login request is made to the /webAuthn/authenticate/finish endpoint.
* We can't register an actual passkey in the E2E environment because registration requires a token verification.
* Instead,we add a mock credential to the virtual authenticator to bypass the registration flow and verify the
* request to the /webAuthn/authenticate/finish endpoint.
*
* @param {Object} options.page - Playwright page object representing a browser tab/window
*/
export const validatePasskeyLogin = async ({page}) => {
// Start a CDP session to interact with WebAuthn
const client = await page.context().newCDPSession(page)
await client.send('WebAuthn.enable')
// Create a virtual authenticator to simulate a hardware authenticator for testing
const {authenticatorId} = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
// Enabling automaticPresenceSimulation automatically completes the device's passkey prompt without user interaction
automaticPresenceSimulation: true
}
})
// Preload mock credential into the virtual authenticator
const rpId = new URL(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME).hostname
// Generate a valid EC key pair for WebAuthn (ES256/P-256)
const {privateKey} = crypto.generateKeyPairSync('ec', {namedCurve: 'P-256'})
const privateKeyBase64 = privateKey.export({format: 'der', type: 'pkcs8'}).toString('base64')
console.log('privateKeyBase64', privateKeyBase64)
const credentialIdBuffer = Buffer.from('mock-credential-id-' + Date.now())
const credentialIdBase64 = credentialIdBuffer.toString('base64') // For mock credential
const credentialId = credentialIdBuffer.toString('base64url') // For verifying the request
await client.send('WebAuthn.addCredential', {
authenticatorId,
credential: {
credentialId: credentialIdBase64,
isResidentCredential: true,
rpId,
privateKey: privateKeyBase64,
userHandle: Buffer.from('test-user-handle').toString('base64'),
signCount: 0,
transports: ['internal']
}
})
let interceptedRequest = null
// Intercept the WebAuthn authenticate/finish endpoint to verify the request
await page.route(
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/webauthn/authenticate/finish',
(route) => {
interceptedRequest = route.request()
route.continue()
}
)
await page.goto(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME + '/login')
// Wait for the WebAuthn authenticate/finish request
await page.waitForResponse(
'**/mobify/slas/private/shopper/auth/v1/organizations/*/oauth2/webauthn/authenticate/finish'
)
// Verify the /webAuthn/authenticate/finish request
expect(interceptedRequest).toBeTruthy()
expect(interceptedRequest.method()).toBe('POST')
const postData = interceptedRequest.postData()
expect(postData).toBeTruthy()
const requestBody = JSON.parse(postData)
expect(requestBody).toBeTruthy()
// Verify the request body structure matches expected format
expect(requestBody.client_id).toBeTruthy()
expect(requestBody.channel_id).toBe(config.EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE)
expect(requestBody.credential.id).toBe(credentialId)
expect(requestBody.credential.clientExtensionResults).toBeTruthy()
expect(requestBody.credential.response).toBeTruthy()
}