diff --git a/.cursor/qa/rules/packages/template-retail-react-app/testing/retail-react-app-test-patterns.mdc b/.cursor/qa/rules/packages/template-retail-react-app/testing/retail-react-app-test-patterns.mdc new file mode 100644 index 0000000000..f0134fb7df --- /dev/null +++ b/.cursor/qa/rules/packages/template-retail-react-app/testing/retail-react-app-test-patterns.mdc @@ -0,0 +1,59 @@ +--- +description: QA Tests for Template Retail React App Test Generation Rules +globs: ["packages/template-retail-react-app/app/components/**/*-underTest.test.{js,jsx}"] +alwaysApply: false +--- +# QA Tests for Template Retail React App Test Patterns + +## Purpose +This file contains test cases to verify that the `unit-tests-template-retail-react-app.mdc` rule effectively guides the creation of consistent and robust React component tests. + +## Test Cases + +### Test 1: DrawerMenu Component Test Generation +**Workflow:** Generate → Analyze → Cleanup (NO test execution) + +**Steps:** +1. **Generate:** Create new test file `drawer-menu-underTest.test.js` (colocated with existing drawer-menu component at `packages/template-retail-react-app/app/components/drawer-menu/`) +2. **Apply Rules:** Use `@/testing` (located at `.cursor/rules/testing/unit-tests-generic.mdc` and `.cursor/rules/testing/unit-tests-template-retail-react-app.mdc`) +3. **Prompt:** "Write unit test for drawer-menu component" +4. **Analyze:** Perform static code analysis against verification patterns (see below) +5. **Cleanup:** Delete the generated test file after validation + +**Important:** DO NOT run the test after creation - skip test execution entirely + +**Verify that the newly generated test file follows these patterns:** +1. Test Setup + - Uses `renderWithProviders` from `@salesforce/retail-react-app/app/utils/test-utils` + - Gets user events from return value: `const {user} = renderWithProviders(...)` + - Includes `beforeEach(() => jest.clearAllMocks())` + +2. Import Structure + - Does NOT import `userEvent` directly + - Uses existing mock data from `@salesforce/retail-react-app/app/mocks/` + - Imports `screen` from `@testing-library/react` + +3. Test Organization + - Uses `describe` block with component name + - Individual `test` or `it` blocks for different scenarios + - Async/await patterns for user interactions + +4. API Mocking + - Uses `prependHandlersToServer` or `msw` for API mocking when needed + +**Failure Indicators:** +- Direct import of `userEvent` from `@testing-library/user-event` +- Using custom render function instead of `renderWithProviders` +- Creating new mock data instead of using existing mocks +- Missing Commerce SDK context providers +- Not using async/await for user interactions + +**Expected Output:** +Provide a clear, structured report based on **static code analysis only** (no test execution): +- ✅ **PASS** or ❌ **FAIL** for each verification point +- Specific line numbers and code snippets for any failures +- Summary: `X/Y patterns followed correctly` +- Overall result: **RULES EFFECTIVE** or **RULES NEED IMPROVEMENT** + +**Cleanup:** +- Delete the generated test file `drawer-menu-underTest.test.js` after QA validation is complete diff --git a/.cursor/rules/cursor-rule.mdc b/.cursor/rules/cursor-rule.mdc new file mode 100644 index 0000000000..774d83ff2c --- /dev/null +++ b/.cursor/rules/cursor-rule.mdc @@ -0,0 +1,66 @@ +--- +description: How to add or edit Cursor rules in our project +globs: +alwaysApply: false +--- +# Cursor Rules Location + +How to add new cursor rules to the project + +1. Always place rule files in PROJECT_ROOT/.cursor/rules/: + ``` + .cursor/rules/ + ├── your-rule-name.mdc + ├── another-rule.mdc + └── ... + ``` + +2. Follow the naming convention: + - Use kebab-case for filenames + - Always use .mdc extension + - Make names descriptive of the rule's purpose + +3. Directory structure: + ``` + PROJECT_ROOT/ + ├── .cursor/ + │ └── rules/ + │ ├── your-rule-name.mdc + │ └── ... + └── ... + ``` + +4. Never place rule files: + - In the project root + - In subdirectories outside .cursor/rules + - In any other location + +5. Cursor rules have the following structure: + +```` +--- +description: Short description of the rule's purpose +globs: optional/path/pattern/**/* +alwaysApply: false +--- +# Rule Title + +Main content explaining the rule with markdown formatting. + +1. Step-by-step instructions +2. Code examples +3. Guidelines + +Example: +```typescript +// Good example +function goodExample() { + // Implementation following guidelines +} + +// Bad example +function badExample() { + // Implementation not following guidelines +} +``` +```` \ No newline at end of file diff --git a/.cursor/rules/testing/unit-tests-generic.mdc b/.cursor/rules/testing/unit-tests-generic.mdc new file mode 100644 index 0000000000..63970b79d5 --- /dev/null +++ b/.cursor/rules/testing/unit-tests-generic.mdc @@ -0,0 +1,57 @@ +--- +description: USE WHEN writing unit tests for components in template packages +globs: ["packages/template-*/*/components/**/*.test.{js,jsx,ts,tsx}"] +alwaysApply: false +--- +USE WHEN writing unit tests for components in template packages + +# 🧪 Generic Component Test Rules + +## Structure & Best Practices +- Use `describe` blocks to group tests, `test` for individual cases +- Use `beforeEach` for setup, clear mocks after each test +- **Arrange** → **Act** → **Assert** pattern +- One behavior per test, clear descriptive names + +## Queries & Assertions +- Prefer `getByRole`, `getByLabelText`, `getByTestId` +- Use `expect().toBeInTheDocument()`, `.toHaveBeenCalledTimes()`, etc. +- For async: `await waitFor(() => { ... })` + +## Mocking +- `jest.fn()` for handlers, `jest.mock()` for modules +- Clear mocks/storage after each test + +```js +describe('MyComponent', () => { + beforeEach(() => jest.clearAllMocks()) + + test('renders and handles interaction', async () => { + const mockHandler = jest.fn() + render() + + await userEvent.click(screen.getByRole('button')) + expect(mockHandler).toHaveBeenCalledTimes(1) + }) +}) +``` + +## Running Tests +After creating unit tests, **ALWAYS run the tests** to verify they pass and provide feedback on test results. + +### Command Format: +```bash +cd packages/ && npm run test -- ' --coverage=false' +``` + +### Examples: +```bash +# Run specific test file from packages directory +cd packages/template-retail-react-app && npm run test -- 'app/components/drawer-menu/drawer-menu.test.js --coverage=false' +``` + +### After Running Tests: +- Report if tests **pass** or **fail** +- If tests fail, provide the error messages and fix any issues +- Confirm test coverage is appropriate for the component's core functionality +- Suggest any additional tests if critical functionality is missing diff --git a/.cursor/rules/testing/unit-tests-template-retail-react-app.mdc b/.cursor/rules/testing/unit-tests-template-retail-react-app.mdc new file mode 100644 index 0000000000..d11d74e24e --- /dev/null +++ b/.cursor/rules/testing/unit-tests-template-retail-react-app.mdc @@ -0,0 +1,35 @@ +--- +description: USE WHEN writing unit tests in template-retail-react-app components +globs: ["packages/template-retail-react-app/app/components/**/*.test.{js,jsx,ts,tsx}"] +alwaysApply: false +--- +# 🛍️ Retail React App Test Rules + +## Package-Specific Requirements +- **File naming**: `index.test.js` (colocated with component) +- **Always use `renderWithProviders`** (provides Commerce SDK context) +- **Get user events from return value**: `const {user} = renderWithProviders(...)` +- **Do NOT import `userEvent` directly** + +## API Mocking +- Use `prependHandlersToServer` or `msw` for API mocking + +## Mock Data Usage + +- **Mandatory**: Always use existing mock data from `@salesforce/retail-react-app/app/mocks/` if it is available. This ensures consistency across tests and reduces redundancy. Creating new mock data should only be considered if the required data is not already present in the mocks directory. + +```js +import {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import MyComponent from '.' + +describe('MyComponent', () => { + beforeEach(() => jest.clearAllMocks()) + + test('handles user interaction', async () => { + const {user} = renderWithProviders() + await user.click(screen.getByText('Click Me')) + expect(screen.getByText('Expected')).toBeInTheDocument() + }) +}) +``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4c0f56afbf..7140e05a3a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,64 @@ # https://help.github.com/en/enterprise/2.17/user/articles/about-code-owners -# These owners will be the default owners for everything in -# the repo. Unless a later match takes precedence, -# @global-owner1 and @global-owner2 will be requested for -# review when someone opens a pull request. +# Global directories +.cursor/ @SalesforceCommerceCloud/mobifyers +.github/ @SalesforceCommerceCloud/mobifyers +e2e/ @SalesforceCommerceCloud/mobifyers +scripts/ @SalesforceCommerceCloud/mobifyers + +# Package-specific ownership +packages/commerce-sdk-react/ @SalesforceCommerceCloud/mobifyers +packages/internal-lib-build/ @SalesforceCommerceCloud/mobifyers +packages/pwa-kit-create-app/ @SalesforceCommerceCloud/mobifyers +packages/pwa-kit-dev/ @SalesforceCommerceCloud/mobifyers +packages/pwa-kit-mcp/ @SalesforceCommerceCloud/mobifyers +packages/pwa-kit-react-sdk/ @SalesforceCommerceCloud/mobifyers +packages/pwa-kit-runtime/ @SalesforceCommerceCloud/mobifyers +packages/template-express-minimal/ @SalesforceCommerceCloud/mobifyers +packages/template-mrt-reference-app/ @SalesforceCommerceCloud/mobifyers +packages/template-retail-react-app/ @SalesforceCommerceCloud/mobifyers +packages/template-typescript-minimal/ @SalesforceCommerceCloud/mobifyers +packages/test-commerce-sdk-react/ @SalesforceCommerceCloud/mobifyers + +# Root configuration and documentation files +/README.md @SalesforceCommerceCloud/mobifyers +/CODE_OF_CONDUCT.md @SalesforceCommerceCloud/mobifyers +/CONTRIBUTING.md @SalesforceCommerceCloud/mobifyers +/SECURITY.md @SalesforceCommerceCloud/mobifyers +/STATEMENTS.md @SalesforceCommerceCloud/mobifyers +/TERMS_OF_USE.md @SalesforceCommerceCloud/mobifyers +/LICENSE @SalesforceCommerceCloud/mobifyers +/.eslintrc.js @SalesforceCommerceCloud/mobifyers +/.gitattributes @SalesforceCommerceCloud/mobifyers +/.gitignore @SalesforceCommerceCloud/mobifyers +/.prettierignore @SalesforceCommerceCloud/mobifyers +/.prettierrc.yaml @SalesforceCommerceCloud/mobifyers +/lerna.json @SalesforceCommerceCloud/mobifyers +/package.json @SalesforceCommerceCloud/mobifyers +/package-lock.json @SalesforceCommerceCloud/mobifyers +/playwright.config.js @SalesforceCommerceCloud/mobifyers +/.git2gus/ @SalesforceCommerceCloud/mobifyers + +# Specific feature file ownership (overrides package-level rules above) + +# BOPIS (Buy Online Pick up In Store) feature files - PR #2646 +packages/template-retail-react-app/app/components/store-display/ @SalesforceCommerceCloud/cc-spark +packages/template-retail-react-app/app/hooks/use-selected-store.js @SalesforceCommerceCloud/cc-spark +packages/template-retail-react-app/app/hooks/use-pickup-shipment.js @SalesforceCommerceCloud/cc-spark +packages/template-retail-react-app/app/hooks/use-pickup-shipment.test.js @SalesforceCommerceCloud/cc-spark +e2e/tests/desktop/bopis.spec.js @SalesforceCommerceCloud/cc-spark + +# Bonus Products feature files - PR #2704 +packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.jsx @SalesforceCommerceCloud/cc-sharks +packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.test.jsx @SalesforceCommerceCloud/cc-sharks +packages/template-retail-react-app/app/components/product-item/product-quantity-picker.jsx @SalesforceCommerceCloud/cc-sharks +packages/template-retail-react-app/app/components/product-item/product-quantity-picker.test.jsx @SalesforceCommerceCloud/cc-sharks +packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.jsx @SalesforceCommerceCloud/cc-sharks +packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.test.js @SalesforceCommerceCloud/cc-sharks + +# Standard Products feature files - PR #2697 +packages/template-retail-react-app/app/mocks/standard-product.js @SalesforceCommerceCloud/cc-sharks +packages/template-retail-react-app/app/utils/add-to-cart-utils.js @SalesforceCommerceCloud/cc-sharks + -* @SalesforceCommerceCloud/mobifyers #ECCN:Open Source \ No newline at end of file diff --git a/.github/actions/changelog-check/action.yml b/.github/actions/changelog-check/action.yml new file mode 100644 index 0000000000..410ca2559b --- /dev/null +++ b/.github/actions/changelog-check/action.yml @@ -0,0 +1,68 @@ +name: 'Changelog Check' +description: 'Check if changelog is updated for the changed packages' +inputs: + pr_number: + description: 'Pull request number' + required: true + +runs: + using: 'composite' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch full history to access all commits + + - name: Determine Base SHA and Merge Base + id: determine_base + run: | + if [ -n "${{ github.event.pull_request.base.sha }}" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + MERGE_BASE=$(git merge-base $BASE_SHA ${{ github.sha }}) + echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV + echo "MERGE_BASE=$MERGE_BASE" >> $GITHUB_ENV + else + echo "Not running in a PR context or unable to determine base SHA. Skipping changelog check." + echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV + fi + shell: bash + + - name: Check if 'skip changelog' label is present + id: check_labels + run: | + SKIP_CHANGELOG_LABEL="${{ contains(github.event.pull_request.labels.*.name, 'skip changelog') }}" + if [ "$SKIP_CHANGELOG_LABEL" = "true" ]; then + echo "Skip changelog label is present. Skipping changelog check." + echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV + fi + shell: bash + + - name: Debugging Info + run: | + echo "Base SHA: ${{ env.BASE_SHA }}" + echo "Current SHA: ${{ github.sha }}" + echo "Merge Base: ${{ env.MERGE_BASE }}" + echo "SKIP_CHANGELOG_CHECK: ${{ env.SKIP_CHANGELOG_CHECK }}" + shell: bash + + - name: Check if changelog is updated + if: ${{ env.SKIP_CHANGELOG_CHECK != 'true' }} + run: | + CHANGED_FILES=$(git diff --name-only ${{ env.MERGE_BASE }} ${{ github.sha }}) + if [ -z "$CHANGED_FILES" ]; then + echo "No changed files detected." + exit 0 + fi + echo "Changed files: $CHANGED_FILES" + + PUBLIC_PACKAGES=("commerce-sdk-react" "pwa-kit-create-app" "pwa-kit-dev" "pwa-kit-react-sdk" "pwa-kit-runtime" "template-retail-react-app") + + for PACKAGE in "${PUBLIC_PACKAGES[@]}"; do + if echo "$CHANGED_FILES" | grep -iq "^packages/$PACKAGE/"; then + if ! echo "$CHANGED_FILES" | grep -iq "^packages/$PACKAGE/CHANGELOG.md"; then + echo "CHANGELOG.md was not updated for package $PACKAGE. Please update the CHANGELOG.md or add 'skip changelog' label to the PR." + exit 1 + fi + fi + done + shell: bash diff --git a/.github/actions/check_if_latest_release/action.yml b/.github/actions/check_if_latest_release/action.yml new file mode 100644 index 0000000000..067976f3a5 --- /dev/null +++ b/.github/actions/check_if_latest_release/action.yml @@ -0,0 +1,26 @@ +name: check_if_latest_release +inputs: + token: + description: "Github API token" +runs: + using: composite + steps: + - name: Check if latest release + run: |- + RELEASE_JSON=$(curl -s -H "Authorization: token ${{ inputs.token }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/latest") + + LATEST_TAG_NAME=$(echo "$RELEASE_JSON" | jq -r '.tag_name') + RELEASE_EVENT_TAG_NAME=$(echo ${{ github.event.release.tag_name }}) + + echo "Latest tag: $LATEST_TAG_NAME" + echo "Release event tag: $RELEASE_EVENT_TAG_NAME" + + if [ "$LATEST_TAG_NAME" == "$RELEASE_EVENT_TAG_NAME" ]; then + echo "We are releasing the latest tag." + echo "IS_LATEST_RELEASE=true" >> $GITHUB_ENV + else + echo "We are not releasing the latest tag." + echo "IS_LATEST_RELEASE=false" >> $GITHUB_ENV + fi + shell: bash \ No newline at end of file diff --git a/.github/actions/create_mrt_target/action.yml b/.github/actions/create_mrt_target/action.yml new file mode 100644 index 0000000000..f93ef67d20 --- /dev/null +++ b/.github/actions/create_mrt_target/action.yml @@ -0,0 +1,108 @@ +name: create_mrt_target +description: Create MRT Environment +inputs: + project_id: + description: "MRT Project ID" + target_id: + description: "MRT Target ID" + proxy_configs: + description: "Proxy Configs" + mobify_api_key: + description: "Mobify user API key" + +runs: + using: composite + steps: + - name: Initialize + id: initialize + shell: bash + run: | + set -e + echo "TARGET_API_BASE_URL=https://cloud.mobify.com/api/projects/${{ inputs.project_id }}/target" >> $GITHUB_ENV + + - name: Get target + id: get_target + shell: bash + run: |- + set -e + response=$(curl --location --silent --show-error --write-out "HTTPSTATUS:%{http_code}" "$TARGET_API_BASE_URL/${{ inputs.target_id }}" \ + --header "Authorization: Bearer ${{ inputs.mobify_api_key }}") + + http_status=$(echo $response | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + echo "status=$http_status" >> $GITHUB_OUTPUT + + if [ "$http_status" -eq 404 ]; then + echo "MRT environment not found, it will be created in the next step." + elif [ "$http_status" -eq 200 ]; then + echo "MRT environment already exists." + else + echo "Error: Unexpected HTTP status: $http_status" + exit 1 + fi + + - name: Create target + id: create_target + if: ${{ steps.get_target.outputs.status == '404' }} + shell: bash + run: |- + set -e + proxy_config_json=$(echo ${{ inputs.proxy_configs }} | jq -r .) + response=$(curl --location --silent --show-error --write-out "HTTPSTATUS:%{http_code}" "$TARGET_API_BASE_URL/" \ + --header "Authorization: Bearer ${{ inputs.mobify_api_key }}" \ + --header "Content-Type: application/json" \ + --data "$(jq -n \ + --arg name "${{ inputs.target_id }}" \ + --arg slug "${{ inputs.target_id }}" \ + --argjson ssr_proxy_configs "$proxy_config_json" \ + '{name: $name, slug: $slug, ssr_proxy_configs: $ssr_proxy_configs}')") + + http_status=$(echo "$response" | sed -n 's/.*HTTPSTATUS://p') + response_body=$(echo "$response" | sed -e 's/HTTPSTATUS:.*//g') + + echo "status=$http_status" >> $GITHUB_OUTPUT + + if [ "$http_status" -ne 201 ]; then + echo "Request failed with status code $http_status" + echo "Response Body: $response_body" + exit 1 + fi + + - name: Wait for target to be active + id: wait_for_target + if: ${{ steps.create_target.outputs.status == '201' }} + shell: bash + run: |- + set -e + max_attempts=30 + sleep_duration=30 + attempts=0 + + while [ $attempts -lt $max_attempts ]; do + response=$(curl --location --silent --show-error --write-out "HTTPSTATUS:%{http_code}" "$TARGET_API_BASE_URL/${{ inputs.target_id }}" \ + --header "Authorization: Bearer ${{ inputs.mobify_api_key }}") + + http_status=$(echo $response | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + response_body=$(echo $response | sed -e 's/HTTPSTATUS\:.*//g') + + if [ "$http_status" -ne 200 ]; then + echo "Request failed with status code $http_status" + exit 1 + fi + + current_state=$(echo $response_body | jq -r '.state') + + if [ "$current_state" == "ACTIVE" ]; then + echo "Target is now ACTIVE." + exit 0 + elif [ "$current_state" != "CREATE_IN_PROGRESS" ]; then + echo "Unexpected target state: $current_state." + exit 1 + fi + + attempts=$((attempts + 1)) + echo "Waiting for target to be ACTIVE. Attempt $attempts/$max_attempts." + sleep $sleep_duration + done + + echo "Target did not become active within the expected time." + exit 1 diff --git a/.github/actions/datadog/action.yml b/.github/actions/datadog/action.yml index a35bcc51fc..8440688ae3 100644 --- a/.github/actions/datadog/action.yml +++ b/.github/actions/datadog/action.yml @@ -9,6 +9,13 @@ runs: steps: - name: Send metrics to Datadog run : | + # For the datadog cli, it must be installed via python + # to install python packages on CI environment, we must activate the virtual env + # or otherwise it throws error: externally-managed-environment + python3 -m venv venv + source venv/bin/activate + pip install datadog + # Add a dogrc so we can submit metrics to datadog printf "[Connection]\napikey = ${{inputs.datadog_api_key}}\nappkey =\n" > ~/.dogrc diff --git a/.github/actions/deploy_app/action.yml b/.github/actions/deploy_app/action.yml new file mode 100644 index 0000000000..d8b7df5298 --- /dev/null +++ b/.github/actions/deploy_app/action.yml @@ -0,0 +1,50 @@ +name: deploy_app +description: Deploy application to MRT +inputs: + project_id: + description: MRT Project ID + target_id: + description: MRT Target ID + project_dir: + description: Project Directory + mobify_user: + description: "Mobify user email" + mobify_api_key: + description: "Mobify user API key" +runs: + using: composite + steps: + - name: Create MRT credentials file + id: create_mrt_credentials + uses: "./.github/actions/create_mrt" + with: + mobify_user: ${{ inputs.mobify_user }} + mobify_api_key: ${{ inputs.mobify_api_key }} + + - name: Read application config + id: read_config + shell: bash + run: | + # Read proxy configs from the default config file using Node.js + config=$(node -e "console.log(JSON.stringify(require('${{ inputs.project_dir }}/config/default.js')))") + # Extract proxyConfigs as a JSON string + echo "proxy_configs=$(echo "$config" | jq -c '.ssrParameters.proxyConfigs' | jq @json)" >> $GITHUB_OUTPUT + + + - name: Create MRT target + id: create_mrt_target + uses: "./.github/actions/create_mrt_target" + with: + project_id: ${{ inputs.project_id }} + target_id: ${{ inputs.target_id }} + proxy_configs: ${{ steps.read_config.outputs.proxy_configs }} + mobify_api_key: ${{ inputs.mobify_api_key }} + + + - name: Push bundle to MRT + id: push_bundle + uses: "./.github/actions/push_to_mrt" + with: + CWD: ${{ inputs.project_dir }} + TARGET: ${{ inputs.target_id }} + FLAGS: --wait diff --git a/.github/actions/e2e_generate_app/action.yml b/.github/actions/e2e_generate_app/action.yml index 4916840373..5504b56341 100644 --- a/.github/actions/e2e_generate_app/action.yml +++ b/.github/actions/e2e_generate_app/action.yml @@ -4,9 +4,30 @@ inputs: description: Project key to specify project to generate ("retail-app-demo", "retail-app-ext" or "retail-app-no-ext") required: true type: string + + TEMPLATE_VERSION: + description: Version of the template to use for the project + required: false + type: string + +outputs: + project_path: + description: Path to the generated project directory + value: ${{ steps.generate_project.outputs.project_path }} + runs: using: composite steps: - name: Generate new project based on project-key - run: node e2e/scripts/generate-project.js ${{ inputs.PROJECT_KEY }} + id: generate_project + run: | + COMMAND="node e2e/scripts/generate-project.js --project-key ${{ inputs.PROJECT_KEY }}" + if [[ -n "${{ inputs.TEMPLATE_VERSION }}" ]]; then + COMMAND="$COMMAND --templateVersion ${{ inputs.TEMPLATE_VERSION }}" + fi + $COMMAND + + # Return path to the generated project + GENERATED_PROJECTS_DIR=$(node -e "console.log(require('./e2e/config.js').GENERATED_PROJECTS_DIR)") + echo "project_path=$GENERATED_PROJECTS_DIR/${{ inputs.PROJECT_KEY }}" >> $GITHUB_OUTPUT shell: bash diff --git a/.github/actions/e2e_validate_generated_app/action.yml b/.github/actions/e2e_validate_generated_app/action.yml index 8531ad2edb..14d39b43e5 100644 --- a/.github/actions/e2e_validate_generated_app/action.yml +++ b/.github/actions/e2e_validate_generated_app/action.yml @@ -4,9 +4,19 @@ inputs: description: Project key to specify project to validate ("retail-app-demo", "retail-app-ext" or "retail-app-no-ext") required: true type: string + + TEMPLATE_VERSION: + description: Version of the template to use for the project + required: true + type: string runs: using: composite steps: - - name: Validate generated project based on project-key - run: node e2e/scripts/validate-generated-project.js ${{ inputs.PROJECT_KEY }} + - name: Validate generated project + run: | + COMMAND="node e2e/scripts/validate-generated-project.js ${{ inputs.PROJECT_KEY }}" + if [[ -n "${{ inputs.TEMPLATE_VERSION }}" ]]; then + COMMAND="$COMMAND --templateVersion ${{ inputs.TEMPLATE_VERSION }}" + fi + $COMMAND shell: bash diff --git a/.github/actions/generate_app/action.yml b/.github/actions/generate_app/action.yml new file mode 100644 index 0000000000..96f2e54e7d --- /dev/null +++ b/.github/actions/generate_app/action.yml @@ -0,0 +1,130 @@ +name: generate_app +description: Generate Application +inputs: + use_extensibility: + description: Use Extensibility? + project_id: + description: Project ID + instance_url: + description: Instance Url + org_id: + description: Org Id + short_code: + description: Short Code + client_id: + description: Client Id + site_id: + description: Site Id + is_private_client: + description: Is Private Client? + setup_hybrid: + description: Setup Phased Headless rollout? + project_dir: + description: Project Directory + +outputs: + project_path: + description: Path to the generated project directory + value: ${{ steps.generate_project.outputs.project_path }} + +runs: + using: composite + steps: + - name: Parse input values + id: parse_input + shell: bash + run: | + use_extensibility_input="${{ inputs.use_extensibility }}" + if [ "use_extensibility_input" = "true" ]; then + use_extensibility_value=1 + else + use_extensibility_value=2 + fi + echo "USE_EXTENSIBILITY_VALUE=$use_extensibility_value" >> $GITHUB_ENV + + is_private_client_input="${{ inputs.is_private_client }}" + if [ "$is_private_client_input" = "true" ]; then + is_private_client_value=1 + else + is_private_client_value=2 + fi + echo "IS_PRIVATE_CLIENT_VALUE=$is_private_client_value" >> $GITHUB_ENV + + setup_hybrid_input="${{ inputs.setup_hybrid }}" + if [ "$setup_hybrid_input" = "true" ]; then + setup_hybrid_value=2 + else + setup_hybrid_value=1 + fi + echo "SETUP_HYBRID_VALUE=$setup_hybrid_value" >> $GITHUB_ENV + + # TODO: update this to use the standard input feature that Ben created recently + # TODO: this action does not finish successfully because of a dirty-workspace error, due to creating this generator-responses.json + - name: Build project generator inputs + id: build_generator_inputs + shell: bash + run: | + echo '{ + "projectDir":"${{ inputs.project_dir }}", + "responses": [ + { + "expectedPrompt": "Choose a project preset to get started:", + "response": "1\n" + }, + { + "expectedPrompt": "Do you wish to use template extensibility?", + "response": "${{ env.USE_EXTENSIBILITY_VALUE }}\n" + }, + { + "expectedPrompt": "What is the name of your Project?", + "response": "${{ inputs.project_id }}\n" + }, + { + "expectedPrompt": "What is the URL for your Commerce Cloud instance?", + "response": "${{ inputs.instance_url }}\n" + }, + { + "expectedPrompt": "What is your SLAS Client ID?", + "response": "${{ inputs.client_id }}\n" + }, + { + "expectedPrompt": "Is your SLAS client private?", + "response":"${{ env.IS_PRIVATE_CLIENT_VALUE }}\n" + }, + { + "expectedPrompt": "What is your Site ID in Business Manager?", + "response": "${{ inputs.site_id }}\n" + }, + { + "expectedPrompt": "What is your Commerce API organization ID in Business Manager?", + "response": "${{ inputs.org_id }}\n" + }, + { + "expectedPrompt": "What is your Commerce API short code in Business Manager?", + "response": "${{ inputs.short_code }}\n" + }, + { + "expectedPrompt": "Do you wish to set up a phased headless rollout?", + "response": "${{ env.SETUP_HYBRID_VALUE }}\n" + } + ] }' > generator-responses.json + + - name: Generate project + id: generate_project + run: | + cat generator-responses.json + node e2e/scripts/generate-project.js --project-config "$(jq -c . generator-responses.json)" + + # Return path to the generated project + GENERATED_PROJECTS_DIR=$(node -e "console.log(require('./e2e/config.js').GENERATED_PROJECTS_DIR)") + echo "project_path=$GENERATED_PROJECTS_DIR/${{ inputs.project_dir }}" >> $GITHUB_OUTPUT + shell: bash + + # TODO: I think we can safely delete this step. We already install the dependencies in the previous step. + - name: Build generated project + id: build_generated_project + working-directory: ${{ steps.generate_project.outputs.project_path }} + run: |- + npm ci + npm run build + shell: bash diff --git a/.github/actions/push_to_mrt/action.yml b/.github/actions/push_to_mrt/action.yml index ec18ed1e93..a8dee2586c 100644 --- a/.github/actions/push_to_mrt/action.yml +++ b/.github/actions/push_to_mrt/action.yml @@ -4,18 +4,28 @@ inputs: description: Project directory default: ${{ github.workspace }} TARGET: - description: MRT target + description: MRT target environment FLAGS: - description: Push flags + description: The rest of the flags for the push command + PROJECT: + description: MRT target project + default: "scaffold-pwa" + MESSAGE: + description: Bundle message / name + default: "build ${{ github.run_id }} on ${{ github.ref }} (${{ github.sha }})" + CLOUD_ORIGIN: + description: 'MRT Cloud origin' + default: 'https://cloud.mobify.com' + CREDENTIALS_FILE_PATH: + description: 'Path to the credentials file' + default: '~/.mobify' runs: using: composite steps: - name: Push Bundle to MRT run: |- cd ${{ inputs.CWD }} - project="scaffold-pwa" - build="build ${{ github.run_id }} on ${{ github.ref }} (${{ github.sha }})" if [[ ${{ inputs.TARGET }} ]]; then - npm run push -- -s $project --message "$build" --target ${{ inputs.TARGET }} ${{ inputs.FLAGS }} + npm run push -- --projectSlug ${{ inputs.PROJECT }} --message "${{ inputs.MESSAGE }}" --target ${{ inputs.TARGET }} --cloud-origin ${{ inputs.CLOUD_ORIGIN }} --credentialsFile ${{ inputs.CREDENTIALS_FILE_PATH }} ${{ inputs.FLAGS }} fi shell: bash diff --git a/.github/actions/setup_ubuntu/action.yml b/.github/actions/setup_ubuntu/action.yml index d0a00818ac..f009d4576c 100644 --- a/.github/actions/setup_ubuntu/action.yml +++ b/.github/actions/setup_ubuntu/action.yml @@ -10,12 +10,6 @@ runs: - name: Install Dependencies working-directory: ${{ inputs.cwd }} run: |- - # Install system dependencies - sudo apt-get update -yq - sudo apt-get install python2 python3-pip time -yq - sudo pip install -U pip setuptools - sudo pip install awscli==1.18.85 datadog==0.40.1 - # Install node dependencies node ./scripts/gtime.js monorepo_install npm ci diff --git a/.github/workflows/agent_checkout_rebase_action.yml b/.github/workflows/agent_checkout_rebase_action.yml new file mode 100644 index 0000000000..8eac071e9c --- /dev/null +++ b/.github/workflows/agent_checkout_rebase_action.yml @@ -0,0 +1,97 @@ +# .github/workflows/rebase-express-payments.yml + +name: 'Auto Rebase adyenExpressPayments' + +# This action will trigger on the creation of new preview release tags +on: + push: + tags: + - '*-preview' + workflow_dispatch: + +permissions: + contents: write + +jobs: + rebase: + name: Rebase adyenExpressPayments on preview release + runs-on: ubuntu-latest + + steps: + # Step 1: Check out the repository's code. + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetches all history for all branches and tags + + # Step 2: Set up the Node.js environment to use npm and npx. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' # Or your project's specific Node.js version + cache: 'npm' # Caches npm dependencies for faster runs + + # Step 3: Configure Git with a user name and email. + - name: Set up Git + run: | + git config --global user.name 'GitHub Actions' + git config --global user.email 'actions@github.com' + + # Step 4: Switch to the adyenExpressPayments branch. + - name: Switch to adyenExpressPayments branch + run: git checkout adyenExpressPayments + + # Step 5: Delete all package-lock.json files before the rebase. + - name: Delete package-lock.json files + run: | + find . -name "package-lock.json" -type f -delete + # We must commit this change so the rebase can proceed cleanly. + # The 'if' statement prevents an error if no lock files were found. + if [[ -n $(git status -s) ]]; then + git add . + git commit -m "chore: remove package-lock.json files before rebase and regenerating after" + else + echo "No package-lock.json files found to delete." + fi + + # Step 6: Attempt to rebase the branch. If this step fails, we abort the rebase and + # return the branch to its original state + - name: Rebase adyenExpressPayments with develop + run: | + TRIGGERING_TAG="${{ github.ref_name }}" + echo "🔄 Rebasing branch 'adyenExpressPayments' onto tag '$TRIGGERING_TAG'" + + if ! git rebase $TRIGGERING_TAG; then + echo "Rebase failed due to conflicts. Aborting." + git rebase --abort + exit 1 + fi + + # Step 7: Install dependencies after a successful rebase. + # The '--yes' flag for lerna is crucial for non-interactive environments. + - name: Install Dependencies + run: | + if ! npx lerna clean --yes && npm i; then + echo "Generating package-lock files after rebase failed!" + exit 1 + fi + + # Step 8: Push the changes only if all previous steps were successful. + - name: Push Changes + run: | + git add . + git commit --amend --no-edit + git push origin adyenExpressPayments --force-with-lease + + # Step 9: This notification step will run if any of the above steps fail. + - name: Notify on Failure + if: failure() + uses: slackapi/slack-github-action@v1.26.0 + with: + # The channel or user ID to send the notification to. + channel-id: '${{ secrets.AGENT_CHECKOUT_ALERT_SLACK_CHANNEL_ID }}' + # A custom message for the Slack notification. + slack-message: "🚨 Automatic rebase of `adyenExpressPayments` failed. Manual intervention is required. Link to failed action: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + env: + # You must store your Slack Bot Token as a secret in your repository settings. + SLACK_BOT_TOKEN: '${{ secrets.AGENT_CHECKOUT_SLACK_BOT_TOKEN }}' diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 0000000000..fcee8c1b82 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,35 @@ +# WARNING! Conditionals are set as variables to minimize repetitive checks. +# However, this results in the variables being the *string* values "true" or "false". +# As a result, you must always explicitly check for those strings. For example, +# ${{ env.DEVELOP }} will ALWAYS evaluate as true; to achieve the expected result +# you must check ${{ env.DEVELOP == 'true' }}. There's probably a better way to DRY, +# but this is what we have for now. + +name: SalesforceCommerceCloud/pwa-kit/bundle-size +on: + pull_request: # Default: opened, reopened, synchronize (head branch updated) + merge_group: # Trigger GA workflow when a pull request is added to a merge queue. + push: + branches: + - develop + - 'release-*' + +jobs: + pwa-kit-bundle-size: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 # Use latest LTS version for bundle size check + cache: npm + + - name: Setup Ubuntu Machine + uses: "./.github/actions/setup_ubuntu" + + - name: Run bundlesize test + uses: "./.github/actions/bundle_size_test" + diff --git a/.github/workflows/deploy_latest_release.yml b/.github/workflows/deploy_latest_release.yml new file mode 100644 index 0000000000..8726e802d1 --- /dev/null +++ b/.github/workflows/deploy_latest_release.yml @@ -0,0 +1,105 @@ +name: SalesforceCommerceCloud/pwa-kit/deploy_latest_release +on: + # For testing - to be removed + push: + branches: + - 'deploy-demo-env-on-release' + # Run this workflow when a new Github release is published + release: + types: [released] + +jobs: + check-latest-release: + runs-on: ubuntu-latest + outputs: + IS_LATEST_RELEASE: ${{ steps.checkRelease.outputs.IS_LATEST_RELEASE }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check if latest release + uses: ./.github/actions/check_if_latest_release + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update Github Outputs + id: checkRelease + run: |- + IS_LATEST_RELEASE=$(echo ${{ env.IS_LATEST_RELEASE }}) + if [ $IS_LATEST_RELEASE != "true" ]; then + echo "Deploy is not for the latest release. Stopping deployment" + fi + echo "IS_LATEST_RELEASE=$IS_LATEST_RELEASE" >> "$GITHUB_OUTPUT" + + deploy-sites: + needs: check-latest-release + runs-on: ubuntu-latest + if: needs.check-latest-release.outputs.IS_LATEST_RELEASE == 'true' + strategy: + fail-fast: false + matrix: + environment: + - name: bug-bounty + project_key: retail-react-app-bug-bounty + target: testing + project: pwa-kit + mobify_user: MOBIFY_STG_CLIENT_USER + mobify_api_key: MOBIFY_STG_CLIENT_API_KEY + cloud_origin: https://cloud-testing.mobify-staging.com + flags: --wait + - name: demo-site + project_key: retail-react-app-demo-site + target: production + project: scaffold-pwa + mobify_user: MOBIFY_CLIENT_USER + mobify_api_key: MOBIFY_CLIENT_API_KEY + cloud_origin: https://cloud.mobify.com + flags: --wait + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + id: setup_node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "npm" + + - name: Install Monorepo Dependencies + run: |- + # Install node dependencies + node ./scripts/gtime.js monorepo_install npm ci + + - name: Generate Retail App Demo + id: generate_app + uses: ./.github/actions/e2e_generate_app + with: + PROJECT_KEY: ${{ matrix.environment.project_key }} + + - name: Create MRT credentials file + uses: "./.github/actions/create_mrt" + with: + mobify_user: ${{ secrets[matrix.environment.mobify_user] }} + mobify_api_key: ${{ secrets[matrix.environment.mobify_api_key] }} + + - name: Set bundle name + run: |- + TAG_NAME=$(echo ${{ github.event.release.tag_name }}) + if [ "$TAG_NAME" ]; then + BUNDLE_NAME="tag ${{ github.event.release.tag_name }}" + else + BUNDLE_NAME="build ${{ github.run_id }} on ${{ github.ref }} (${{ github.sha }})" + fi + echo "Setting bundle name as $BUNDLE_NAME" + echo "BUNDLE_NAME=$BUNDLE_NAME" >> $GITHUB_ENV + + - name: Push Bundle to MRT (${{matrix.environment.name}}) + uses: "./.github/actions/push_to_mrt" + with: + CWD: ${{ steps.generate_app.outputs.project_path }} + TARGET: ${{ matrix.environment.target }} + PROJECT: ${{ matrix.environment.project }} + MESSAGE: ${{ env.BUNDLE_NAME }}) + CLOUD_ORIGIN: ${{ matrix.environment.cloud_origin }} + FLAGS: ${{ matrix.environment.flags }} diff --git a/.github/workflows/e2e-pr.yml b/.github/workflows/e2e-pr.yml new file mode 100644 index 0000000000..763dc942a6 --- /dev/null +++ b/.github/workflows/e2e-pr.yml @@ -0,0 +1,113 @@ +name: SalesforceCommerceCloud/pwa-kit/e2e-pr +on: + workflow_dispatch: + pull_request: # Default: opened, reopened, synchronize (head branch updated) + merge_group: # Trigger GA workflow when a pull request is added to a merge queue. + push: + branches: + - develop + - 'release-*' + +jobs: + test_e2e_private: + runs-on: ubuntu-latest + steps: + # Skipping the entire workflow for now until all steps are implemented. + - name: Skip Check + run: | + echo "SKIP_WORKFLOW=true" >> "$GITHUB_ENV" + + - name: Checkout + uses: actions/checkout@v4 + + - name: Check PWA Kit Version + run: |- + version=`jq -r ".version" package.json` + echo "pwa_kit_version=$version" >> "$GITHUB_ENV" + + # TODO: Skip the entire workflow since we don't have e2e tests for PWA Kit v2.x + - name: Skip if PWA Kit version older than v3.x + if: ${{ env.SKIP_WORKFLOW != 'true' }} + run: | + major_version=$(echo "${{ env.pwa_kit_version }}" | cut -d. -f1) + if [ "$major_version" -lt 3 ]; then + echo "PWA Kit version is older than v3.x, skipping workflow." + echo "SKIP_WORKFLOW=true" >> "$GITHUB_ENV" + fi + + # Only test for latest Node version supported by MRT + - name: Setup Node + if: ${{ env.SKIP_WORKFLOW != 'true' }} + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # Check central resource allocation on AWS and get a lock on an available environment from the pool. + # Returns the MRT target ID if lock is acquired, otherwise returns an error state. + - name: Get MRT Target lock + if: ${{ env.SKIP_WORKFLOW != 'true' }} + id: get_mrt_target_lock + run: | + echo "TODO: Implement .github/actions/get_mrt_target_lock" + + - name: Create MRT target + id: create_mrt_target + if: ${{ env.SKIP_WORKFLOW != 'true' && steps.get_mrt_target_lock.outputs.status == 'ERR_NO_AVAILABLE_TARGETS' }} + run: | + echo "TODO: Call .github/actions/create_mrt_target with correct inputs" + + - name: Get Template Version + if: ${{ env.SKIP_WORKFLOW != 'true' }} + run: |- + version=`jq -r ".version" packages/template-retail-react-app/package.json` + echo "retail_app_template_version=$version" >> "$GITHUB_ENV" + + - name: Generate Retail App Private Client + id: generate_app + if: ${{ env.SKIP_WORKFLOW != 'true' }} + uses: ./.github/actions/e2e_generate_app + with: + PROJECT_KEY: 'retail-app-private-client' + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} + + - name: Validate Retail App Without Extensibility + if: ${{ env.SKIP_WORKFLOW != 'true' }} + uses: ./.github/actions/e2e_validate_generated_app + with: + PROJECT_KEY: 'retail-app-no-ext' + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} + + # TODO: Revisit the next 2 steps to see if we can use the existing .github/actions/deploy_app action. + - name: Create MRT credentials file + if: ${{ env.SKIP_WORKFLOW != 'true' }} + uses: './.github/actions/create_mrt' + with: + mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} + mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} + + - name: Push Bundle to MRT (E2E Test PWA Kit) + if: ${{ env.SKIP_WORKFLOW != 'true' }} + uses: './.github/actions/push_to_mrt' + with: + CWD: ${{ steps.generate_app.outputs.project_path }} + # TODO: Use the MRT target ID from the target lock step above. + TARGET: e2e-tests-pwa-kit + FLAGS: --wait + + - name: Install Playwright Browsers + if: ${{ env.SKIP_WORKFLOW != 'true' }} + run: npx playwright install --with-deps + + - name: Run Playwright tests + if: ${{ env.SKIP_WORKFLOW != 'true' }} + run: npm run test:e2e + + - name: Run Playwright a11y tests + if: ${{ env.SKIP_WORKFLOW != 'true' }} + run: npm run test:e2e:a11y + + - name: Release MRT Target Lock + if: always() # Always release the target lock back to the pool even if the tests fail. + run: | + echo "TODO: Implement .github/actions/release_mrt_target_lock" \ No newline at end of file diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 00f8b4a43c..0c063dc4c8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,18 +11,18 @@ jobs: fail-fast: false matrix: # Run all matrix env at once because we will not deploy demo app to MRT. - node: [16, 18, 20] - npm: [8, 9, 10] - exclude: # node 16 is not compatible with npm 10 - - node: 16 - npm: 10 + node: [18, 20, 22] + npm: [8, 9, 10, 11] + exclude: # node 18 with npm 11 is not compatible + - node: 18 + npm: 11 runs-on: ubuntu-latest env: # The "default" npm is the one that ships with a given version of node. # For more: https://nodejs.org/en/download/releases/ # (We also use this env var for making sure a step runs once for the current node version) # Note: For node 18, the default was npm 9 until v18.19.0, when it became npm 10 - IS_DEFAULT_NPM: ${{ (matrix.node == 16 && matrix.npm == 8) || (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) }} + IS_DEFAULT_NPM: ${{ (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) || (matrix.node == 22 && matrix.npm == 10) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -43,6 +43,14 @@ jobs: # Install node dependencies node ./scripts/gtime.js monorepo_install npm ci + - name: Check Playwright Versions + run: |- + # Check the installed version of playwright + echo "Playwright version: $(npm list playwright --depth=0)" + + # Check the installed version of @playwright/test + echo "@playwright/test version: $(npm list @playwright/test --depth=0)" + - name: Generate Retail App Demo uses: ./.github/actions/e2e_generate_app with: @@ -58,18 +66,20 @@ jobs: # Run one matrix env at a time because we need to deploy each app to MRT and run e2e tests there max-parallel: 1 matrix: - node: [16, 18, 20] - npm: [8, 9, 10] - exclude: # node 16 is not compatible with npm 10 - - node: 16 - npm: 10 + # Run all matrix env at once because we will not deploy demo app to MRT. + node: [18, 20, 22] + npm: [8, 9, 10, 11] + exclude: # node 18 with npm 11 is not compatible + - node: 18 + npm: 11 runs-on: ubuntu-latest env: # The "default" npm is the one that ships with a given version of node. # For more: https://nodejs.org/en/download/releases/ # (We also use this env var for making sure a step runs once for the current node version) # Note: For node 18, the default was npm 9 until v18.19.0, when it became npm 10 - IS_DEFAULT_NPM: ${{ (matrix.node == 16 && matrix.npm == 8) || (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) }} + IS_DEFAULT_NPM: ${{ (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) || (matrix.node == 22 && matrix.npm == 10) }} + steps: - name: Checkout uses: actions/checkout@v4 @@ -90,15 +100,31 @@ jobs: # Install node dependencies node ./scripts/gtime.js monorepo_install npm ci + - name: Check Playwright Versions + run: |- + # Check the installed version of playwright + echo "Playwright version: $(npm list playwright --depth=0)" + + # Check the installed version of @playwright/test + echo "@playwright/test version: $(npm list @playwright/test --depth=0)" + + - name: Get Template Version + run: |- + version=`jq -r ".version" packages/template-retail-react-app/package.json` + echo "retail_app_template_version=$version" >> "$GITHUB_ENV" + - name: Generate Retail App Without Extensibility + id: generate_app_no_ext uses: ./.github/actions/e2e_generate_app with: PROJECT_KEY: "retail-app-no-ext" + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} - name: Validate Retail App Without Extensibility uses: ./.github/actions/e2e_validate_generated_app with: PROJECT_KEY: "retail-app-no-ext" + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} - name: Create MRT credentials file uses: "./.github/actions/create_mrt" @@ -109,13 +135,14 @@ jobs: - name: Push Bundle to MRT (E2E Test PWA Kit) uses: "./.github/actions/push_to_mrt" with: - CWD: "../generated-projects/retail-app-no-ext" + CWD: ${{ steps.generate_app_no_ext.outputs.project_path }} TARGET: e2e-tests-pwa-kit FLAGS: --wait - name: Install Playwright Browsers run: npx playwright install --with-deps + # this will not include a11y tests - name: Run Playwright tests run: npm run test:e2e notify-slack-pwa-no-ext: @@ -155,18 +182,22 @@ jobs: # Run one matrix env at a time because we need to deploy each app to MRT and run e2e tests there max-parallel: 1 matrix: - node: [16, 18, 20] - npm: [8, 9, 10] - exclude: # node 16 is not compatible with npm 10 - - node: 16 - npm: 10 + # Run all matrix env at once because we will not deploy demo app to MRT. + node: [18, 20, 22] + npm: [8, 9, 10, 11] + exclude: # node 18 with npm 11 is not compatible + - node: 18 + npm: 11 runs-on: ubuntu-latest env: # The "default" npm is the one that ships with a given version of node. # For more: https://nodejs.org/en/download/releases/ # (We also use this env var for making sure a step runs once for the current node version) # Note: For node 18, the default was npm 9 until v18.19.0, when it became npm 10 - IS_DEFAULT_NPM: ${{ (matrix.node == 16 && matrix.npm == 8) || (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) }} + IS_DEFAULT_NPM: ${{ (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) || (matrix.node == 22 && matrix.npm == 10) }} + # The current recommended version for Managed Runtime: + # https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/upgrade-node-version.html + IS_MRT_NODE: ${{ matrix.node == 22 && matrix.npm == 11 }} steps: - name: Checkout uses: actions/checkout@v4 @@ -187,15 +218,31 @@ jobs: # Install node dependencies node ./scripts/gtime.js monorepo_install npm ci + - name: Check Playwright Versions + run: |- + # Check the installed version of playwright + echo "Playwright version: $(npm list playwright --depth=0)" + + # Check the installed version of @playwright/test + echo "@playwright/test version: $(npm list @playwright/test --depth=0)" + + - name: Get Template Version + run: |- + version=`jq -r ".version" packages/template-retail-react-app/package.json` + echo "retail_app_template_version=$version" >> "$GITHUB_ENV" + - name: Generate Retail App With Extensibility + id: generate_app_ext uses: ./.github/actions/e2e_generate_app with: PROJECT_KEY: "retail-app-ext" + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} - name: Validated Generated Retail App Demo uses: ./.github/actions/e2e_validate_generated_app with: PROJECT_KEY: "retail-app-ext" + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} - name: Create MRT credentials file uses: "./.github/actions/create_mrt" @@ -206,16 +253,21 @@ jobs: - name: Push Bundle to MRT (E2E Test PWA Kit) uses: "./.github/actions/push_to_mrt" with: - CWD: "../generated-projects/retail-app-ext" + CWD: ${{ steps.generate_app_ext.outputs.project_path }} TARGET: e2e-tests-pwa-kit FLAGS: --wait - name: Install Playwright Browsers run: npx playwright install --with-deps + # this will not include a11y tests - name: Run Playwright tests run: npm run test:e2e + - name: Run a11y test for Node 22 with npm 11 + if: env.IS_MRT_NODE == 'true' + run: npm run test:e2e:a11y + notify-slack-pwa-ext: needs: [run-generator-retail-app-ext] if: ${{ always() }} @@ -250,19 +302,19 @@ jobs: # Run one matrix env at a time because we need to deploy each app to MRT and run e2e tests there max-parallel: 1 matrix: - # Run all matrix env at once because we will not deploy demo app to MRT. - node: [16, 18, 20] - npm: [8, 9, 10] - exclude: # node 16 is not compatible with npm 10 - - node: 16 - npm: 10 + # Run all matrix env at once because we will not deploy demo app to MRT. + node: [18, 20, 22] + npm: [8, 9, 10, 11] + exclude: # node 18 with npm 11 is not compatible + - node: 18 + npm: 11 runs-on: ubuntu-latest env: # The "default" npm is the one that ships with a given version of node. # For more: https://nodejs.org/en/download/releases/ # (We also use this env var for making sure a step runs once for the current node version) # Note: For node 18, the default was npm 9 until v18.19.0, when it became npm 10 - IS_DEFAULT_NPM: ${{ (matrix.node == 16 && matrix.npm == 8) || (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) }} + IS_DEFAULT_NPM: ${{ (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) || (matrix.node == 22 && matrix.npm == 10) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -282,10 +334,31 @@ jobs: # Install node dependencies node ./scripts/gtime.js monorepo_install npm ci + - name: Check Playwright Versions + run: |- + # Check the installed version of playwright + echo "Playwright version: $(npm list playwright --depth=0)" + + # Check the installed version of @playwright/test + echo "@playwright/test version: $(npm list @playwright/test --depth=0)" + + - name: Get Template Version + run: |- + version=`jq -r ".version" packages/template-retail-react-app/package.json` + echo "retail_app_template_version=$version" >> "$GITHUB_ENV" + - name: Generate Retail App Private Client + id: generate_app_private_client uses: ./.github/actions/e2e_generate_app with: PROJECT_KEY: "retail-app-private-client" + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} + + - name: Validate Generated Retail App Private Client + uses: ./.github/actions/e2e_validate_generated_app + with: + PROJECT_KEY: "retail-app-private-client" + TEMPLATE_VERSION: ${{ env.retail_app_template_version }} - name: Create MRT credentials file uses: "./.github/actions/create_mrt" @@ -296,16 +369,20 @@ jobs: - name: Push Bundle to MRT uses: "./.github/actions/push_to_mrt" with: - CWD: "../generated-projects/retail-app-private-client" + CWD: ${{ steps.generate_app_private_client.outputs.project_path }} TARGET: e2e-pwa-kit-private FLAGS: --wait - name: Set Retail App Private Client Home run: export RETAIL_APP_HOME=https://scaffold-pwa-e2e-pwa-kit-private.mobify-storefront.com/ + - name: Set PWA Kit E2E Test User + run: export PWA_E2E_USER_EMAIL=e2e.pwa.kit@gmail.com PWA_E2E_USER_PASSWORD=hpv_pek-JZK_xkz0wzf + - name: Install Playwright Browsers run: npx playwright install --with-deps + # this will not include a11y tests - name: Run Playwright tests run: npm run test:e2e @@ -337,3 +414,25 @@ jobs: } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + test-extra-features: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install Monorepo Dependencies + run: node ./scripts/gtime.js monorepo_install npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Set PWA Kit E2E Test User + run: export PWA_E2E_USER_EMAIL=e2e.pwa.kit@gmail.com PWA_E2E_USER_PASSWORD=hpv_pek-JZK_xkz0wzf + - name: Run extra features tests + env: + PWA_E2E_USER_EMAIL: e2e.pwa.kit@gmail.com + PWA_E2E_USER_PASSWORD: hpv_pek-JZK_xkz0wzf + run: npm run test:e2e:extra_features diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..0cb18d67bf --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,113 @@ +# WARNING! Conditionals are set as variables to minimize repetitive checks. +# However, this results in the variables being the *string* values "true" or "false". +# As a result, you must always explicitly check for those strings. For example, +# ${{ env.DEVELOP }} will ALWAYS evaluate as true; to achieve the expected result +# you must check ${{ env.DEVELOP == 'true' }}. There's probably a better way to DRY, +# but this is what we have for now. + +name: SalesforceCommerceCloud/pwa-kit/lint +on: + pull_request: # Default: opened, reopened, synchronize (head branch updated) + merge_group: # Trigger GA workflow when a pull request is added to a merge queue. + push: + branches: + - develop + - 'release-*' + +jobs: + pwa-kit-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 # Use latest LTS version for linting + cache: npm + + - name: Setup Ubuntu Machine + uses: "./.github/actions/setup_ubuntu" + + - name: Run linting + uses: "./.github/actions/linting" + + generated-project-lint: + strategy: + fail-fast: false + matrix: + template: [retail-react-app-test-project, retail-react-app-demo] + runs-on: ubuntu-latest + env: + PROJECT_DIR: generated-${{ matrix.template }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Ubuntu Machine + uses: "./.github/actions/setup_ubuntu" + + - name: Generate ${{ matrix.template }} project + run: |- + node packages/pwa-kit-create-app/scripts/create-mobify-app-dev.js --outputDir ${{ env.PROJECT_DIR }} + env: + GENERATOR_PRESET: ${{ matrix.template }} + timeout-minutes: 8 + + - name: Lint the generated project + uses: "./.github/actions/linting" + with: + cwd: ${{ env.PROJECT_DIR }} + + - name: Store Verdaccio logfile artifact + uses: actions/upload-artifact@v4 + with: + name: verdaccio-log-lint-${{ matrix.template }} + path: packages/pwa-kit-create-app/local-npm-repo/verdaccio-${{ matrix.template }}.log + + generated-project-lint-windows: + strategy: + fail-fast: false + matrix: + template: [retail-react-app-test-project, retail-react-app-demo] + runs-on: windows-latest + env: + PROJECT_DIR: generated-${{ matrix.template }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Windows Machine + uses: "./.github/actions/setup_windows" + + - name: Generate ${{ matrix.template }} project + run: |- + node packages/pwa-kit-create-app/scripts/create-mobify-app-dev.js --outputDir ${{ env.PROJECT_DIR }} + env: + GENERATOR_PRESET: ${{ matrix.template }} + timeout-minutes: 7 + + - name: Lint the generated project + uses: "./.github/actions/linting" + with: + cwd: ${{ env.PROJECT_DIR }} + + - name: Store Verdaccio logfile artifact + uses: actions/upload-artifact@v4 + with: + name: verdaccio-log-lint-windows-${{ matrix.template }} + path: packages/pwa-kit-create-app/local-npm-repo/verdaccio-windows-${{ matrix.template }}.log + diff --git a/.github/workflows/nightly_release.yml b/.github/workflows/nightly_release.yml index a0e1a89d6e..8189407e5f 100644 --- a/.github/workflows/nightly_release.yml +++ b/.github/workflows/nightly_release.yml @@ -12,7 +12,7 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -42,9 +42,9 @@ jobs: echo "commerce_sdk_react_version_base=$version" >> "$GITHUB_ENV" - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 cache: npm - name: Install Monorepo Dependencies diff --git a/.github/workflows/performance-metrics.yml b/.github/workflows/performance-metrics.yml new file mode 100644 index 0000000000..149efb6cc8 --- /dev/null +++ b/.github/workflows/performance-metrics.yml @@ -0,0 +1,76 @@ +name: SalesforceCommerceCloud/pwa-kit/performance-metrics + +on: + workflow_dispatch: # Allows you to manually run the workflow on any branch. + schedule: + # Run every day at 7pm (PST) - cron uses UTC times. + # We want to run it before the performance tests are scheduled to run. + # Runs on the default branch. + - cron: '0 3 * * *' + +jobs: + run-performance-metrics: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install Monorepo Dependencies + run: |- + npm ci + + - name: Generate App + id: generate_app + uses: ./.github/actions/e2e_generate_app + with: + PROJECT_KEY: "retail-react-app-performance-tests" + + - name: Create MRT credentials file + uses: './.github/actions/create_mrt' + with: + mobify_user: ${{ secrets.MRT_STG_Q4_PWA_PERF_API_USER }} + mobify_api_key: ${{ secrets.MRT_STG_Q4_PWA_PERF_API_KEY }} + + - name: Push Bundle to MRT + uses: './.github/actions/push_to_mrt' + with: + CWD: '${{ steps.generate_app.outputs.project_path }}' + PROJECT: q4-pwa-perf-develop + TARGET: production + CLOUD_ORIGIN: ${{ vars.MRT_STG_CLOUD_ORIGIN }} + FLAGS: --wait + + notify-slack: + needs: [run-performance-metrics] + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Send GitHub Action data to Slack workflow (Performance Metrics Success) + id: slack-success + if: ${{ github.event_name == 'schedule' && needs.run-performance-metrics.result == 'success' }} + uses: slackapi/slack-github-action@v1.23.0 + with: + payload: | + { + "message": "✅ Performance metrics workflow: successfully deployed bundle for performance testing." + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + - name: Send GitHub Action data to Slack workflow (Performance Metrics Failure) + id: slack-failure + if: ${{ github.event_name == 'schedule' && needs.run-performance-metrics.result != 'success' }} + uses: slackapi/slack-github-action@v1.23.0 + with: + payload: | + { + "message": "❌ Performance metrics workflow: please check the logs for details. (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/setup_pwa_manual.yml b/.github/workflows/setup_pwa_manual.yml new file mode 100644 index 0000000000..15d87201b7 --- /dev/null +++ b/.github/workflows/setup_pwa_manual.yml @@ -0,0 +1,94 @@ +name: SalesforceCommerceCloud/pwa-kit/setup_pwa_manual +on: + workflow_dispatch: + inputs: + use_extensibility: + type: boolean + description: Use Extensibility? + default: true + project_id: + type: string + description: Project ID/Name + default: "scaffold-pwa" + instance_url: + type: string + description: Instance Url + org_id: + type: string + description: Org Id + short_code: + type: string + description: Short Code + client_id: + type: string + description: Client Id + site_id: + type: string + description: Site Id + mrt_target_id: + type: string + description: MRT Target ID/Name + is_private_client: + type: boolean + description: Is Private Client? + setup_hybrid: + type: boolean + description: Setup Phased Headless rollout? + default: false + +jobs: + setup-pwa-kit: + runs-on: ubuntu-latest + steps: + - name: Initialize + id: initialize + run: | + set -e + echo "PROJECT_DIR=my-retail-react-app" >> $GITHUB_ENV + + - name: Checkout + id: checkout + uses: actions/checkout@v4 + + - name: Setup node + id: setup_node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "npm" + + - name: Setup PWA dependencies + id: setup_pwa_dependencies + run: |- + set -e + sudo apt-get update -yq && sudo apt-get install --no-install-recommends python2 python3-pip time -yq + sudo pip install -U pip setuptools + sudo pip install awscli==1.18.85 datadog==0.40.1 + node ./scripts/gtime.js monorepo_install npm ci + npm ci + shell: bash + + - name: Generate app + id: generate_app + uses: "./.github/actions/generate_app" + with: + use_extensibility: ${{ github.event.inputs.use_extensibility }} + project_id: ${{ github.event.inputs.project_id }} + instance_url: ${{ github.event.inputs.instance_url }} + org_id: ${{ github.event.inputs.org_id }} + short_code: ${{ github.event.inputs.short_code }} + client_id: ${{ github.event.inputs.client_id }} + site_id: ${{ github.event.inputs.site_id }} + is_private_client: ${{ github.event.inputs.is_private_client }} + setup_hybrid: ${{ github.event.inputs.setup_hybrid }} + project_dir: ${{ env.PROJECT_DIR }} + + - name: Deploy app + id: deploy_app + uses: "./.github/actions/deploy_app" + with: + project_id: ${{ github.event.inputs.project_id }} + target_id: ${{ github.event.inputs.mrt_target_id }} + project_dir: "${{ steps.generate_app.outputs.project_path }}" + mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} + mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} diff --git a/.github/workflows/setup_template_retail_react_app_manual.yml b/.github/workflows/setup_template_retail_react_app_manual.yml new file mode 100644 index 0000000000..44d647a058 --- /dev/null +++ b/.github/workflows/setup_template_retail_react_app_manual.yml @@ -0,0 +1,50 @@ +name: SalesforceCommerceCloud/pwa-kit/setup_template_retail_react_app +on: + workflow_dispatch: + +jobs: + setup-template-retail-react-app: + runs-on: ubuntu-latest + steps: + - name: Initialize + id: initialize + shell: bash + run: | + set -e + echo "PROJECT_ID=scaffold-pwa" >> $GITHUB_ENV + branch_name="${GITHUB_REF##*/}" + # Trim using cut as MRT target name is limited to 19 chars + mrt_target_id=$(echo "$branch_name" | cut -c 1-19) + echo "MRT_TARGET_ID=$mrt_target_id" >> $GITHUB_ENV + + - name: Checkout + id: checkout + uses: actions/checkout@v4 + + - name: Setup node + id: setup_node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "npm" + + - name: Setup PWA dependencies + id: setup_pwa_dependencies + run: |- + set -e + sudo apt-get update -yq && sudo apt-get install --no-install-recommends python2 python3-pip time -yq + sudo pip install -U pip setuptools + sudo pip install awscli==1.18.85 datadog==0.40.1 + node ./scripts/gtime.js monorepo_install npm ci + npm ci + shell: bash + + - name: Deploy app + id: deploy_app + uses: "./.github/actions/deploy_app" + with: + project_id: ${{ env.PROJECT_ID }} + target_id: ${{ env.MRT_TARGET_ID }} + project_dir: "./packages/template-retail-react-app" + mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} + mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} diff --git a/.github/workflows/sync_extra_features_e2e.yml b/.github/workflows/sync_extra_features_e2e.yml new file mode 100644 index 0000000000..5bfff89a23 --- /dev/null +++ b/.github/workflows/sync_extra_features_e2e.yml @@ -0,0 +1,170 @@ +name: Sync Extra Features E2E Branch with Develop + +on: + # Trigger when develop branch is updated + push: + branches: + - develop + # Run daily at 11 PM PST (7 AM UTC) to catch any missed syncs + schedule: + - cron: '0 7 * * *' + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: read + +jobs: + sync-branch: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config --global user.name ${{ secrets.GIT_CONFIG_USERNAME }} + git config --global user.email ${{ secrets.GIT_CONFIG_EMAIL }} + + - name: Sync extra-features-e2e-branch with develop + id: sync + continue-on-error: true + run: | + set -e + + # Fetch all branches + git fetch origin + + # Check if the target branch exists + if ! git show-ref --verify --quiet refs/remotes/origin/extra-features-e2e-branch; then + echo "Branch extra-features-e2e-branch does not exist. Creating it from develop..." + git checkout -b extra-features-e2e-branch origin/develop + git push origin extra-features-e2e-branch + echo "✅ Created extra-features-e2e-branch branch from develop" + echo "status=created" >> $GITHUB_OUTPUT + exit 0 + fi + + # Switch to the target branch + git checkout extra-features-e2e-branch + git reset --hard origin/extra-features-e2e-branch + + # Check if we're already up to date + if git merge-base --is-ancestor origin/develop HEAD; then + echo "✅ extra-features-e2e-branch is already up to date with develop" + echo "status=up-to-date" >> $GITHUB_OUTPUT + exit 0 + fi + + if git merge origin/develop --no-edit; then + echo "✅ Successfully merged develop into extra-features-e2e-branch" + git push origin extra-features-e2e-branch + echo "status=merged" >> $GITHUB_OUTPUT + else + echo "❌ Merge conflicts detected!" + echo "📋 Files with conflicts:" + git diff --name-only --diff-filter=U || true + git merge --abort + echo "status=conflict" >> $GITHUB_OUTPUT + exit 1 + fi + + - name: Create conflict resolution issue + if: steps.sync.outputs.status == 'conflict' + uses: actions/github-script@v7 + with: + script: | + const conflictFiles = `${{ steps.sync.outputs.conflict_files || 'Unknown files' }}`; + const issueBody = ` + ## 🚨 Automatic Sync Failed - Merge Conflicts Detected + + Some features in PWA kit are defaulted to be off in \`develop\` branch (e.g. if they only work with private client). + Hence there is another site that has these features enabled that E2E tests of these extra features are run against. + The \`extra-features-e2e-branch\` is used for this site. + + A job is run nightly to sync \`develop\` to \`extra-features-e2e-branch\` branch, but today this automatic sync from \`develop\` to \`extra-features-e2e-branch\` failed due to merge conflicts. + + ### Conflicting Files: + \`\`\` + ${conflictFiles} + \`\`\` + + + ### Manual Resolution: + \`\`\`bash + git checkout extra-features-e2e-branch + git pull origin extra-features-e2e-branch + git merge develop + # Resolve conflicts manually + git add . + git commit -m "Resolve merge conflicts from develop" + git push origin extra-features-e2e-branch + \`\`\` + + ### After resolving the conflicts, close this issue + `; + + // Check if issue already exists + const existingIssues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-conflict' + }); + + if (existingIssues.data.length === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '🚨 Extra Features E2E Branch Sync Conflict - Manual Resolution Required', + body: issueBody, + labels: ['sync-conflict', 'automation'] + }); + } + + deploy: + needs: sync-branch + if: needs.sync-branch.outputs.status != 'conflict' && needs.sync-branch.outputs.status != 'up-to-date' + runs-on: ubuntu-latest + environment: extra-features-e2e + + steps: + - name: Checkout extra-features-e2e-branch branch + uses: actions/checkout@v4 + with: + ref: extra-features-e2e-branch + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install Monorepo Dependencies + run: | + # Install node dependencies + node ./scripts/gtime.js monorepo_install npm ci + + - name: Build project + run: | + cd packages/template-retail-react-app + npm run build + + - name: Create MRT credentials file + uses: "./.github/actions/create_mrt" + with: + mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} + mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} + + - name: Deploy to MRT + uses: "./.github/actions/push_to_mrt" + with: + CWD: "./packages/template-retail-react-app" + TARGET: extra-features-e2e + PROJECT: scaffold-pwa + MESSAGE: "Auto-sync from develop - build ${{ github.run_id }} (${{ github.sha }})" + FLAGS: --wait \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70571656c4..152d6be30b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,37 +22,56 @@ on: schedule: # Run every day at 12am (PST) - cron uses UTC times - cron: '0 8 * * *' + env: IS_NOT_FORK: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} DEVELOP: ${{ (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && (github.head_ref || github.ref_name) == 'develop' }} RELEASE: ${{ (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && startsWith(github.head_ref || github.ref_name, 'release-') }} jobs: + changelog-check: + if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + steps: + - name: Auto-pass for non-PR events + if: env.DEVELOP == 'true' + run: exit 0 + + - name: Checkout + if: env.DEVELOP != 'true' + uses: actions/checkout@v4 + + - name: Changelog Check + if: env.DEVELOP != 'true' + uses: ./.github/actions/changelog-check + with: + pr_number: ${{ github.event.pull_request.number }} + pwa-kit: + needs: changelog-check strategy: fail-fast: false matrix: - node: [16, 18, 20] - npm: [8, 9, 10] - exclude: # node 16 is not compatible with npm 10 - - node: 16 - npm: 10 + node: [18, 20, 22] + npm: [8, 9, 10, 11] + exclude: # node 18 with npm 11 are not compatible + - node: 18 + npm: 11 runs-on: ubuntu-latest env: # The "default" npm is the one that ships with a given version of node. # For more: https://nodejs.org/en/download/releases/ # (We also use this env var for making sure a step runs once for the current node version) - # Note: For node 18, the default was npm 9 until v18.19.0, when it became npm 10 - IS_DEFAULT_NPM: ${{ (matrix.node == 16 && matrix.npm == 8) || (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) }} + IS_DEFAULT_NPM: ${{ (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) || (matrix.node == 22 && matrix.npm == 10) }} # The current recommended version for Managed Runtime: # https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/upgrade-node-version.html - IS_MRT_NODE: ${{ matrix.node == 20 && matrix.npm == 10 }} + IS_MRT_NODE: ${{ matrix.node == 22 && matrix.npm == 10 }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: npm @@ -68,8 +87,7 @@ jobs: - name: Run unit tests uses: "./.github/actions/unit_tests" - - name: Run bundlesize test - uses: "./.github/actions/bundle_size_test" + - name: Smoke test scripts if: env.IS_DEFAULT_NPM == 'true' @@ -82,6 +100,7 @@ jobs: mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} + # TODO - Do we still need this? - name: Push Bundle to MRT (Development) if: env.IS_NOT_FORK == 'true' && env.IS_MRT_NODE == 'true' && env.DEVELOP == 'true' uses: "./.github/actions/push_to_mrt" @@ -89,13 +108,6 @@ jobs: CWD: "./packages/template-retail-react-app" TARGET: staging - - name: Push Bundle to MRT (Production) - if: env.IS_NOT_FORK == 'true' && env.IS_MRT_NODE == 'true' && env.RELEASE == 'true' - uses: "./.github/actions/push_to_mrt" - with: - CWD: "./packages/template-retail-react-app" - TARGET: production - - name: Push Bundle to MRT (Commerce SDK React) if: env.IS_NOT_FORK == 'true' && env.IS_MRT_NODE == 'true' && env.DEVELOP == 'true' uses: "./.github/actions/push_to_mrt" @@ -125,30 +137,31 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} pwa-kit-windows: + needs: changelog-check strategy: fail-fast: false matrix: - node: [16, 18, 20] - npm: [8, 9, 10] - exclude: # node 16 is not compatible with npm 10 - - node: 16 - npm: 10 + node: [18, 20, 22] + npm: [8, 9, 10, 11] + exclude: # node 18 with npm 11 is not compatible + - node: 18 + npm: 11 + runs-on: windows-latest env: # The "default" npm is the one that ships with a given version of node. # For more: https://nodejs.org/en/download/releases/ # (We also use this env var for making sure a step runs once for the current node version) # Note: For node 18, the default was npm 9 until v18.19.0, when it became npm 10 - IS_DEFAULT_NPM: ${{ (matrix.node == 16 && matrix.npm == 8) || (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) }} + IS_DEFAULT_NPM: ${{ (matrix.node == 18 && matrix.npm == 10) || (matrix.node == 20 && matrix.npm == 10) || (matrix.node == 22 && matrix.npm == 10) }} # The current recommended version for Managed Runtime: # https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/upgrade-node-version.html - IS_MRT_NODE: ${{ matrix.node == 20 && matrix.npm == 10 }} - runs-on: windows-latest + IS_MRT_NODE: ${{ matrix.node == 22 && matrix.npm == 10 }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: npm @@ -167,6 +180,7 @@ jobs: # TODO: The generated workflow is identical to the generated-windows workflow, # with a few extra steps. Can the workflows be merged? (Add `os` to the matrix?) generated: + needs: changelog-check strategy: fail-fast: false matrix: @@ -177,12 +191,12 @@ jobs: PROJECT_DIR: generated-${{ matrix.template }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Setup Ubuntu Machine uses: "./.github/actions/setup_ubuntu" @@ -194,11 +208,7 @@ jobs: GENERATOR_PRESET: ${{ matrix.template }} timeout-minutes: 8 - - name: Lint the generated project - if: env.IS_TEMPLATE_FROM_RETAIL_REACT_APP == 'true' - uses: "./.github/actions/linting" - with: - cwd: ${{ env.PROJECT_DIR }} + - name: Run unit tests if: env.IS_TEMPLATE_FROM_RETAIL_REACT_APP == 'true' @@ -218,9 +228,10 @@ jobs: uses: "./.github/actions/count_deps" - name: Store Verdaccio logfile artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - path: packages/pwa-kit-create-app/local-npm-repo/verdaccio.log + name: verdaccio-log-${{ matrix.template }} + path: packages/pwa-kit-create-app/local-npm-repo/verdaccio-${{ matrix.template }}.log # TODO: Ticket W-12425059. Revisit Snyk CLI integration to monitor manifest files on generated projects. # TODO: Update the SNYK_TOKEN stored in GitHub with a token generated from the proper Snyk org. @@ -264,6 +275,7 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} generated-windows: + needs: changelog-check strategy: fail-fast: false matrix: @@ -274,12 +286,12 @@ jobs: PROJECT_DIR: generated-${{ matrix.template }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Setup Windows Machine uses: "./.github/actions/setup_windows" @@ -291,11 +303,7 @@ jobs: GENERATOR_PRESET: ${{ matrix.template }} timeout-minutes: 7 - - name: Lint the generated project - if: env.IS_TEMPLATE_FROM_RETAIL_REACT_APP == 'true' - uses: "./.github/actions/linting" - with: - cwd: ${{ env.PROJECT_DIR }} + - name: Run unit tests if: env.IS_TEMPLATE_FROM_RETAIL_REACT_APP == 'true' @@ -314,21 +322,23 @@ jobs: uses: "./.github/actions/count_deps" - name: Store Verdaccio logfile artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - path: packages/pwa-kit-create-app/local-npm-repo/verdaccio.log + name: verdaccio-log-${{ matrix.template }} + path: packages/pwa-kit-create-app/local-npm-repo/verdaccio-windows-${{ matrix.template }}.log lighthouse: + needs: changelog-check strategy: fail-fast: false matrix: - node: [18] # Should match node version supported by MRT. + node: [22] # Should match node version supported by MRT. runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: npm diff --git a/.github/workflows/validate-codeowners.yml b/.github/workflows/validate-codeowners.yml new file mode 100644 index 0000000000..cfa1a452e0 --- /dev/null +++ b/.github/workflows/validate-codeowners.yml @@ -0,0 +1,32 @@ +name: Validate CODEOWNERS + +# Only run when CODEOWNERS file changes - efficient! +on: + pull_request: + paths: + - '.github/CODEOWNERS' + push: + branches: + - develop + - 'release-*' + paths: + - '.github/CODEOWNERS' + +jobs: + validate: + name: Validate CODEOWNERS File + runs-on: ubuntu-latest + # Only run on pushes or PRs from the same repository (not forks) + # This prevents permission issues with GITHUB_TOKEN on external PRs + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Validate CODEOWNERS structure + uses: mszostok/codeowners-validator@v0.7.4 + with: + # files: paths exist, duppatterns: no duplicates, syntax: valid format + checks: "files,duppatterns,syntax" + github_access_token: "${{ secrets.GITHUB_TOKEN }}" \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..e4e5e605db --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +build +docs +coverage +scripts/generator/assets +app/static diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000000..33069bf2b2 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,7 @@ +printWidth: 100 +singleQuote: true +semi: false +bracketSpacing: false +tabWidth: 4 +arrowParens: 'always' +trailingComma: 'none' diff --git a/e2e/config.js b/e2e/config.js index 363012cf3a..d95b76502e 100644 --- a/e2e/config.js +++ b/e2e/config.js @@ -9,6 +9,7 @@ module.exports = { RETAIL_APP_HOME: process.env.RETAIL_APP_HOME || "https://scaffold-pwa-e2e-tests-pwa-kit.mobify-storefront.com", + RETAIL_APP_HOME_SITE: "RefArch", GENERATED_PROJECTS_DIR: "../generated-projects", GENERATE_PROJECTS: ["retail-app-demo", "retail-app-ext", "retail-app-no-ext"], GENERATOR_CMD: @@ -101,12 +102,19 @@ module.exports = { }, ], "retail-app-private-client": [], + "retail-react-app-bug-bounty": [], + "retail-react-app-demo-site": [], + "retail-react-app-performance-tests": [], }, PRESET: { "retail-app-private-client": "retail-react-app-private-slas-client", + "retail-react-app-bug-bounty": "retail-react-app-bug-bounty", + "retail-react-app-demo-site": "retail-react-app-demo-site-internal", + "retail-react-app-performance-tests": "retail-react-app-performance-tests" }, EXPECTED_GENERATED_ARTIFACTS: { "retail-app-demo": [ + ".cursor", ".eslintignore", ".eslintrc.js", ".prettierrc.yaml", @@ -120,6 +128,7 @@ module.exports = { "worker", ], "retail-app-ext": [ + ".cursor", ".eslintignore", ".eslintrc.js", ".prettierrc.yaml", @@ -156,4 +165,8 @@ module.exports = { "worker", ], }, + PWA_E2E_USER_EMAIL: process.env.PWA_E2E_USER_EMAIL, + PWA_E2E_USER_PASSWORD: process.env.PWA_E2E_USER_PASSWORD, + EXTRA_FEATURES_E2E_RETAIL_APP_HOME: "https://scaffold-pwa-extra-features-e2e.mobify-storefront.com", + EXTRA_FEATURES_E2E_RETAIL_APP_HOME_SITE: "RefArchGlobal" }; diff --git a/e2e/scripts/execute-shell-commands.js b/e2e/scripts/execute-shell-commands.js index d9e7308ea1..b46afadb8d 100644 --- a/e2e/scripts/execute-shell-commands.js +++ b/e2e/scripts/execute-shell-commands.js @@ -5,57 +5,57 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -const { exec } = require("child_process"); -const { isPrompt } = require("./utils.js"); +const {exec} = require('child_process') +const {isPrompt} = require('./utils.js') const runGeneratorWithResponses = (cmd, cliResponses = []) => { - const child = exec(cmd); - return new Promise((resolve, reject) => { - let expectedPrompt, response; - if (cliResponses && cliResponses.length) { - ({ expectedPrompt, response } = cliResponses.shift()); - } - - child.stdout.on("data", (data) => { - console.log(data); - if (isPrompt(data, expectedPrompt)) { - child.stdin.write(response); - if (cliResponses.length > 0) { - ({ expectedPrompt, response } = cliResponses.shift()); + const child = exec(cmd) + return new Promise((resolve, reject) => { + let expectedPrompt, response + if (cliResponses && cliResponses.length) { + ;({expectedPrompt, response} = cliResponses.shift()) } - } - }); - child.stderr.on("data", (err) => { - console.error(err); - }); - - child.on("error", (code) => { - reject(`Child process exited with code ${code}.`); - }); - - child.on("close", (code) => { - resolve(`Child process exited with code ${code}.`); - }); - }); -}; + child.stdout.on('data', (data) => { + console.log(data) + if (isPrompt(data, expectedPrompt)) { + child.stdin.write(response) + if (cliResponses.length > 0) { + ;({expectedPrompt, response} = cliResponses.shift()) + } + } + }) + + child.stderr.on('data', (err) => { + console.error(err) + }) + + child.on('error', (code) => { + reject(`Child process exited with code ${code}.`) + }) + + child.on('close', (code) => { + resolve(`Child process exited with code ${code}.`) + }) + }) +} const executeCommand = (cmd) => { - return new Promise((resolve, reject) => { - exec(cmd, (error, stdout, stderr) => { - if (error) { - reject(error.message); - } - if (stderr) { - reject(stderr); - } - - resolve(stdout); - }); - }); -}; + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + if (error) { + reject(error.message) + } + if (stderr) { + reject(stderr) + } + + resolve(stdout) + }) + }) +} module.exports = { - runGeneratorWithResponses, - executeCommand, -}; + runGeneratorWithResponses, + executeCommand +} diff --git a/e2e/scripts/generate-project.js b/e2e/scripts/generate-project.js index 7414904e7b..c1cd335dcd 100644 --- a/e2e/scripts/generate-project.js +++ b/e2e/scripts/generate-project.js @@ -4,56 +4,90 @@ * 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 { runGeneratorWithResponses } = require("./execute-shell-commands.js"); -const config = require("../config.js"); -const { program, Argument } = require("commander"); -const { mkdirIfNotExists } = require("./utils.js"); +const {runGeneratorWithResponses} = require('./execute-shell-commands.js') +const config = require('../config.js') +const {program} = require('commander') +const {mkdirIfNotExists} = require('./utils.js') const main = async (opts) => { - const { args } = opts; - const [project] = args; - if (opts.args.length !== 1) { - console.log(program.helpInformation()); - process.exit(1); - } + const {projectKey, projectConfig, templateVersion} = opts - try { - // Explicitly create outputDir because generator runs into permissions issue when generating no-ext projects. - await mkdirIfNotExists(config.GENERATED_PROJECTS_DIR); - const outputDir = `${config.GENERATED_PROJECTS_DIR}/${project}`; - // TODO: Update script to setup local verdaccio npm repo to allow running 'npx @salesforce/pwa-kit-create-app' to generate apps - let generateAppCommand = `${config.GENERATOR_CMD} ${outputDir}`; - const preset = config.PRESET[project]; - - if (preset) { - generateAppCommand = `${config.GENERATOR_CMD} ${outputDir} --preset ${preset}` + if (!projectKey && !projectConfig) { + console.error('You must provide either or .') + console.log(program.helpInformation()) + process.exit(1) } - - const stdout = await runGeneratorWithResponses( - generateAppCommand, - config.CLI_RESPONSES[project] - ); - return stdout; - } catch (err) { - // Generator failed to create project - console.log("Generator failed to create project", err); - process.exit(1); - } -}; -program.description( - `Generate a retail-react-app project using the key ` -); + try { + let cliResponses = [] + let projectDir = projectKey + let preset + if (projectKey) { + cliResponses = config.CLI_RESPONSES[projectKey] + preset = config.PRESET[projectKey] + } else { + projectDir = projectConfig['projectDir'] + let cliResponsesJsonArr = projectConfig['responses'] + cliResponsesJsonArr.forEach((item) => { + cliResponses.push({ + expectedPrompt: new RegExp(item.expectedPrompt, 'i'), + response: item.response + }) + }) + } + + // Explicitly create outputDir because generator runs into permissions issue when generating no-ext projects. + await mkdirIfNotExists(config.GENERATED_PROJECTS_DIR) + const outputDir = `${config.GENERATED_PROJECTS_DIR}/${projectDir}` + let generateAppCommand = `${config.GENERATOR_CMD} ${outputDir}` + // TODO: Update script to setup local verdaccio npm repo to allow running 'npx @salesforce/pwa-kit-create-app' to generate apps + if (preset) { + generateAppCommand = `${config.GENERATOR_CMD} ${outputDir} --preset ${preset}` + } -program.addArgument( - new Argument("", "project key").choices([ - "retail-app-demo", - "retail-app-ext", - "retail-app-no-ext", - "retail-app-private-client", - ]) -); + if (templateVersion) { + generateAppCommand = `${generateAppCommand} --templateVersion ${templateVersion}` + } + console.log('Running command:', generateAppCommand) + return await runGeneratorWithResponses(generateAppCommand, cliResponses) + } catch (err) { + // Generator failed to create project + console.error('Generator failed to create project', err) + process.exit(1) + } +} -program.parse(process.argv); +// Define the program with description and arguments +program + .description( + 'Generate a retail-react-app project using the key or the JSON ' + ) + .option('--project-key ', 'Project key', (value) => { + const validKeys = [ + 'retail-app-demo', + 'retail-app-ext', + 'retail-app-no-ext', + 'retail-app-private-client', + 'retail-react-app-bug-bounty', + 'retail-react-app-demo-site', + 'retail-react-app-performance-tests' + ] + if (!validKeys.includes(value)) { + throw new Error('Invalid project key.') + } + return value + }) + .option('--project-config ', 'Project config as JSON string', (value) => { + try { + return JSON.parse(value) + } catch (e) { + throw new Error('Invalid JSON string.') + } + }) + .option('--templateVersion ', 'Template version used to generate the project') + .action((options) => { + // Call the main function with parsed options + main(options) + }) -main(program); +program.parse(process.argv) diff --git a/e2e/scripts/pageHelpers.js b/e2e/scripts/pageHelpers.js new file mode 100644 index 0000000000..5c55d3b95c --- /dev/null +++ b/e2e/scripts/pageHelpers.js @@ -0,0 +1,794 @@ +const {expect} = require('@playwright/test') +const config = require('../config') +const {getCreditCardExpiry, runAccessibilityTest} = require('../scripts/utils.js') +/** + * 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, isMobile = false}) => { + // 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 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 {} + + 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/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 + }) + + let hasShippingStep = false + try { + await expect(continueToPayment).toBeVisible({timeout: 2000}) + await continueToPayment.click() + hasShippingStep = true + } catch { + // Shipping step was skipped, proceed directly to payment + } + + // Verify step-2 edit button only if shipping step was present + if (hasShippingStep) { + const step2Card = page.locator("div[data-testid='sf-toggle-card-step-2']") + await expect(step2Card.getByRole('button', {name: /Edit/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() + + // Confirm the shipping options form toggles to show edit button on clicking "Checkout as guest" + const step3Card = page.locator("div[data-testid='sf-toggle-card-step-3']") + + await expect(step3Card.getByRole('button', {name: /Edit/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() + } +} diff --git a/e2e/scripts/utils.js b/e2e/scripts/utils.js index 42dfe801e9..58bf36a0f9 100644 --- a/e2e/scripts/utils.js +++ b/e2e/scripts/utils.js @@ -4,54 +4,143 @@ * 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 { types } = require("util"); -const fs = require("fs"); -const promisify = require("util").promisify; -const statAsync = promisify(fs.stat); -const mkdirAsync = promisify(fs.mkdir); +const AxeBuilder = require('@axe-core/playwright') +const {expect} = require('@playwright/test') + +const {types} = require('util') +const fs = require('fs') +const promisify = require('util').promisify +const statAsync = promisify(fs.stat) +const mkdirAsync = promisify(fs.mkdir) const isPrompt = (streamData, expectedText) => { - if (!streamData || !expectedText) return false; + if (!streamData || !expectedText) return false - if (types.isRegExp(expectedText)) { - return streamData.match(expectedText); - } else return streamData.includes(expectedText); -}; + if (types.isRegExp(expectedText)) { + return streamData.match(expectedText) + } else return streamData.includes(expectedText) +} -const mkdirIfNotExists = (dirname) => - statAsync(dirname).catch(() => mkdirAsync(dirname)); +const mkdirIfNotExists = (dirname) => statAsync(dirname).catch(() => mkdirAsync(dirname)) const diffArrays = (expectedArr, actualArr) => { - const actualSet = new Set(actualArr); - return [...expectedArr].filter((x) => !actualSet.has(x)); -}; + const actualSet = new Set(actualArr) + return [...expectedArr].filter((x) => !actualSet.has(x)) +} const getCreditCardExpiry = (yearsFromNow = 5) => { - const padMonth = "00"; - return `${(padMonth + (new Date().getMonth() + 1)).slice(-padMonth.length)}/${ - (new Date().getFullYear() % 100) + parseInt(yearsFromNow) - }`; -}; + const padMonth = '00' + return `${(padMonth + (new Date().getMonth() + 1)).slice(-padMonth.length)}/${ + (new Date().getFullYear() % 100) + parseInt(yearsFromNow) + }` +} +/** + * Helper function to create simplified violation objects for snapshots + * + * @param {Array} violations - Array of axe-core violations + * @returns {Array} - Array of simplified violation objects + */ +function simplifyViolations(violations) { + return violations.map((violation) => ({ + id: violation.id, + // Severity of violation (critical, serious, moderate, minor) + impact: violation.impact, + description: violation.description, + help: violation.help, + helpUrl: violation.helpUrl, + nodes: violation.nodes.map((node) => ({ + // Simplify the HTML to make it more stable for snapshots + html: sanitizeHtml(node.html), + // Include the important failure information + failureSummary: node.failureSummary, + // Simplify target selectors for stability + // #app-header[data-v-12345] > .navigation[data-testid="main-nav"] => #app-header > .navigation + // Also handle Chakra UI dynamic selectors like #popover-trigger-:r5l4v: + target: node.target.map((t) => + t + .split(/\[.*?\]/).join('') // Remove data attributes + .replace(/#([^-\s]+(?:-[^:]*)?):([^"\s]*)/g, '#$1-...') // Remove Chakra UI dynamic IDs + .replace(/\.css-[a-zA-Z0-9]+/g, '.css-...') // Simplify Chakra UI CSS classes + ) + })) + })) +} +/** + * Helper function to strip dynamic content from HTML to make snapshots more stable + * + * @param {string} html - HTML string + * @returns {string} - HTML string with dynamic content removed + */ +function sanitizeHtml(html) { + return ( + html + // Remove IDs which may change + .replace(/id="[^"]*"/g, 'id="..."') + // Remove data attributes which may change + .replace(/data-[a-zA-Z0-9-]+="[^"]*"/g, '') + // Simplify classes which may change + .replace(/class="[^"]*"/g, 'class="..."') + // Remove inline styles which may change + .replace(/style="[^"]*"/g, '') + // Remove content of script tags + .replace(/]*>([\s\S]*?)<\/script>/gi, '') + // Dynamic values - keep stable part: + // Before: aria-controls="popover-content-:rn:" + // After: aria-controls="popover-content-..." + .replace(/(aria-(?:controls|describedby|labelledby|owns))="([^:]*?)(?::[^"]*)?"/g, '$1="$2..."') + // Trim whitespace + .trim() + ) +} +/** + * Runs an accessibility analysis on the current page + * + * @param {Page} page - Playwright page object + * @param {string|string[]} snapshotName - Name for the snapshot file + * @param {Object} options - Optional configuration + * @param {string[]} options.exclude - CSS selectors to exclude from scan + */ +async function runAccessibilityTest(page, snapshotName, options = {}) { + const {exclude = []} = options + + // Create AxeBuilder instance + let axeBuilder = new AxeBuilder({page}) + // Add exclusions if provided + if (exclude.length > 0) { + axeBuilder = axeBuilder.exclude(exclude) + } + + // Run the accessibility audit + const accessibilityScanResults = await axeBuilder.analyze() + + // console.log(`Found ${accessibilityScanResults.violations.length} accessibility violations`) + + // Create simplified versions of violations for more stable snapshots + const simplifiedViolations = simplifyViolations(accessibilityScanResults.violations) + + // Convert to JSON string for stable snapshot comparison + const violationsJson = JSON.stringify(simplifiedViolations, null, 2) + + // Compare with snapshot - using string comparison instead of object comparison + expect(violationsJson).toMatchSnapshot(snapshotName) +} /** * Generates a random string of given length containing uppercase letters, lowercase letters and numbers. * @param {number} length Length of generated string required. * @returns Randomly generated alphanumeric string. */ const generateRandomString = function (length) { - let randomString = ""; - const characters = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const charactersLength = characters.length; - let counter = 0; - while (counter < length) { - randomString += characters.charAt( - Math.floor(Math.random() * charactersLength) - ); - counter += 1; - } - return randomString; -}; + let randomString = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + let counter = 0 + while (counter < length) { + randomString += characters.charAt(Math.floor(Math.random() * charactersLength)) + counter += 1 + } + return randomString +} /** * Generates a random valid phone number string @@ -59,48 +148,47 @@ const generateRandomString = function (length) { * @returns Randomly generated numeric string. */ const generateRandomNumericString = function (length) { - // US Phone numbers must have the format NXX NXX-XXXX - // where N cannot be 0 or 1. - // The area code cannot have 9 in the 2nd digit - // The middle 3 digits cannot be N11 - - let randomPhone = ""; - const validNumbers = "23456789"; // exclude 0 or 1 to keep things simple - const validNumbersLength = validNumbers.length; - let counter = 0; - while (counter < length) { - randomPhone += validNumbers.charAt( - Math.floor(Math.random() * validNumbersLength) - ); - counter += 1; - } - return randomPhone; -}; + // US Phone numbers must have the format NXX NXX-XXXX + // where N cannot be 0 or 1. + // The area code cannot have 9 in the 2nd digit + // The middle 3 digits cannot be N11 + + let randomPhone = '' + const validNumbers = '23456789' // exclude 0 or 1 to keep things simple + const validNumbersLength = validNumbers.length + let counter = 0 + while (counter < length) { + randomPhone += validNumbers.charAt(Math.floor(Math.random() * validNumbersLength)) + counter += 1 + } + return randomPhone +} /** * Generates a random user object containing firstName, lastName, phone, email and password based on locale (Supports en_US and en_GB only). * @returns Object containing randomly generated user data. */ const generateUserCredentials = function () { - const user = {}; - user.firstName = generateRandomString(8); - user.lastName = generateRandomString(8); - user.phone = "857" + generateRandomNumericString(7); - user.email = (generateRandomString(12) + "@domain.com").toLowerCase(); - user.password = generateRandomString(15) + "Ab1!%&*$#@^+:;=?"; - user.address = {} - user.address.street = generateRandomString(10); - user.address.city = "Burlington"; - user.address.state = "MA"; - user.address.zipcode = "02" + generateRandomNumericString(3); - - return user; -}; + const user = {} + user.firstName = generateRandomString(8) + user.lastName = generateRandomString(8) + user.phone = '857' + generateRandomNumericString(7) + user.email = (generateRandomString(12) + '@domain.com').toLowerCase() + user.password = generateRandomString(15) + 'Ab1!%&*$#@^+:;=?' + user.address = {} + user.address.street = generateRandomString(10) + user.address.city = 'Burlington' + user.address.state = 'MA' + user.address.zipcode = '02' + generateRandomNumericString(3) + + return user +} module.exports = { - isPrompt, - mkdirIfNotExists, - diffArrays, - getCreditCardExpiry, - generateUserCredentials, -}; + isPrompt, + mkdirIfNotExists, + diffArrays, + getCreditCardExpiry, + generateUserCredentials, + runAccessibilityTest +} diff --git a/e2e/scripts/validate-generated-project.js b/e2e/scripts/validate-generated-project.js index 5ddeccebe4..3c0bfda07f 100644 --- a/e2e/scripts/validate-generated-project.js +++ b/e2e/scripts/validate-generated-project.js @@ -5,89 +5,88 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -const { program, Argument } = require("commander"); -const { diffArrays } = require("./utils.js"); -const fs = require("fs"); -const config = require("../config.js"); -const path = require("path"); +const {program, Argument} = require('commander') +const {diffArrays} = require('./utils.js') +const fs = require('fs') +const config = require('../config.js') +const path = require('path') const validateGeneratedArtifacts = async (project) => { - const generatedProjectDirPath = path.join( - process.cwd(), - config.GENERATED_PROJECTS_DIR, - project - ); - const generatedArtifacts = await fs.readdirSync(generatedProjectDirPath); + try { + const generatedProjectDirPath = path.join(process.cwd(), config.GENERATED_PROJECTS_DIR, project) + const generatedArtifacts = fs.readdirSync(generatedProjectDirPath) - return new Promise((resolve, reject) => { - const missingArtifacts = diffArrays( - config.EXPECTED_GENERATED_ARTIFACTS[project], - generatedArtifacts - ); - if (missingArtifacts && missingArtifacts.length > 0) { - reject( - `Generated project (${project}) is missing one or more artifacts: ${missingArtifacts}` - ); - } else { - resolve(`Successfully validated generated artifacts for: ${project} `); + return new Promise((resolve, reject) => { + const missingArtifacts = diffArrays( + config.EXPECTED_GENERATED_ARTIFACTS[project], + generatedArtifacts + ) + if (missingArtifacts && missingArtifacts.length > 0) { + reject( + `Generated project (${project}) is missing one or more artifacts: ${missingArtifacts}` + ) + } else { + resolve(`Successfully validated generated artifacts for: ${project} `) + } + }) + } catch (err) { + reject(`Generated project (${project}) is missing one or more artifacts: ${err}`) } - }); -}; +} -const validateExtensibilityConfig = async (project) => { - const pkgPath = path.join( - process.cwd(), - config.GENERATED_PROJECTS_DIR, - project, - "package.json" - ); - const pkg = require(pkgPath); - return new Promise((resolve, reject) => { - if ( - pkg.hasOwnProperty("ccExtensibility") && - pkg["ccExtensibility"].hasOwnProperty("extends") && - pkg["ccExtensibility"].hasOwnProperty("overridesDir") && - pkg["ccExtensibility"].extends === "@salesforce/retail-react-app" && - pkg["ccExtensibility"].overridesDir === "overrides" - ) { - resolve(`Successfully validated extensibility config for ${project}`); - } - reject( - `Generated project ${project} is missing extensibility config in package.json` - ); - }); -}; +const validateExtensibilityConfig = async (project, templateVersion) => { + const pkgPath = path.join(process.cwd(), config.GENERATED_PROJECTS_DIR, project, 'package.json') + const pkg = require(pkgPath) + return new Promise((resolve, reject) => { + if ( + !pkg.hasOwnProperty('ccExtensibility') || + !pkg['ccExtensibility'].hasOwnProperty('extends') || + !pkg['ccExtensibility'].hasOwnProperty('overridesDir') || + !pkg['ccExtensibility'].extends === '@salesforce/retail-react-app' || + !pkg['ccExtensibility'].overridesDir === 'overrides' + ) { + reject(`Generated project ${project} is missing extensibility config in package.json`) + } -const main = async (opts) => { - const { args } = opts; - const [project] = args; - if (opts.args.length !== 1) { - console.log(program.helpInformation()); - process.exit(1); - } + if (templateVersion && pkg.version !== templateVersion) { + reject( + `Generated project ${project} is using an incorrect template version. Expected ${templateVersion}, but got ${pkg.version}.` + ) + } + resolve(`Successfully validated extensibility config for ${project}`) + }) +} - try { - console.log(await validateGeneratedArtifacts(project)); - if (project === "retail-app-ext" || project === "retail-app-ext") { - console.log(await validateExtensibilityConfig(project)); +const main = async (opts) => { + const {args} = opts + const [project, templateVersion] = args + if (opts.args.length !== 1) { + console.log(program.helpInformation()) + process.exit(1) } - } catch (err) { - console.error(err); - } -}; -program.description( - `Validate project generated by generator using the key ` -); + try { + console.log(await validateGeneratedArtifacts(project)) + if (project === 'retail-app-ext' || project === 'retail-app-ext') { + console.log(await validateExtensibilityConfig(project, templateVersion)) + } + } catch (err) { + console.error(err) + } +} -program.addArgument( - new Argument("", "project key").choices([ - "retail-app-demo", - "retail-app-ext", - "retail-app-no-ext", - ]) -); +program + .description(`Validate project generated by generator using the key `) + .addArgument( + new Argument('', 'project key').choices([ + 'retail-app-demo', + 'retail-app-ext', + 'retail-app-no-ext', + 'retail-app-private-client' + ]) + ) + .option('--templateVersion ', 'Template version used to generate the project') -program.parse(process.argv); +program.parse(process.argv) -main(program); +main(program) diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/cart-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/guest/cart-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/cart-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "
", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-0.json b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-0.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-0.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-1.json b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-1.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-1.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-2.json b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-2.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-2.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-3.json b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-3.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-3.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-4-order-confirmation.json b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-4-order-confirmation.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/checkout-a11y-violations-step-4-order-confirmation.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/homepage-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/guest/homepage-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/homepage-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/pdp-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/guest/pdp-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/pdp-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/guest/plp-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/guest/plp-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/guest/plp-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/account-addresses-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/registered/account-addresses-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/account-addresses-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/account-details-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/registered/account-details-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/account-details-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-0.json b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-0.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-0.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-1.json b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-1.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-1.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-2.json b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-2.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-2.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-3.json b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-3.json new file mode 100644 index 0000000000..bd26ad728f --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-3.json @@ -0,0 +1,34 @@ +[ + { + "id": "page-has-heading-one", + "impact": "moderate", + "description": "Ensure that the page, or at least one of its frames contains a level-one heading", + "help": "Page should contain a level-one heading", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/page-has-heading-one?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n Page must have a level-one heading", + "target": [ + "html" + ] + } + ] + }, + { + "id": "region", + "impact": "moderate", + "description": "Ensure all page content is contained by landmarks", + "help": "All page content should be contained by landmarks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/region?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix any of the following:\n Some page content is not contained by landmarks", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-4-order-confirmation.json b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-4-order-confirmation.json new file mode 100644 index 0000000000..30e91e5e17 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/checkout-a11y-violations-step-4-order-confirmation.json @@ -0,0 +1,34 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + }, + { + "id": "color-contrast", + "impact": "serious", + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + "help": "Elements must meet minimum color contrast ratio thresholds", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=playwright", + "nodes": [ + { + "html": "Continue Shopping", + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 4.17 (foreground color: #0176d3, background color: #f3f3f3, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1", + "target": [ + ".css-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/order-history-a11y-violations.json b/e2e/tests/a11y/desktop/__snapshots__/registered/order-history-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/order-history-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/__snapshots__/registered/wishlist-violations.json b/e2e/tests/a11y/desktop/__snapshots__/registered/wishlist-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/desktop/__snapshots__/registered/wishlist-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/desktop/a11y-snapshot-test-guest.spec.js b/e2e/tests/a11y/desktop/a11y-snapshot-test-guest.spec.js new file mode 100644 index 0000000000..8b4f930758 --- /dev/null +++ b/e2e/tests/a11y/desktop/a11y-snapshot-test-guest.spec.js @@ -0,0 +1,130 @@ +/* + * 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 {test, expect} = require('@playwright/test') +const {generateUserCredentials, runAccessibilityTest} = require('../../../scripts/utils') +const config = require('../../../config') +const { + answerConsentTrackingForm, + navigateToPDPDesktop, + addProductToCart, + checkoutProduct +} = require('../../../scripts/pageHelpers') + +test.describe('Accessibility Tests with Snapshots for guest user', () => { + const GUEST_USER_CREDENTIALS = generateUserCredentials() + + test('Homepage should not have new accessibility issues', async ({page}) => { + // Go to the homepage + await page.goto(config.RETAIL_APP_HOME) + + // Handle the consent tracking form using the existing helper + await answerConsentTrackingForm(page) + + // wait until product tiles are fully load before analyzing + await expect(page.getByRole('link', {name: /Denim slim skirt/i})).toBeVisible() + + // Run the a11y test + await runAccessibilityTest(page, ['guest', 'homepage-a11y-violations.json']) + }) + + test('Product Listing Page should not have new accessibility issues', 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() + const productTile = page.getByRole('link', { + name: /Cotton Turtleneck Sweater/i + }) + await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible() + + // Run the a11y test + await runAccessibilityTest(page, ['guest', 'plp-a11y-violations.json']) + }) + + test('Product Detail Page should not have new accessibility issues', async ({page}) => { + await navigateToPDPDesktop({page}) + + const getProductPromise = page.waitForResponse( + '**/shopper-products/v1/organizations/**/products/25518241M**', + {timeout: 10000} + ) + + await getProductPromise + const getProductRes = await getProductPromise + expect(getProductRes.status()).toBe(200) + // ensure that the page is fully loaded before starting a11y scan + await expect(page.getByRole('heading', {name: /Cotton Turtleneck Sweater/i})).toBeVisible() + await expect(page.getByText(/From \$39\.99/i).nth(1)).toBeVisible() + + const addToWishlistButton = page.getByRole('button', {name: /Add to Wishlist/i}) + await expect(addToWishlistButton).toBeVisible() + await expect(addToWishlistButton).toBeEnabled() + // NOTE: Chakra Skeleton has animation when it is visible in the DOME, + // sometimes axe can't detect if the transition from skeleton to element is completed or not + // which cause the a11y scan to detect false positive violations + // here, we want to ensure skeleton is completely gone before running a11y scan + await page + .waitForFunction( + () => { + const skeletons = Array.from(document.querySelectorAll('.chakra-skeleton')) + return skeletons.every((skeleton) => { + // Check if skeleton has data-loaded attribute (Chakra UI sets this when loaded) + const hasDataLoaded = skeleton.hasAttribute('data-loaded') + + // Check if skeleton animation has stopped + const computedStyle = getComputedStyle(skeleton) + const hasNoAnimation = computedStyle.animationName === 'none' + + // Consider it loaded if either condition is met + return hasDataLoaded || hasNoAnimation + }) + }, + {timeout: 10000} + ) + .catch(() => { + console.warn('Skeleton loading wait timed out, proceeding with test') + }) + + // Run the a11y test + await runAccessibilityTest(page, ['guest', 'pdp-a11y-violations.json']) + }) + + test('Cart should not have new accessibility issues', async ({page}) => { + await addProductToCart({page}) + + // cart + await page.getByLabel(/My cart/i).click() + await page.waitForLoadState() + + // make sure the cart is fully load + await expect(page.getByRole('link', {name: /Cotton Turtleneck Sweater/i})).toBeVisible() + + // Run the a11y test + await runAccessibilityTest(page, ['guest', 'cart-a11y-violations.json']) + }) + + test('Checkout should not have new accessibility issues', async ({page}) => { + await addProductToCart({page}) + + // cart + await page.getByLabel(/My cart/i).click() + await page.waitForLoadState() + + // make sure the cart is fully load + await expect(page.getByRole('link', {name: /Cotton Turtleneck Sweater/i})).toBeVisible() + + await checkoutProduct({ + page, + userCredentials: GUEST_USER_CREDENTIALS, + a11y: {checkA11y: true, snapShotName: 'guest'} + }) + }) +}) diff --git a/e2e/tests/a11y/desktop/a11y-snapshot-test-registered.spec.js b/e2e/tests/a11y/desktop/a11y-snapshot-test-registered.spec.js new file mode 100644 index 0000000000..06f54da5e9 --- /dev/null +++ b/e2e/tests/a11y/desktop/a11y-snapshot-test-registered.spec.js @@ -0,0 +1,74 @@ +/* + * 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 {test, expect} = require('@playwright/test') +const { + answerConsentTrackingForm, + registeredUserHappyPath, + wishlistFlow, + loginShopper, + registerShopper +} = require('../../../scripts/pageHelpers') +const {generateUserCredentials, runAccessibilityTest} = require('../../../scripts/utils') + +test.describe('Accessibility Tests with Snapshots for a registered user', async () => { + let registeredUserCredentials = {} + + test.beforeAll(async () => { + // Generate credentials once and use throughout tests to avoid creating a new account + registeredUserCredentials = generateUserCredentials() + }) + + test('Registered shopper happy path flow should not have new accessibility issues', async ({ + page + }) => { + await registeredUserHappyPath({ + page, + registeredUserCredentials, + a11y: {checkA11y: true, snapShotName: 'registered'} + }) + }) + + test('Wishlist page should not have any new a11y issues', async ({page}) => { + await wishlistFlow({ + page, + registeredUserCredentials, + a11y: {checkA11y: true, snapShotName: 'registered'} + }) + }) +}) + +test.describe('Registered Account pages', () => { + let registeredUserCredentials = {} + + test.beforeAll(async () => { + // Generate credentials once and use throughout tests to avoid creating a new account + registeredUserCredentials = generateUserCredentials() + }) + test('Account pages should not have any new a11y issues', async ({page}) => { + const isLoggedIn = await loginShopper({ + page, + userCredentials: registeredUserCredentials + }) + + if (!isLoggedIn) { + await registerShopper({ + page, + userCredentials: registeredUserCredentials + }) + } + + // The consent form shows up again after registration + await answerConsentTrackingForm(page) + + await runAccessibilityTest(page, ['registered', 'account-details-a11y-violations.json']) + + await page.getByRole('link', {name: 'Addresses'}).click() + + await runAccessibilityTest(page, ['registered', 'account-addresses-a11y-violations.json']) + }) +}) diff --git a/e2e/tests/a11y/mobile/__snapshots__/guest/plp-a11y-violations.json b/e2e/tests/a11y/mobile/__snapshots__/guest/plp-a11y-violations.json new file mode 100644 index 0000000000..2aed2aa6f8 --- /dev/null +++ b/e2e/tests/a11y/mobile/__snapshots__/guest/plp-a11y-violations.json @@ -0,0 +1,18 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + } +] \ No newline at end of file diff --git a/e2e/tests/a11y/mobile/__snapshots__/registered/account-details-a11y-violations.json b/e2e/tests/a11y/mobile/__snapshots__/registered/account-details-a11y-violations.json new file mode 100644 index 0000000000..7ffb74c86d --- /dev/null +++ b/e2e/tests/a11y/mobile/__snapshots__/registered/account-details-a11y-violations.json @@ -0,0 +1,34 @@ +[ + { + "id": "aria-allowed-attr", + "impact": "critical", + "description": "Ensure an element's role supports its ARIA attributes", + "help": "Elements must only use supported ARIA attributes", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr?application=playwright", + "nodes": [ + { + "html": "", + "failureSummary": "Fix all of the following:\n ARIA attribute is not allowed: aria-expanded=\"false\"", + "target": [ + "#popover-trigger-\\-..." + ] + } + ] + }, + { + "id": "list", + "impact": "serious", + "description": "Ensure that lists are structured correctly", + "help": "
    and
      must only directly contain
    1. , - )} - { if (!messages) { @@ -327,7 +336,12 @@ const App = (props) => { ))} @@ -335,62 +349,85 @@ const App = (props) => { {/* A wider fallback for user locales that the app does not support */} + {commerceAgentConfiguration?.enabled === 'true' && ( + 0} + /> + )} + Skip to Content - - - {!isCheckout ? ( - <> - -
      - - - - - - - -
      - - ) : ( - - )} -
      + {STORE_LOCATOR_IS_ENABLED && ( + + )} + + + {!isCheckout ? ( + <> + +
      + + + + + + + +
      + + ) : ( + + )} +
      +
      {!isOnline && } {
      - {!isCheckout ?