|
1 | | -import type {Page, BrowserContext} from '@playwright/test'; |
| 1 | +import type {Page, BrowserContext, Locator} from '@playwright/test'; |
2 | 2 | import {expect} from '@playwright/test'; |
3 | 3 |
|
4 | 4 | // Privacy Banner element IDs |
@@ -804,6 +804,179 @@ export class StorefrontPage { |
804 | 804 | ]); |
805 | 805 | } |
806 | 806 |
|
| 807 | + // ============================================================================ |
| 808 | + // Cart Line Item Helpers |
| 809 | + // ============================================================================ |
| 810 | + |
| 811 | + /** |
| 812 | + * Returns all visible cart line items. Uses visibility filter to avoid |
| 813 | + * matching hidden elements in the cart drawer when on the cart page. |
| 814 | + */ |
| 815 | + getCartLineItems(): Locator { |
| 816 | + return this.page.locator('li.cart-line:visible'); |
| 817 | + } |
| 818 | + |
| 819 | + /** |
| 820 | + * Returns a specific visible cart line item by index. |
| 821 | + */ |
| 822 | + getCartLineItemByIndex(index: number): Locator { |
| 823 | + return this.page.locator('li.cart-line:visible').nth(index); |
| 824 | + } |
| 825 | + |
| 826 | + /** |
| 827 | + * Returns the cart drawer dialog (uses semantic role selector). |
| 828 | + */ |
| 829 | + getCartDrawer(): Locator { |
| 830 | + return this.page.getByRole('dialog'); |
| 831 | + } |
| 832 | + |
| 833 | + /** |
| 834 | + * Returns the visible empty cart message (uses regex to handle curly apostrophe U+2019). |
| 835 | + * Scoped to visible .cart-main to avoid matching hidden drawer content. |
| 836 | + */ |
| 837 | + getCartEmptyMessage(): Locator { |
| 838 | + return this.page |
| 839 | + .locator('.cart-main:visible') |
| 840 | + .getByText(/Looks like you haven.t added anything yet/); |
| 841 | + } |
| 842 | + |
| 843 | + /** |
| 844 | + * Returns the checkout button/link. |
| 845 | + */ |
| 846 | + getCheckoutButton(): Locator { |
| 847 | + return this.page.getByRole('link', {name: /Continue to Checkout/i}); |
| 848 | + } |
| 849 | + |
| 850 | + /** |
| 851 | + * Opens the cart aside drawer by clicking the cart badge in the header. |
| 852 | + */ |
| 853 | + async openCartAside(): Promise<void> { |
| 854 | + const cartLink = this.page |
| 855 | + .getByRole('banner') |
| 856 | + .getByRole('link', {name: /cart/i}); |
| 857 | + await cartLink.click(); |
| 858 | + await expect(this.getCartDrawer()).toBeVisible({timeout: 5000}); |
| 859 | + } |
| 860 | + |
| 861 | + /** |
| 862 | + * Closes the cart aside drawer. |
| 863 | + */ |
| 864 | + async closeCartAside(): Promise<void> { |
| 865 | + const closeButton = this.getCartDrawer().getByRole('button', { |
| 866 | + name: 'Close', |
| 867 | + }); |
| 868 | + if (await closeButton.isVisible()) { |
| 869 | + await closeButton.click(); |
| 870 | + await expect(this.getCartDrawer()).not.toBeVisible(); |
| 871 | + } |
| 872 | + } |
| 873 | + |
| 874 | + /** |
| 875 | + * Gets the cart badge count from the header. |
| 876 | + */ |
| 877 | + async getCartBadgeCount(): Promise<number> { |
| 878 | + const cartLink = this.page |
| 879 | + .getByRole('banner') |
| 880 | + .getByRole('link', {name: /cart/i}); |
| 881 | + const text = await cartLink.textContent(); |
| 882 | + const match = text?.match(/\d+/); |
| 883 | + return match ? parseInt(match[0], 10) : 0; |
| 884 | + } |
| 885 | + |
| 886 | + /** |
| 887 | + * Extracts the quantity number from a line item. |
| 888 | + */ |
| 889 | + async getLineItemQuantity(lineItem: Locator): Promise<number> { |
| 890 | + const quantityText = await lineItem |
| 891 | + .locator('small') |
| 892 | + .filter({hasText: 'Quantity:'}) |
| 893 | + .textContent(); |
| 894 | + const match = quantityText?.match(/Quantity:\s*(\d+)/); |
| 895 | + return match ? parseInt(match[1], 10) : 0; |
| 896 | + } |
| 897 | + |
| 898 | + /** |
| 899 | + * Increases quantity and waits for the quantity text to actually change. |
| 900 | + * Uses expect.poll to wait for the effect, not just button re-enablement. |
| 901 | + */ |
| 902 | + async increaseLineItemQuantity(lineItem: Locator): Promise<void> { |
| 903 | + const currentQuantity = await this.getLineItemQuantity(lineItem); |
| 904 | + const increaseButton = lineItem.getByRole('button', { |
| 905 | + name: 'Increase quantity', |
| 906 | + }); |
| 907 | + |
| 908 | + await expect(increaseButton).toBeEnabled({timeout: 5000}); |
| 909 | + await increaseButton.click(); |
| 910 | + |
| 911 | + await expect |
| 912 | + .poll(() => this.getLineItemQuantity(lineItem), {timeout: 10000}) |
| 913 | + .toBe(currentQuantity + 1); |
| 914 | + } |
| 915 | + |
| 916 | + /** |
| 917 | + * Decreases quantity and waits for the quantity text to actually change. |
| 918 | + */ |
| 919 | + async decreaseLineItemQuantity(lineItem: Locator): Promise<void> { |
| 920 | + const currentQuantity = await this.getLineItemQuantity(lineItem); |
| 921 | + const decreaseButton = lineItem.getByRole('button', { |
| 922 | + name: 'Decrease quantity', |
| 923 | + }); |
| 924 | + |
| 925 | + await expect(decreaseButton).toBeEnabled({timeout: 5000}); |
| 926 | + await decreaseButton.click(); |
| 927 | + |
| 928 | + await expect |
| 929 | + .poll(() => this.getLineItemQuantity(lineItem), {timeout: 10000}) |
| 930 | + .toBe(currentQuantity - 1); |
| 931 | + } |
| 932 | + |
| 933 | + /** |
| 934 | + * Removes a line item and waits for it to disappear. |
| 935 | + */ |
| 936 | + async removeLineItem(lineItem: Locator): Promise<void> { |
| 937 | + const removeButton = lineItem.getByRole('button', {name: 'Remove'}); |
| 938 | + await expect(removeButton).toBeEnabled({timeout: 5000}); |
| 939 | + await removeButton.click(); |
| 940 | + await expect(lineItem).not.toBeVisible({timeout: 10000}); |
| 941 | + } |
| 942 | + |
| 943 | + /** |
| 944 | + * Extracts subtotal as a number (removes currency formatting). |
| 945 | + * Handles multiple currency formats like "$1,234.56" or "1.234,56 €". |
| 946 | + * Uses :visible to match only the current context (drawer or page). |
| 947 | + */ |
| 948 | + async getSubtotalAmount(): Promise<number> { |
| 949 | + const subtotalElement = this.page.locator('.cart-subtotal:visible dd'); |
| 950 | + const text = await subtotalElement.textContent(); |
| 951 | + if (!text) return 0; |
| 952 | + |
| 953 | + // For US format ($1,234.56): remove commas, keep decimal point |
| 954 | + // For EU format (1.234,56 €): swap comma/dot |
| 955 | + const cleaned = text.replace(/[^\d.,]/g, ''); |
| 956 | + // Detect format: if last separator is comma, it's EU format |
| 957 | + const lastCommaIndex = cleaned.lastIndexOf(','); |
| 958 | + const lastDotIndex = cleaned.lastIndexOf('.'); |
| 959 | + |
| 960 | + let numericString: string; |
| 961 | + if (lastCommaIndex > lastDotIndex) { |
| 962 | + // EU format: 1.234,56 -> 1234.56 |
| 963 | + numericString = cleaned.replace(/\./g, '').replace(',', '.'); |
| 964 | + } else { |
| 965 | + // US format: 1,234.56 -> 1234.56 |
| 966 | + numericString = cleaned.replace(/,/g, ''); |
| 967 | + } |
| 968 | + |
| 969 | + return parseFloat(numericString) || 0; |
| 970 | + } |
| 971 | + |
| 972 | + /** |
| 973 | + * Clears all cookies to reset cart and session state. |
| 974 | + * Cart state is stored in a cookie, so clearing cookies resets the cart. |
| 975 | + */ |
| 976 | + async clearAllCookies(): Promise<void> { |
| 977 | + await this.context.clearCookies(); |
| 978 | + } |
| 979 | + |
807 | 980 | /** |
808 | 981 | * Set the `withPrivacyBanner` value by intercepting the Hydrogen JS bundle. |
809 | 982 | * This injects code to directly set the value before Hydrogen's default check. |
|
0 commit comments