├── .env.development ├── .env.production ├── .env.staging ├── .github ├── CODEOWNERS ├── actions │ ├── test-summary-slack-noti │ │ └── action.yml │ └── trigger-build-slack-noti │ │ └── action.yml ├── pull_request_template.md └── workflows │ ├── eslint.yml │ ├── regression-test.yml │ └── run-test-on-demand.yml ├── .gitignore ├── .husky └── pre-commit ├── .jenkins ├── onDemandTests.Jenkinsfile └── regressionTests.Jenkinsfile ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── Makefile ├── README.md ├── eslint.config.mjs ├── global.d.ts ├── package-lock.json ├── package.json ├── playwright-helpers.ts ├── playwright.config.ts ├── playwright └── index.ts ├── src ├── api │ ├── baseAPI.ts │ ├── brand │ │ ├── brandApi.ts │ │ └── types.ts │ ├── cart │ │ ├── cartApi.ts │ │ └── types.ts │ ├── fixtures │ │ ├── statusesExpect.ts │ │ └── typesExpect.ts │ ├── lib │ │ ├── dataFactory │ │ │ ├── auth.ts │ │ │ ├── brand.ts │ │ │ ├── cart.ts │ │ │ └── product.ts │ │ ├── helpers │ │ │ ├── authHelpers.ts │ │ │ ├── env.ts │ │ │ ├── responseHelpers.ts │ │ │ └── validateJsonSchema.ts │ │ └── schemas │ │ │ ├── GET_search_product.json │ │ │ ├── cart │ │ │ └── GET_cart_schema.json │ │ │ ├── product │ │ │ ├── GET_list_product_schema.json │ │ │ └── GET_product_schema.json │ │ │ └── user │ │ │ ├── GET_login_user_schema.json │ │ │ ├── GET_register_user_schema.json │ │ │ └── POST_register_user_schema.json │ ├── product │ │ └── productApi.ts │ └── user │ │ ├── types.ts │ │ └── userApi.ts ├── fixtures │ ├── baseAPIFixture.ts │ └── baseUIFixture.ts ├── mock-api │ ├── common-mock-api.ts │ └── types.ts ├── pages │ ├── account │ │ └── accountPage.ts │ ├── admin │ │ ├── brand │ │ │ └── brandPage.ts │ │ └── dashboardPage.ts │ ├── basePage.ts │ ├── checkout │ │ ├── checkoutPage.ts │ │ ├── components │ │ │ ├── cart.ts │ │ │ └── payment.ts │ │ ├── data │ │ │ └── paymentMethod.ts │ │ └── types.ts │ ├── common │ │ ├── data │ │ │ └── address.ts │ │ └── types.ts │ ├── components │ │ ├── address.ts │ │ ├── datePicker.ts │ │ ├── dropdown.ts │ │ ├── login.ts │ │ ├── navigationBar.ts │ │ ├── sideBarComponent.ts │ │ └── types.ts │ ├── fixtures │ │ └── stringsExpect.ts │ ├── homePage.ts │ ├── invoice │ │ └── invoicePage.ts │ ├── login │ │ └── loginPage.ts │ ├── product │ │ ├── data │ │ │ └── product.ts │ │ ├── productPage.ts │ │ ├── searchProductPage.ts │ │ └── types.ts │ └── register │ │ └── registerPage.ts └── types.ts ├── test-data └── snapshots │ ├── account.spec.ts │ ├── login-form.png │ └── register-form.png │ ├── checkout.spec.ts │ ├── bank-transfer-payment-method.png │ ├── billing-address-step.png │ ├── cart-step.png │ ├── credit-card-payment-method.png │ ├── gitf-card-payment-method.png │ ├── login-form.png │ └── register-form.png │ ├── invoice.spec.ts │ └── list-invoices.png │ └── product.spec.ts │ └── product-details.png ├── tests ├── api │ ├── auth │ │ ├── login.spec.ts │ │ ├── logout.spec.ts │ │ └── register.spec.ts │ ├── cart │ │ └── cart.spec.ts │ └── product │ │ └── product.spec.ts ├── auth.setup.ts ├── e2e │ ├── auth │ │ ├── login.spec.ts │ │ └── register.spec.ts │ ├── cart │ │ └── cart.spec.ts │ ├── checkout │ │ └── checkout.spec.ts │ └── product │ │ └── product.spec.ts └── visual │ ├── auth │ └── account.spec.ts │ ├── checkout │ └── checkout.spec.ts │ ├── fixtures │ ├── cartItems.json │ ├── invoices.json │ └── product.json │ └── invoice │ └── invoice.spec.ts ├── tsconfig.json └── utils ├── date.ts ├── env.ts └── randomize.ts /.env.development: -------------------------------------------------------------------------------- 1 | # URLs 2 | BASE_URL=https://practicesoftwaretesting.com/ 3 | API_URL=https://api.practicesoftwaretesting.com/ 4 | 5 | # Logins 6 | CUSTOMER_01_EMAIL=customer@practicesoftwaretesting.com 7 | CUSTOMER_01_PASSWORD=welcome01 8 | CUSTOMER_02_EMAIL=customer2@practicesoftwaretesting.com 9 | CUSTOMER_02_PASSWORD=welcome01 10 | ADMIN_EMAIL=admin@practicesoftwaretesting.com 11 | ADMIN_PASSWORD=welcome01 12 | 13 | # Playwright config 14 | BROWSER="Desktop Chrome" 15 | WORKERS=4 16 | ACTION_TIMEOUT=20000 17 | EXPECT_TIMEOUT=30000 18 | NAVIGATION_TIMEOUT=60000 19 | RETRY_ON_CI=3 20 | RETRY=0 21 | HEADLESS=false 22 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # URLs 2 | BASE_URL=https://practicesoftwaretesting.com/ 3 | API_URL=https://api.practicesoftwaretesting.com/ 4 | 5 | # Logins 6 | CUSTOMER_01_EMAIL=customer@practicesoftwaretesting.com 7 | CUSTOMER_01_PASSWORD=welcome01 8 | CUSTOMER_02_EMAIL=customer2@practicesoftwaretesting.com 9 | CUSTOMER_02_PASSWORD=welcome01 10 | ADMIN_EMAIL=admin@practicesoftwaretesting.com 11 | ADMIN_PASSWORD=welcome01 12 | 13 | # Playwright config 14 | BROWSER="Desktop Chrome" 15 | WORKERS=4 16 | ACTION_TIMEOUT=20000 17 | EXPECT_TIMEOUT=30000 18 | NAVIGATION_TIMEOUT=60000 19 | RETRY_ON_CI=3 20 | RETRY=0 21 | HEADLESS=false 22 | -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | # URLs 2 | BASE_URL=https://practicesoftwaretesting.com/ 3 | API_URL=https://api.practicesoftwaretesting.com/ 4 | 5 | # Logins 6 | CUSTOMER_01_EMAIL=customer@practicesoftwaretesting.com 7 | CUSTOMER_01_PASSWORD=welcome01 8 | CUSTOMER_02_EMAIL=customer2@practicesoftwaretesting.com 9 | CUSTOMER_02_PASSWORD=welcome01 10 | ADMIN_EMAIL=admin@practicesoftwaretesting.com 11 | ADMIN_PASSWORD=welcome01 12 | 13 | # Playwright config 14 | BROWSER="Desktop Chrome" 15 | WORKERS=4 16 | ACTION_TIMEOUT=20000 17 | EXPECT_TIMEOUT=30000 18 | NAVIGATION_TIMEOUT=60000 19 | RETRY_ON_CI=3 20 | RETRY=0 21 | HEADLESS=false 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | ## Require to approve PRs 2 | @demo/qa-engineering -------------------------------------------------------------------------------- /.github/actions/test-summary-slack-noti/action.yml: -------------------------------------------------------------------------------- 1 | name: Reusable workflow - Slack notification 2 | description: 'Send a slack notification for e2e tests' 3 | inputs: 4 | job_status: 5 | required: true 6 | type: string 7 | description: 'Job status that triggered this notification' 8 | report_url: 9 | description: 'URL to the test report' 10 | required: true 11 | type: string 12 | slack_webhook_url: 13 | required: true 14 | 15 | runs: 16 | using: 'composite' 17 | steps: 18 | - name: Send slack notification on success 19 | if: inputs.job_status == 'success' 20 | uses: 8398a7/action-slack@v3 21 | with: 22 | status: custom 23 | custom_payload: | 24 | { 25 | "text": "✅ All tests PASSED! :tada:", 26 | "attachments": [ 27 | { 28 | "title": "View test report", 29 | "title_link": "${{ inputs.report_url }}", 30 | "color": 'good', 31 | } 32 | ] 33 | } 34 | env: 35 | SLACK_WEBHOOK_URL: ${{ inputs.slack_webhook_url }} 36 | 37 | - name: Send Slack notification on failure 38 | if: inputs.job_status != 'success' 39 | uses: 8398a7/action-slack@v3 40 | with: 41 | status: custom 42 | custom_payload: | 43 | { 44 | "text": "❌ Some tests FAILED :cry:", 45 | "attachments": [ 46 | { 47 | "title": "View test report", 48 | "title_link": "${{ inputs.report_url }}", 49 | "color": 'good', 50 | } 51 | ] 52 | } 53 | env: 54 | SLACK_WEBHOOK_URL: ${{ inputs.slack_webhook_url }} 55 | -------------------------------------------------------------------------------- /.github/actions/trigger-build-slack-noti/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Reusable slack notification' 2 | description: 'Send a slack notification for e2e tests' 3 | inputs: 4 | actor: 5 | description: 'GitHub user who triggered the workflow' 6 | required: true 7 | type: string 8 | workflow_url: 9 | description: 'URL to the workflow run' 10 | required: true 11 | type: string 12 | commit: 13 | description: 'Commit SHA' 14 | required: true 15 | type: string 16 | repository: 17 | description: 'GitHub repository' 18 | required: true 19 | type: string 20 | run_id: 21 | description: 'GitHub run ID' 22 | required: true 23 | type: string 24 | report_url: 25 | description: 'URL to the test report' 26 | required: true 27 | type: string 28 | test_env: 29 | description: 'Test environment' 30 | required: false 31 | default: 'staging' 32 | type: string 33 | test_branch: 34 | description: 'Test e2e branch' 35 | required: false 36 | default: 'main' 37 | type: string 38 | test_type: 39 | description: 'Type of test' 40 | required: true 41 | type: string 42 | workers: 43 | description: 'Number of parallel workers' 44 | required: true 45 | default: 4 46 | type: string 47 | slack_channel: 48 | description: 'Slack channel to notify' 49 | required: false 50 | default: '#jenkins-tests-prodtest' 51 | type: string 52 | slack_webhook_url: 53 | description: 'Slack webhook URL' 54 | required: true 55 | type: string 56 | 57 | runs: 58 | using: 'composite' 59 | steps: 60 | - name: Send Slack notification 61 | uses: 8398a7/action-slack@v3 62 | with: 63 | status: custom 64 | custom_payload: | 65 | { 66 | text: "Running tests triggered by ${{ inputs.actor }} with following parameters", 67 | attachments: [ 68 | { 69 | color: 'good', 70 | fields: [ 71 | { 72 | title: 'Test environment', 73 | value: '${{ inputs.test_env }}', 74 | short: false 75 | }, 76 | { 77 | title: 'Test branch', 78 | value: '${{ inputs.test_branch }}', 79 | short: false 80 | }, 81 | { 82 | title: 'Test type', 83 | value: '${{ inputs.test_type }}', 84 | short: false 85 | }, 86 | { 87 | title: 'Number of parallel workers', 88 | value: '${{ inputs.workers }}', 89 | short: false 90 | }, 91 | { 92 | title: 'Notify slack channels', 93 | value: '${{ inputs.slack_channel }}', 94 | short: false 95 | }, 96 | { 97 | "type": "mrkdwn", 98 | "text": "*Workflow:* <${{ inputs.workflow_url }}|View Workflow Run>" 99 | }, 100 | { 101 | "type": "mrkdwn", 102 | "text": "*Report:* <${{ inputs.report_url }}|View Test Report>" 103 | } 104 | ] 105 | } 106 | ] 107 | } 108 | env: 109 | SLACK_WEBHOOK_URL: ${{ inputs.slack_webhook_url }} 110 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What? 2 | 3 | Explain what are the technical changes. 4 | 5 | ## Why? 6 | 7 | Explain the technical decision behind it, why it was done this way. 8 | 9 | ## Ticket? 10 | 11 | Add your Jira ticket 12 | 13 | ## Tests result 14 | 15 | Provide screenshots/videos for your test scripts 16 | 17 | ## Note: 18 | 19 | - You need to run your test scripts in CI pipeline and attach screenshots in your PR as a envident 20 | - If you change the code base, you may need to run related test suites or regression tests to make sure new changes do not break any existing functionalities 21 | - If you are still working on your PR, please change it as Draft PR 22 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | name: Eslint 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | eslint-format: 7 | name: Run Eslint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout source code 11 | uses: actions/checkout@v4 12 | - name: Setup NodeJS 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: '20' 16 | cache: 'npm' 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Run Eslint 20 | run: npm run format 21 | -------------------------------------------------------------------------------- /.github/workflows/regression-test.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled - Regression tests (Daily) 2 | on: 3 | schedule: 4 | - cron: '0 16 * * *' # Run job everyday at 23p.m UTC (Asia/Ho_Chi_Minh) 5 | workflow_dispatch: 6 | inputs: 7 | test_branch: 8 | description: 'Test branch to run' 9 | required: true 10 | default: 'main' 11 | type: string 12 | test_env: 13 | description: 'Environment to run tests against' 14 | required: true 15 | default: 'staging' 16 | type: choice 17 | options: 18 | - development 19 | - staging 20 | - production 21 | test_type: 22 | description: 'Test type' 23 | required: true 24 | type: choice 25 | options: 26 | - regression-test 27 | workers: 28 | description: 'Number of parallel workers' 29 | required: true 30 | default: '10' 31 | type: string 32 | slack_channel: 33 | description: 'Slack channel to send notifications' 34 | required: true 35 | default: '#jenkins-tests-prodtest' 36 | type: string 37 | env: 38 | TEST_ENV: ${{ github.event.inputs.test_env || 'staging' }} 39 | TEST_TYPE: ${{ github.event.inputs.test_type || 'regression-test' }} 40 | TEST_BRANCH: ${{ github.event.inputs.test_branch || 'main' }} 41 | WORKERS: ${{ github.event.inputs.workers || '10' }} 42 | SLACK_CHANNEL: ${{ github.event.inputs.slack_channel || '#jenkins-tests-prodtest' }} 43 | 44 | jobs: 45 | test: 46 | name: Run Playwright Tests 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | - name: Checkout to branch ${{ inputs.test_branch }} 51 | uses: actions/checkout@v4 52 | with: 53 | ref: ${{ inputs.test_branch }} 54 | 55 | - name: Notify slack job started 56 | if: always() 57 | uses: ./.github/actions/trigger-build-slack-noti 58 | with: 59 | test_env: ${{ env.TEST_ENV }} 60 | test_type: ${{ env.TEST_TYPE }} 61 | test_branch: ${{ env.TEST_BRANCH }} 62 | workers: ${{ env.WORKERS }} 63 | workflow_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' 64 | report_url: ${{ env.REPORT_URL }} 65 | actor: ${{ github.actor }} 66 | commit: ${{ github.event.pull_request.head.sha || github.sha }} 67 | slack_channel: ${{ env.SLACK_CHANNEL }} 68 | slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} 69 | 70 | - name: Setup NodeJS 71 | uses: actions/setup-node@v4 72 | with: 73 | node-version: '20' 74 | cache: 'npm' 75 | 76 | - name: Get installed Playwright version 77 | id: playwright-version 78 | shell: bash 79 | run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').dependencies['@playwright/test'].version)")" >> $GITHUB_ENV 80 | 81 | - name: Cache playwright binaries 82 | uses: actions/cache@v3 83 | id: playwright-cache 84 | with: 85 | path: | 86 | ~/.cache/ms-playwright 87 | key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} 88 | 89 | - name: Install dependencies 90 | shell: bash 91 | run: npm ci 92 | 93 | - name: Install Playwright Browsers 94 | shell: bash 95 | run: npx playwright install --with-deps 96 | if: steps.playwright-cache.outputs.cache-hit != 'true' 97 | 98 | - name: Run Playwright tests 99 | run: | 100 | TEST_ENV=${{ inputs.test_env }} TEST_TYPE=${{ inputs.test_type }} WORKERS=${{ inputs.workers }} make test 101 | 102 | - name: Upload HTML test report 103 | uses: actions/upload-artifact@v4 104 | if: ${{ always() && !cancelled() }} 105 | with: 106 | name: playwright-report-${{ github.run_id }} 107 | path: playwright-report 108 | retention-days: 30 109 | 110 | - name: Publish Test Report 111 | run: | 112 | OUTPUT_FILE="ctrf-test-report-${{ github.run_id }}-${{ github.run_number }}.json" 113 | npx github-actions-ctrf playwright-report/$OUTPUT_FILE 114 | if: always() 115 | 116 | - name: Set report URL 117 | if: always() 118 | run: | 119 | echo "REPORT_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV 120 | 121 | - name: Notify slack job completed 122 | if: always() 123 | uses: ./.github/actions/test-summary-slack-noti 124 | with: 125 | job_status: ${{ job.status }} 126 | report_url: ${{ env.REPORT_URL }} 127 | slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} 128 | -------------------------------------------------------------------------------- /.github/workflows/run-test-on-demand.yml: -------------------------------------------------------------------------------- 1 | name: Run tests on demand 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | test_branch: 7 | description: 'Test branch to run' 8 | required: true 9 | default: 'main' 10 | type: string 11 | test_env: 12 | description: 'Environment to run tests against' 13 | required: true 14 | default: 'staging' 15 | type: choice 16 | options: 17 | - development 18 | - staging 19 | - production 20 | test_type: 21 | description: 'Test type' 22 | required: true 23 | default: 'smoke-test' 24 | type: choice 25 | options: 26 | - api-test 27 | - e2e-test 28 | - visual-test 29 | - smoke-test 30 | - regression-test 31 | workers: 32 | description: 'Number of parallel workers' 33 | required: true 34 | default: 4 35 | type: string 36 | slack_channel: 37 | description: 'Slack channel to send notifications' 38 | required: true 39 | default: '#jenkins-tests-prodtest' 40 | type: string 41 | 42 | env: 43 | TEST_ENV: ${{ github.event.inputs.test_env || 'staging' }} 44 | TEST_TYPE: ${{ github.event.inputs.test_type || 'regression-test' }} 45 | TEST_BRANCH: ${{ github.event.inputs.test_branch || 'main' }} 46 | WORKERS: ${{ github.event.inputs.workers || '10' }} 47 | SLACK_CHANNEL: ${{ github.event.inputs.slack_channel || '#jenkins-tests-prodtest' }} 48 | 49 | jobs: 50 | test: 51 | name: Run Playwright Tests 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - name: Checkout to branch ${{ inputs.test_branch }} 56 | uses: actions/checkout@v4 57 | with: 58 | ref: ${{ inputs.test_branch }} 59 | 60 | - name: Notify slack job started 61 | if: always() 62 | uses: ./.github/actions/trigger-build-slack-noti 63 | with: 64 | test_env: ${{ env.TEST_ENV }} 65 | test_type: ${{ env.TEST_TYPE }} 66 | test_branch: ${{ env.TEST_BRANCH }} 67 | workers: ${{ env.WORKERS }} 68 | workflow_url: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' 69 | report_url: ${{ env.REPORT_URL }} 70 | actor: ${{ github.actor }} 71 | commit: ${{ github.event.pull_request.head.sha || github.sha }} 72 | slack_channel: ${{ env.SLACK_CHANNEL }} 73 | slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} 74 | 75 | - name: Setup NodeJS 76 | uses: actions/setup-node@v4 77 | with: 78 | node-version: '20' 79 | cache: 'npm' 80 | 81 | - name: Get installed Playwright version 82 | id: playwright-version 83 | shell: bash 84 | run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').dependencies['@playwright/test'].version)")" >> $GITHUB_ENV 85 | 86 | - name: Cache playwright binaries 87 | uses: actions/cache@v3 88 | id: playwright-cache 89 | with: 90 | path: | 91 | ~/.cache/ms-playwright 92 | key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} 93 | 94 | - name: Install dependencies 95 | shell: bash 96 | run: npm ci 97 | 98 | - name: Install Playwright Browsers 99 | shell: bash 100 | run: npx playwright install --with-deps 101 | if: steps.playwright-cache.outputs.cache-hit != 'true' 102 | 103 | - name: Run Playwright tests 104 | run: | 105 | TEST_ENV=${{ inputs.test_env }} TEST_TYPE=${{ inputs.test_type }} WORKERS=${{ inputs.workers }} make test 106 | 107 | - name: Upload HTML test report 108 | uses: actions/upload-artifact@v4 109 | if: ${{ always() && !cancelled() }} 110 | with: 111 | name: playwright-report-${{ github.run_id }} 112 | path: playwright-report 113 | retention-days: 30 114 | 115 | - name: Publish Test Report 116 | run: | 117 | OUTPUT_FILE="ctrf-test-report-${{ github.run_id }}-${{ github.run_number }}.json" 118 | npx github-actions-ctrf playwright-report/$OUTPUT_FILE 119 | if: always() 120 | 121 | - name: Set report URL 122 | if: always() 123 | run: | 124 | echo "REPORT_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV 125 | 126 | - name: Notify slack job completed 127 | if: always() 128 | uses: ./.github/actions/test-summary-slack-noti 129 | with: 130 | job_status: ${{ job.status }} 131 | report_url: ${{ env.REPORT_URL }} 132 | slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Playwright 2 | node_modules/ 3 | /test-results/ 4 | /playwright-report/ 5 | /blob-report/ 6 | /playwright/.cache/ 7 | /playwright/.auth/ 8 | .vscode/* 9 | !.vscode/settings.json 10 | 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.jenkins/onDemandTests.Jenkinsfile: -------------------------------------------------------------------------------- 1 | @Library('jenkins-shared-library') _ 2 | 3 | testPipeline() 4 | -------------------------------------------------------------------------------- /.jenkins/regressionTests.Jenkinsfile: -------------------------------------------------------------------------------- 1 | @Library('jenkins-shared-library') _ 2 | 3 | import org.longnv.pipeline.Environment 4 | import org.longnv.pipeline.TestType 5 | import org.longnv.pipeline.SlackChannel 6 | 7 | testPipeline([ 8 | testTypes: [ TestType.REGRESSION_TEST ], 9 | environments: [ Environment.STAGING ], 10 | workers: '8', 11 | slackChannel: SlackChannel.SCHEDULED_TESTS_PRODTEST, 12 | cronSchedule: 'H(0-0) 23 * * *' // Run everyday at 23p.m at Asia/Ho_Chi_Minh 13 | ]) 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /blob-report/ 5 | /playwright/.cache/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc.json", 3 | "tabWidth": 2, 4 | "printWidth": 80, 5 | "semi": true, 6 | "useTabs": true, 7 | "singleQuote": true, 8 | "bracketSpacing": true, 9 | "endOfLine": "auto", 10 | "arrowParens": "always", 11 | "trailingComma": "es5" 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.editorAssociations": { 3 | "*.md": "vscode.markdown.preview.editor" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST_ENV ?= staging 2 | WORKERS ?= 4 3 | 4 | api-test: 5 | TEST_ENV=$(TEST_ENV) npx playwright test --project="api-test" --workers=$(WORKERS) 6 | 7 | e2e-test: 8 | TEST_ENV=$(TEST_ENV) npx playwright test --project="e2e-test" --workers=$(WORKERS) 9 | 10 | visual-test: 11 | TEST_ENV=$(TEST_ENV) npx playwright test --project="visual-test" --workers=$(WORKERS) 12 | 13 | smoke-test: 14 | TEST_ENV=$(TEST_ENV) npx playwright test --grep "@smoke" --workers=$(WORKERS) 15 | 16 | regression-test: 17 | TEST_ENV=$(TEST_ENV) npx playwright test --workers=$(WORKERS) 18 | 19 | last-failed-test: 20 | TEST_ENV=$(TEST_ENV) npx playwright test --last-failed 21 | 22 | count-tests: 23 | npx playwright test --list 24 | 25 | test: 26 | ifeq ($(TEST_TYPE), regression-test) 27 | @echo "Running regression-test in $(TEST_ENV) environment using $(WORKERS) workers" 28 | TEST_ENV=$(TEST_ENV) npx playwright test --workers=$(WORKERS) 29 | else ifeq ($(TEST_TYPE), smoke-test) 30 | @echo "Running smoke-test in $(TEST_ENV) environment using $(WORKERS) workers, exclude: visual-test 31 | TEST_ENV=$(TEST_ENV) npx playwright test --grep "@smoke" --workers=$(WORKERS) 32 | else 33 | @echo "Running '$(TEST_TYPE)' in $(TEST_ENV) environment using $(WORKERS) workers" 34 | TEST_ENV=$(TEST_ENV) npx playwright test --project="$(TEST_TYPE)" --workers=$(WORKERS) 35 | endif 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Playwright E2E Testing Project 2 | 3 | This repository contains end-to-end tests built with `Playwright` and `TypeScript`.
4 | The tests are executed via both `Jenkins` pipeline and `GitHub Actions` 5 | 6 | ## 🚀 Overview 7 | 8 | A comprehensive testing framework that 9 | 10 | - Combines API, E2E, Visual testing in one solution 11 | - Integrated with CI/CD pipelines and automated reporting systems. 12 | 13 | ### Screenshots 14 | 15 | image 16 | image 17 | image 18 | image 19 | image 20 | image 21 | 22 | ## ✨ Features 23 | 24 | 1. All-in-one Testing Solution: 25 | 26 | - 🌐 UI Testing: End-to-end user interface tests 27 | - 🔌 API Testing: Backend API validation 28 | - 👁️ Visual Testing: Screenshot comparison and visual regression 29 | 30 | 2. Robust CI/CD Integration 31 | 32 | - 🔄 Jenkins Pipeline using reusable pipeline code from [Jenkins shared library](https://github.com/longautoqa/jenkins-shared-library) 33 | - 🐙 GitHub Actions 34 | 35 | 3. Advanced Testing Capabilities: 36 | 37 | - ⚙️ Dynamic test parameterization (environments, test types, workers...) 38 | - 📆 Scheduled test runs (daily regression tests) 39 | - 🔍 Automated PR validation jobs, eg: `Eslint`, `Synk` 40 | - 🔔 Slack notifications for trigger test / test results 41 | - 📍 Run smoke test whenever a PR is merged to web-app 42 | 43 | 4. 🔌 Integrate between Github & Slack 44 | 45 | - Notify channel when a PR is created / merged / closed 46 | 47 | 5. 📊 Test reports 48 | 49 | - Test reports are automatically generated 50 | - Download generated test reports 51 | - View summary test reports (passed, failed, flaky tests) 52 | - View test report details via `Jenkins`, `Github Actions` 53 | 54 | ## 🛠️ Tools & Technologies 55 | 56 | - [Playwright](https://playwright.dev/) - Modern web testing framework 57 | - [TypeScript](https://www.typescriptlang.org/) - Typed JavaScript programming language 58 | - [GitHub Actions](https://github.com/features/actions) - Cloud-based CI/CD platform 59 | - [Jenkins](https://www.jenkins.io/) - Self-hosted automation server 60 | - [Ngrok](https://ngrok.com/) - Secure tunneling for local Jenkins exposure 61 | - [ESLint](https://eslint.org/) - JavaScript linting utility 62 | - [Prettier](https://prettier.io/) - Code formatting tool 63 | - [Makefile](https://www.gnu.org/software/make/manual/make.html) - Task automation and build configuration 64 | 65 | ## 🧰 Prerequisites 66 | 67 | - Node.js (version 20 or higher) 68 | 69 | ## ⚙️ Installation 70 | 71 | 1. Clone the repository: 72 | 73 | ```bash 74 | git clone https://github.com/longautoqa/e2e-automation-playwright.git 75 | ``` 76 | 77 | 2. Navigate to project directory: 78 | 79 | ```bash 80 | cd e2e-automation-playwright 81 | ``` 82 | 83 | 3. Install dependencies: 84 | 85 | ```bash 86 | npm ci 87 | ``` 88 | 89 | ## Local Development 90 | 91 | Maintain code quality with pre-commit checks 92 | 93 | ```bash 94 | npm run format 95 | ``` 96 | 97 | ## ⚡ How to run E2E tests 98 | 99 | By default, tests will run in staging (QA) environment 100 | 101 | ### Environment support 102 | 103 | - Development 104 | - Staging (QA) 105 | - Production (Release) 106 | 107 | ### Basic command 108 | 109 | ```bash 110 | TEST_ENV={env} TEST_TYPE=${project_type} WORKERS={number} make test 111 | ``` 112 | 113 | ### Execute tests 114 | 115 | #### API Tests 116 | 117 | ```bash 118 | TEST_ENV=${env} WORKERS=4 make api-test 119 | ``` 120 | 121 | #### UI Tests 122 | 123 | ```bash 124 | TEST_ENV=${env} WORKERS=4 make e2e-test 125 | ``` 126 | 127 | #### Visual Tests 128 | 129 | ```bash 130 | TEST_ENV=${env} WORKERS=4 make visual-test 131 | ``` 132 | 133 | #### Regression Tests 134 | 135 | ```bash 136 | TEST_ENV=${env} WORKERS=10 make regression-test 137 | ``` 138 | 139 | #### Count Tests 140 | 141 | To count how many `test specs` & `tests` in your project 142 | 143 | ```bash 144 | make count-tests 145 | ``` 146 | 147 | ## 🤝 Contributing 148 | 149 | Contributions are welcome! Please feel free to submit a Pull Request. 150 | 151 | ## ⭐ Support 152 | 153 | If you find this project useful, please consider giving it a star on GitHub! 154 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // eslint.config.js 2 | import { defineConfig } from 'eslint/config'; 3 | 4 | export default defineConfig([ 5 | { 6 | rules: { 7 | semi: 'error', 8 | 'prefer-const': 'error', 9 | '@typescript-eslint/no-duplicate-enum-values': 'error', 10 | }, 11 | ignores: ['node_modules/*', 'playwright-report/*', '.github/**'], 12 | }, 13 | ]); 14 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | namespace PlaywrightTest { 5 | interface Matchers { 6 | toBeNumber(): R; 7 | toBeString(): R; 8 | toHaveOKStatus(): R; 9 | toHaveCreatedStatus(): R; 10 | toHaveNoContentStatus(): R; 11 | toHaveUnauthorizedStatus(): R; 12 | toHaveForbiddenStatus(): R; 13 | toHaveNotFoundStatus(): R; 14 | toHaveUnprocessableContentStatus(): R; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e-test", 3 | "type": "commonjs", 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "prepare": "husky", 9 | "eslint": "eslint . --ext .js,.jsx,.ts,.tsx", 10 | "format": "prettier --write ." 11 | }, 12 | "husky": { 13 | "hooks": { 14 | "pre-commit": "lint-staged" 15 | } 16 | }, 17 | "lint-staged": { 18 | "*.{js,jsx,ts,tsx,json,css,scss,md}": [ 19 | "eslint --fix", 20 | "prettier --write" 21 | ] 22 | }, 23 | "keywords": [], 24 | "author": "", 25 | "license": "ISC", 26 | "devDependencies": { 27 | "@eslint/js": "^9.23.0", 28 | "@faker-js/faker": "^9.6.0", 29 | "@playwright/test": "^1.52.0", 30 | "@types/fs-extra": "^11.0.4", 31 | "@types/node": "^22.13.14", 32 | "@typescript-eslint/eslint-plugin": "^8.28.0", 33 | "@typescript-eslint/parser": "^8.28.0", 34 | "eslint": "^9.23.0", 35 | "eslint-config-prettier": "^10.1.1", 36 | "eslint-plugin-prettier": "^5.2.5", 37 | "eslint-plugin-unused-imports": "^4.1.4", 38 | "github-actions-ctrf": "^0.0.58", 39 | "husky": "^9.1.7", 40 | "lint-staged": "^15.5.0", 41 | "playwright": "^1.52.0", 42 | "playwright-ctrf-json-reporter": "^0.0.20", 43 | "prettier": "^3.5.3", 44 | "typescript": "^5.8.2", 45 | "typescript-eslint": "^8.28.0" 46 | }, 47 | "dependencies": { 48 | "@types/lodash": "^4.17.16", 49 | "ajv": "^8.17.1", 50 | "date-fns": "^4.1.0", 51 | "dotenv": "^16.4.7", 52 | "fs-extra": "^11.3.0", 53 | "lodash": "^4.17.21" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /playwright-helpers.ts: -------------------------------------------------------------------------------- 1 | import test from '@playwright/test'; 2 | 3 | export function step(stepName?: string) { 4 | // Refer: https://www.checklyhq.com/learn/playwright/steps-decorators/ 5 | // 1. Make `@step` executable to enable function arguments 6 | // 2. Return the original decorator 7 | return function decorator( 8 | target: Function, 9 | context: ClassMethodDecoratorContext 10 | ) { 11 | return function replacementMethod(...args: any) { 12 | // 3. Use `stepName` when it's defined or 13 | // fall back to class name / method name 14 | const name = 15 | stepName || `${this.constructor.name + '.' + (context.name as string)}`; 16 | return test.step(name, async () => { 17 | return await target.call(this, ...args); 18 | }); 19 | }; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | import dotenv from 'dotenv'; 8 | import path from 'path'; 9 | import Env from 'utils/env'; 10 | 11 | const environment = process.env.TEST_ENV || 'development'; 12 | const envFile = `.env.${environment}`; 13 | 14 | const browser = Env.BROWSER || 'Desktop Chrome'; 15 | 16 | dotenv.config({ 17 | path: path.resolve(__dirname, envFile), 18 | override: true, 19 | }); 20 | 21 | /** 22 | * See https://playwright.dev/docs/test-configuration. 23 | */ 24 | export default defineConfig({ 25 | testDir: './tests', 26 | /* Run tests in files in parallel */ 27 | fullyParallel: false, 28 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 29 | forbidOnly: !!process.env.CI, 30 | /* Retry on CI only */ 31 | retries: process.env.CI ? Env.RETRY_ON_CI : 0, 32 | /* Opt out of parallel tests on CI. */ 33 | // workers: process.env.CI ? 1 : undefined, 34 | workers: process.env.CI ? Env.WORKERS : undefined, 35 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 36 | reporter: process.env.CI 37 | ? [ 38 | ['list'], 39 | ['html', { outputFolder: 'playwright-report' }], 40 | ['junit', { outputFile: 'test-results/junit-report.xml' }], 41 | [ 42 | 'playwright-ctrf-json-reporter', 43 | { 44 | outputDir: 'playwright-report', 45 | outputFile: `ctrf-test-report-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_NUMBER}.json`, 46 | }, 47 | ], 48 | ] 49 | : [ 50 | ['html'], 51 | ['list'], 52 | ['junit', { outputFile: 'test-results/junit-report.xml' }], 53 | ], 54 | expect: { 55 | timeout: Env.EXPECT_TIMEOUT || 30_000, 56 | }, 57 | snapshotPathTemplate: 'test-data/snapshots/{testFileName}/{arg}{ext}', 58 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 59 | use: { 60 | ...devices[browser], 61 | /* Base URL to use in actions like `await page.goto('/')`. */ 62 | // baseURL: 'http://127.0.0.1:3000', 63 | baseURL: 'https://practicesoftwaretesting.com/', 64 | 65 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 66 | trace: 'on', 67 | video: { 68 | mode: 'retain-on-failure', 69 | }, 70 | actionTimeout: Env.ACTION_TIMEOUT || 20_000, 71 | navigationTimeout: Env.NAVIGATION_TIMEOUT || 60_000, 72 | testIdAttribute: 'data-test', 73 | }, 74 | 75 | /* Configure projects for major browsers */ 76 | projects: [ 77 | { 78 | name: 'setup', 79 | testMatch: '**/*.setup.ts', 80 | fullyParallel: true, 81 | }, 82 | { 83 | name: 'api-test', 84 | dependencies: ['setup'], 85 | testDir: './tests/api', 86 | }, 87 | { 88 | name: 'e2e-test', 89 | dependencies: ['setup'], 90 | testDir: './tests/e2e', 91 | }, 92 | { 93 | name: 'visual-test', 94 | dependencies: ['setup'], 95 | testDir: './tests/visual', 96 | fullyParallel: true, 97 | }, 98 | ], 99 | }); 100 | -------------------------------------------------------------------------------- /playwright/index.ts: -------------------------------------------------------------------------------- 1 | // Import styles, initialize component theme here. 2 | // import '../src/common.css'; 3 | -------------------------------------------------------------------------------- /src/api/baseAPI.ts: -------------------------------------------------------------------------------- 1 | import { APIRequestContext } from '@playwright/test'; 2 | 3 | export type Headers = { 4 | [key: string]: string; 5 | }; 6 | 7 | export default class BaseAPI { 8 | readonly request: APIRequestContext; 9 | protected headers: Headers; 10 | 11 | constructor(request: APIRequestContext, headers: Headers) { 12 | this.request = request; 13 | this.headers = headers; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/api/brand/brandApi.ts: -------------------------------------------------------------------------------- 1 | import { APIRequestContext } from '@playwright/test'; 2 | import Env from '@api/lib/helpers/env'; 3 | import BaseApi, { Headers } from '@api/baseAPI'; 4 | 5 | export default class BrandApi extends BaseApi { 6 | static readonly brandEndpoint = 'brands'; 7 | 8 | private endpoint: string; 9 | 10 | constructor(request: APIRequestContext, headers: Headers) { 11 | super(request, headers); 12 | this.endpoint = Env.API_URL + BrandApi.brandEndpoint; 13 | } 14 | 15 | async createBrand(name: string, slug: string) { 16 | return await this.request.post(this.endpoint, { 17 | data: { 18 | name, 19 | slug, 20 | }, 21 | headers: this.headers, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/brand/types.ts: -------------------------------------------------------------------------------- 1 | export interface Brand { 2 | name?: string; 3 | slug?: string; 4 | } 5 | 6 | export interface BrandResponse { 7 | id: string; 8 | name: string; 9 | slug: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/api/cart/cartApi.ts: -------------------------------------------------------------------------------- 1 | import { APIRequestContext } from '@playwright/test'; 2 | import Env from '@api/lib/helpers/env'; 3 | import { AddProductToCart } from './types'; 4 | import BaseApi, { Headers } from '@api/baseAPI'; 5 | 6 | export default class CartApi extends BaseApi { 7 | static readonly cartEndpoint = 'carts'; 8 | 9 | private endpoint: string; 10 | 11 | constructor(request: APIRequestContext, headers: Headers) { 12 | super(request, headers); 13 | this.endpoint = Env.API_URL + CartApi.cartEndpoint; 14 | } 15 | 16 | async createCart() { 17 | return await this.request.post(this.endpoint, { 18 | headers: this.headers, 19 | }); 20 | } 21 | 22 | async addProductToCart(cartId: string, data: AddProductToCart) { 23 | return await this.request.post(this.endpoint + `/${cartId}`, { 24 | data: data, 25 | headers: this.headers, 26 | }); 27 | } 28 | 29 | async getCart(cartId: string) { 30 | return await this.request.get(this.endpoint + `/${cartId}`, { 31 | headers: this.headers, 32 | }); 33 | } 34 | 35 | async updateProductQty(cartId: string, data: any) { 36 | return await this.request.put(this.endpoint + `/${cartId}`, { 37 | data: data, 38 | headers: this.headers, 39 | }); 40 | } 41 | 42 | async removeProduct(cartId: string, productId: string) { 43 | return await this.request.delete( 44 | `${this.endpoint}/${cartId}/product/${productId}`, 45 | { 46 | headers: this.headers, 47 | } 48 | ); 49 | } 50 | 51 | async deleteCart(cartId: string) { 52 | return await this.request.delete(this.endpoint + `/${cartId}`, { 53 | headers: this.headers, 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/api/cart/types.ts: -------------------------------------------------------------------------------- 1 | export interface AddProductToCart { 2 | product_id?: any; 3 | quantity?: any; 4 | } 5 | -------------------------------------------------------------------------------- /src/api/fixtures/statusesExpect.ts: -------------------------------------------------------------------------------- 1 | import { expect as baseExpect } from '@playwright/test'; 2 | 3 | export const expect = baseExpect.extend({ 4 | toHaveOKStatus(received: any) { 5 | const pass = received.status() == 200; 6 | if (pass) { 7 | return { 8 | message: () => 'passed', 9 | pass: true, 10 | }; 11 | } else { 12 | return { 13 | message: () => 14 | `Expected status code 200, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 15 | pass: false, 16 | }; 17 | } 18 | }, 19 | toHaveCreatedStatus(received: any) { 20 | const pass = received.status() == 201; 21 | if (pass) { 22 | return { 23 | message: () => 'passed', 24 | pass: true, 25 | }; 26 | } else { 27 | return { 28 | message: () => 29 | `Expected status code 201, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 30 | pass: false, 31 | }; 32 | } 33 | }, 34 | toHaveNoContentStatus(received: any) { 35 | const pass = received.status() == 204; 36 | if (pass) { 37 | return { 38 | message: () => 'passed', 39 | pass: true, 40 | }; 41 | } else { 42 | return { 43 | message: () => 44 | `Expected status code 204, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 45 | pass: false, 46 | }; 47 | } 48 | }, 49 | toHaveUnauthorizedStatus(received: any) { 50 | const pass = received.status() == 401; 51 | if (pass) { 52 | return { 53 | message: () => 'passed', 54 | pass: true, 55 | }; 56 | } else { 57 | return { 58 | message: () => 59 | `Expected status code 401, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 60 | pass: false, 61 | }; 62 | } 63 | }, 64 | toHaveForbiddenStatus(received: any) { 65 | const pass = received.status() == 403; 66 | if (pass) { 67 | return { 68 | message: () => 'passed', 69 | pass: true, 70 | }; 71 | } else { 72 | return { 73 | message: () => 74 | `Expected status code 403, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 75 | pass: false, 76 | }; 77 | } 78 | }, 79 | toHaveNotFoundStatus(received: any) { 80 | const pass = received.status() == 404; 81 | if (pass) { 82 | return { 83 | message: () => 'passed', 84 | pass: true, 85 | }; 86 | } else { 87 | return { 88 | message: () => 89 | `Expected status code 404, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 90 | pass: false, 91 | }; 92 | } 93 | }, 94 | toHaveUnprocessableContentStatus(received: any) { 95 | const pass = received.status() == 422; 96 | if (pass) { 97 | return { 98 | message: () => 'passed', 99 | pass: true, 100 | }; 101 | } else { 102 | return { 103 | message: () => 104 | `Expected status code 422, but got ${received.status()}.\nResponse: ${JSON.stringify(received)}`, 105 | pass: false, 106 | }; 107 | } 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /src/api/fixtures/typesExpect.ts: -------------------------------------------------------------------------------- 1 | import { expect as baseExpect } from '@playwright/test'; 2 | 3 | export const expect = baseExpect.extend({ 4 | toBeNumber(received: any) { 5 | const isNumber = typeof received == 'number'; 6 | if (isNumber) { 7 | return { 8 | message: () => 'passed', 9 | pass: true, 10 | }; 11 | } else { 12 | return { 13 | message: () => 14 | `Expected ${received} is a number, but got ${typeof received}`, 15 | pass: false, 16 | }; 17 | } 18 | }, 19 | toBeString(received: any) { 20 | const isString = typeof received == 'string'; 21 | if (isString) { 22 | return { 23 | message: () => 'passed', 24 | pass: true, 25 | }; 26 | } else { 27 | return { 28 | message: () => 29 | `Expected ${received} is a string, but got ${typeof received}`, 30 | pass: false, 31 | }; 32 | } 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/api/lib/dataFactory/auth.ts: -------------------------------------------------------------------------------- 1 | import UserApi from '@api/user/userApi'; 2 | import { expect } from '@fixtures/baseAPIFixture'; 3 | import { request } from '@playwright/test'; 4 | import { RegisterUser } from '@api/user/types'; 5 | import { en, Faker } from '@faker-js/faker'; 6 | import { minusYears } from 'utils/date'; 7 | import { randomEmail, randomPassword } from 'utils/randomize'; 8 | 9 | const faker = new Faker({ locale: en }); 10 | 11 | export async function createRandomUserBody(email?: string, password?: string) { 12 | const userBody: RegisterUser = { 13 | first_name: faker.person.firstName(), 14 | last_name: faker.person.lastName(), 15 | dob: minusYears(30), 16 | phone: '0987654321', 17 | email: email || randomEmail(), 18 | password: password || randomPassword(), 19 | address: { 20 | street: faker.location.streetAddress({ useFullAddress: true }), 21 | city: faker.location.city(), 22 | state: faker.location.state(), 23 | country: 'Australia', // faker.location.country(), 24 | postal_code: faker.location.zipCode(), 25 | }, 26 | }; 27 | 28 | return userBody; 29 | } 30 | 31 | export async function createRandomUser(email?: string, password?: string) { 32 | let body: RegisterUser; 33 | 34 | await expect(async () => { 35 | const userApi = new UserApi(await request.newContext(), {}); 36 | const payload = await createRandomUserBody(email, password); 37 | const response = await userApi.createUser(payload); 38 | 39 | expect(response.status()).toEqual(201); 40 | body = await response.json(); 41 | body.password = payload.password; 42 | }).toPass({ 43 | intervals: [1_000, 4_000, 10_000], 44 | timeout: 10_000, 45 | }); 46 | 47 | return body; 48 | } 49 | -------------------------------------------------------------------------------- /src/api/lib/dataFactory/brand.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@fixtures/baseAPIFixture'; 2 | import { request } from '@playwright/test'; 3 | import { Headers } from '@api/baseAPI'; 4 | import BrandApi from '@api/brand/brandApi'; 5 | import { stringToSlug, randomWords } from 'utils/randomize'; 6 | import { BrandResponse } from '@api/brand/types'; 7 | 8 | export async function createBrand(headers: Headers) { 9 | let body: BrandResponse; 10 | 11 | const brandApi = new BrandApi(await request.newContext(), headers); 12 | 13 | await expect(async () => { 14 | const brandName = randomWords(); 15 | const resp = await brandApi.createBrand(brandName, stringToSlug(brandName)); 16 | expect(resp).toHaveCreatedStatus(); 17 | body = await resp.json(); 18 | }).toPass({ 19 | intervals: [1_000, 2_000, 5_000], 20 | timeout: 10_000, 21 | }); 22 | 23 | return body; 24 | } 25 | -------------------------------------------------------------------------------- /src/api/lib/dataFactory/cart.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@fixtures/baseAPIFixture'; 2 | import { request } from '@playwright/test'; 3 | import CartApi from 'src/api/cart/cartApi'; 4 | import { extractField } from '../helpers/responseHelpers'; 5 | import { AddProductToCart } from '@api/cart/types'; 6 | import { Headers } from '@api/baseAPI'; 7 | 8 | export async function createCart(headers: Headers) { 9 | let cartId: string; 10 | const cartApi = new CartApi(await request.newContext(), headers); 11 | 12 | await expect(async () => { 13 | const response = await cartApi.createCart(); 14 | expect(response).toHaveCreatedStatus(); 15 | 16 | cartId = await extractField('id', response); 17 | }).toPass({ 18 | intervals: [1_000, 2_000, 5_000], 19 | timeout: 10_000, 20 | }); 21 | 22 | return cartId; 23 | } 24 | 25 | export async function addProductToCart( 26 | data: AddProductToCart, 27 | headers: Headers 28 | ) { 29 | await expect(async () => { 30 | const cartId = await createCart(headers); 31 | const cartApi = new CartApi(await request.newContext(), headers); 32 | const resp = await cartApi.addProductToCart(cartId, data); 33 | expect(resp).toHaveOKStatus(); 34 | }).toPass({ 35 | intervals: [1_000, 2_000, 5_000], 36 | timeout: 10_000, 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/api/lib/dataFactory/product.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { expect } from '@fixtures/baseAPIFixture'; 4 | import { request } from '@playwright/test'; 5 | import ProductApi from 'src/api/product/productApi'; 6 | 7 | // Get list products on 1st page 8 | async function getListProducts() { 9 | let products: any[]; 10 | 11 | const productApi = new ProductApi(await request.newContext(), {}); 12 | await expect(async () => { 13 | const productRes = await productApi.getProducts(); 14 | expect(productRes).toHaveOKStatus(); 15 | 16 | const productBody = await productRes.json(); 17 | products = productBody.data; 18 | }).toPass({ 19 | intervals: [1_000, 2_000, 5_000], 20 | timeout: 20_000, 21 | }); 22 | 23 | return products; 24 | } 25 | 26 | export async function getRandomInStockProduct() { 27 | const products = await getListProducts(); 28 | const inStockProducts = products.filter((product) => product.in_stock); 29 | return _.sample(inStockProducts); 30 | } 31 | 32 | export async function getRandomInStockProductId() { 33 | const products = await getListProducts(); 34 | const product = _.sample(products.filter((product) => product.in_stock)); 35 | return product.id; 36 | } 37 | 38 | export async function getRandomInStockProducts(count: number) { 39 | const products = await getListProducts(); 40 | const inStockProducts = products.filter((product) => product.in_stock); 41 | if (count < 2) throw new Error('Count must be greater than 1'); 42 | return _.sampleSize(inStockProducts, count); 43 | } 44 | 45 | export async function getRandomInStockProductIds(count: number) { 46 | const products = await getListProducts(); 47 | const inStockProducts = products.filter((product) => product.in_stock); 48 | if (count < 2) throw new Error('Count must be greater than 1'); 49 | const results = _.sampleSize(inStockProducts, count); 50 | return _.map(results, 'id'); 51 | } 52 | 53 | export async function getRandomOutOfStockProduct() { 54 | const products = await getListProducts(); 55 | const outOfStockProducts = products.filter((product) => !product.in_stock); 56 | 57 | return _.sample(outOfStockProducts); 58 | } 59 | -------------------------------------------------------------------------------- /src/api/lib/helpers/authHelpers.ts: -------------------------------------------------------------------------------- 1 | import UserApi from '@api/user/userApi'; 2 | import { expect, request } from '@playwright/test'; 3 | 4 | export async function getAccessToken(email: string, password: string) { 5 | const userApi = new UserApi(await request.newContext(), {}); 6 | const resp = await userApi.login(email, password); 7 | 8 | expect(resp.status()).toEqual(200); 9 | const body = await resp.json(); 10 | 11 | return body['access_token']; 12 | } 13 | 14 | export async function createHeaders(email: string, password: string) { 15 | const headers = {}; 16 | const token = await getAccessToken(email, password); 17 | 18 | headers['Authorization'] = `Bearer ${token}`; 19 | headers['Accept'] = 'application/json'; 20 | headers['Content-Type'] = 'application/json'; 21 | 22 | return headers; 23 | } 24 | -------------------------------------------------------------------------------- /src/api/lib/helpers/env.ts: -------------------------------------------------------------------------------- 1 | export default class Env { 2 | static readonly BASE_URL = 3 | process.env.BASE_URL || 'https://practicesoftwaretesting.com/'; 4 | static readonly API_URL = 5 | process.env.API_URL || 'https://api.practicesoftwaretesting.com/'; 6 | static readonly ADMIN_EMAIL = process.env.ADMIN_EMAIL; 7 | static readonly ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; 8 | static readonly CUSTOMER_01_EMAIL = process.env.CUSTOMER_01_EMAIL; 9 | static readonly CUSTOMER_01_PASSWORD = process.env.CUSTOMER_01_PASSWORD; 10 | static readonly CUSTOMER_02_EMAIL = process.env.CUSTOMER_02_EMAIL; 11 | static readonly CUSTOMER_02_PASSWORD = process.env.CUSTOMER_02_PASSWORD; 12 | } 13 | -------------------------------------------------------------------------------- /src/api/lib/helpers/responseHelpers.ts: -------------------------------------------------------------------------------- 1 | import { APIResponse } from '@playwright/test'; 2 | 3 | export async function extractField(field: string, response: APIResponse) { 4 | return (await response.json())[field]; 5 | } 6 | -------------------------------------------------------------------------------- /src/api/lib/helpers/validateJsonSchema.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@fixtures/baseAPIFixture'; 2 | import Ajv from 'ajv'; 3 | 4 | export async function validateJsonSchema( 5 | fileName: string, 6 | filePath: string, 7 | body: object 8 | ) { 9 | const schemaFile = require( 10 | `../../lib/schemas/${filePath}/${fileName}_schema.json` 11 | ); 12 | 13 | const ajv = new Ajv({ allErrors: true }); 14 | const validate = ajv.compile(schemaFile); 15 | const isValidSchema = validate(body); 16 | 17 | if (!isValidSchema) { 18 | console.log(validate.errors); 19 | console.log(`Response body: ${body}`); 20 | } 21 | 22 | expect(isValidSchema).toBe(true); 23 | } 24 | -------------------------------------------------------------------------------- /src/api/lib/schemas/GET_search_product.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Search Product Response", 4 | "type": "object", 5 | "properties": { 6 | "current_page": { 7 | "type": "string" 8 | }, 9 | "data": { 10 | "type": "array", 11 | "items": { 12 | "type": "object", 13 | "properties": {}, 14 | "required": [""], 15 | "additionalProperties": false 16 | } 17 | }, 18 | "from": { 19 | "type": "number", 20 | "minimum": 1 21 | }, 22 | "last_page": { 23 | "type": "number", 24 | "minimum": 1 25 | }, 26 | "per_page": { 27 | "type": "number", 28 | "minimum": 1 29 | }, 30 | "to": { 31 | "type": "number", 32 | "minimum": 1 33 | }, 34 | "total": { 35 | "type": "number", 36 | "minimum": 0 37 | } 38 | }, 39 | "required": [ 40 | "current_page", 41 | "data", 42 | "from", 43 | "last_page", 44 | "per_page", 45 | "to", 46 | "total" 47 | ], 48 | "additionalProperties": false 49 | } 50 | -------------------------------------------------------------------------------- /src/api/lib/schemas/cart/GET_cart_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "string" 6 | }, 7 | "additional_discount_percentage": { 8 | "type": ["string", "null"] 9 | }, 10 | "lat": { 11 | "type": ["string", "null"] 12 | }, 13 | "lng": { 14 | "type": ["string", "null"] 15 | }, 16 | "cart_items": { 17 | "type": "array", 18 | "items": { 19 | "anyOf": [ 20 | { 21 | "type": "object", 22 | "properties": { 23 | "id": { 24 | "type": "string" 25 | }, 26 | "quantity": { 27 | "type": "number", 28 | "exclusiveMinimum": 0 29 | }, 30 | "discount_percentage": { 31 | "type": ["string", "null"] 32 | }, 33 | "cart_id": { 34 | "type": "string" 35 | }, 36 | "product_id": { 37 | "type": "string" 38 | }, 39 | "product": { 40 | "type": "object", 41 | "properties": { 42 | "id": { 43 | "type": "string" 44 | }, 45 | "name": { 46 | "type": "string" 47 | }, 48 | "description": { 49 | "type": "string" 50 | }, 51 | "price": { 52 | "type": "number" 53 | }, 54 | "is_location_offer": { 55 | "type": "boolean" 56 | }, 57 | "is_rental": { 58 | "type": "boolean" 59 | }, 60 | "in_stock": { 61 | "type": "boolean" 62 | } 63 | }, 64 | "required": [ 65 | "id", 66 | "name", 67 | "description", 68 | "price", 69 | "is_location_offer", 70 | "is_rental", 71 | "in_stock" 72 | ], 73 | "additionalProperties": false 74 | } 75 | }, 76 | "required": [ 77 | "id", 78 | "quantity", 79 | "discount_percentage", 80 | "cart_id", 81 | "product_id", 82 | "product" 83 | ], 84 | "additionalProperties": false 85 | } 86 | ] 87 | } 88 | } 89 | }, 90 | "required": [ 91 | "id", 92 | "additional_discount_percentage", 93 | "lat", 94 | "lng", 95 | "cart_items" 96 | ], 97 | "additionalProperties": false 98 | } 99 | -------------------------------------------------------------------------------- /src/api/lib/schemas/product/GET_list_product_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "current_page": { 6 | "type": "number", 7 | "exclusiveMinimum": 0 8 | }, 9 | "data": { 10 | "type": "array", 11 | "items": { 12 | "type": "object", 13 | "properties": { 14 | "id": { 15 | "type": "string" 16 | }, 17 | "name": { 18 | "type": "string" 19 | }, 20 | "description": { 21 | "type": "string" 22 | }, 23 | "price": { 24 | "type": "number", 25 | "exclusiveMinimum": 0 26 | }, 27 | "is_location_offer": { 28 | "type": "boolean" 29 | }, 30 | "is_rental": { 31 | "type": "boolean" 32 | }, 33 | "in_stock": { 34 | "type": "boolean" 35 | }, 36 | "product_image": { 37 | "type": "object", 38 | "properties": { 39 | "id": { 40 | "type": "string" 41 | }, 42 | "by_name": { 43 | "type": "string" 44 | }, 45 | "by_url": { 46 | "type": "string" 47 | }, 48 | "source_name": { 49 | "type": "string" 50 | }, 51 | "source_url": { 52 | "type": "string" 53 | }, 54 | "file_name": { 55 | "type": "string" 56 | }, 57 | "title": { 58 | "type": "string" 59 | } 60 | }, 61 | "required": [ 62 | "id", 63 | "by_name", 64 | "by_url", 65 | "source_name", 66 | "source_url", 67 | "file_name", 68 | "title" 69 | ], 70 | "additionalProperties": false 71 | }, 72 | "category": { 73 | "type": "object", 74 | "properties": { 75 | "id": { 76 | "type": "string" 77 | }, 78 | "name": { 79 | "type": "string" 80 | }, 81 | "slug": { 82 | "type": "string" 83 | }, 84 | "parent_id": { 85 | "type": "string" 86 | } 87 | }, 88 | "required": ["id", "name", "slug", "parent_id"], 89 | "additionalProperties": false 90 | }, 91 | "brand": { 92 | "type": "object", 93 | "properties": { 94 | "id": { 95 | "type": "string" 96 | }, 97 | "name": { 98 | "type": "string" 99 | }, 100 | "slug": { 101 | "type": "string" 102 | } 103 | }, 104 | "required": ["id", "name", "slug"], 105 | "additionalProperties": false 106 | } 107 | }, 108 | "required": [ 109 | "id", 110 | "name", 111 | "description", 112 | "price", 113 | "is_location_offer", 114 | "is_rental", 115 | "in_stock", 116 | "product_image", 117 | "category", 118 | "brand" 119 | ], 120 | "additionalProperties": false 121 | } 122 | }, 123 | "from": { 124 | "type": ["number", "null"] 125 | }, 126 | "last_page": { 127 | "type": ["number"] 128 | }, 129 | "per_page": { 130 | "type": "number" 131 | }, 132 | "to": { 133 | "type": ["number", "null"] 134 | }, 135 | "total": { 136 | "type": "number", 137 | "minimum": 0 138 | } 139 | }, 140 | "required": [ 141 | "current_page", 142 | "data", 143 | "from", 144 | "last_page", 145 | "per_page", 146 | "to", 147 | "total" 148 | ], 149 | "additionalProperties": false 150 | } 151 | -------------------------------------------------------------------------------- /src/api/lib/schemas/product/GET_product_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "string" 6 | }, 7 | "name": { 8 | "type": "string" 9 | }, 10 | "description": { 11 | "type": "string" 12 | }, 13 | "price": { 14 | "type": "number", 15 | "exclusiveMinimum": 0 16 | }, 17 | "is_location_offer": { 18 | "type": "boolean" 19 | }, 20 | "is_rental": { 21 | "type": "boolean" 22 | }, 23 | "in_stock": { 24 | "type": "boolean" 25 | }, 26 | "product_image": { 27 | "type": "object", 28 | "properties": { 29 | "id": { 30 | "type": "string" 31 | }, 32 | "by_name": { 33 | "type": "string" 34 | }, 35 | "by_url": { 36 | "type": "string" 37 | }, 38 | "source_name": { 39 | "type": "string" 40 | }, 41 | "source_url": { 42 | "type": "string" 43 | }, 44 | "file_name": { 45 | "type": "string" 46 | }, 47 | "title": { 48 | "type": "string" 49 | } 50 | }, 51 | "required": [ 52 | "id", 53 | "by_name", 54 | "by_url", 55 | "source_name", 56 | "source_url", 57 | "file_name", 58 | "title" 59 | ], 60 | "additionalProperties": false 61 | }, 62 | "category": { 63 | "type": "object", 64 | "properties": { 65 | "id": { 66 | "type": "string" 67 | }, 68 | "name": { 69 | "type": "string" 70 | }, 71 | "slug": { 72 | "type": "string" 73 | }, 74 | "parent_id": { 75 | "type": "string" 76 | } 77 | }, 78 | "required": ["id", "name", "slug", "parent_id"], 79 | "additionalProperties": false 80 | }, 81 | "brand": { 82 | "type": "object", 83 | "properties": { 84 | "id": { 85 | "type": "string" 86 | }, 87 | "name": { 88 | "type": "string" 89 | }, 90 | "slug": { 91 | "type": "string" 92 | } 93 | }, 94 | "required": ["id", "name", "slug"], 95 | "additionalProperties": false 96 | } 97 | }, 98 | "required": [ 99 | "id", 100 | "name", 101 | "description", 102 | "price", 103 | "is_location_offer", 104 | "is_rental", 105 | "in_stock", 106 | "product_image", 107 | "category", 108 | "brand" 109 | ], 110 | "additionalProperties": false 111 | } 112 | -------------------------------------------------------------------------------- /src/api/lib/schemas/user/GET_login_user_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Login response schema", 4 | "type": "object", 5 | "properties": { 6 | "access_token": { 7 | "type": "string" 8 | }, 9 | "token_type": { 10 | "type": "string", 11 | "const": "bearer" 12 | }, 13 | "expires_in": { 14 | "type": "number", 15 | "const": 300 16 | } 17 | }, 18 | "required": ["access_token", "token_type", "expires_in"], 19 | "additionalProperties": false 20 | } 21 | -------------------------------------------------------------------------------- /src/api/lib/schemas/user/GET_register_user_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "first_name": { 9 | "type": "string" 10 | }, 11 | "last_name": { 12 | "type": "string" 13 | }, 14 | "dob": { 15 | "type": "string" 16 | }, 17 | "phone": { 18 | "type": "string" 19 | }, 20 | "email": { 21 | "type": "string" 22 | }, 23 | "totp_enabled": { 24 | "type": "boolean" 25 | }, 26 | "created_at": { 27 | "type": "string" 28 | }, 29 | "address": { 30 | "type": "object", 31 | "properties": { 32 | "street": { 33 | "type": "string" 34 | }, 35 | "city": { 36 | "type": "string" 37 | }, 38 | "state": { 39 | "type": "string" 40 | }, 41 | "country": { 42 | "type": "string" 43 | }, 44 | "postal_code": { 45 | "type": "string" 46 | } 47 | }, 48 | "required": ["street", "city", "state", "country", "postal_code"] 49 | } 50 | }, 51 | "required": [ 52 | "id", 53 | "first_name", 54 | "last_name", 55 | "dob", 56 | "phone", 57 | "email", 58 | "totp_enabled", 59 | "created_at", 60 | "address" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /src/api/lib/schemas/user/POST_register_user_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string" 7 | }, 8 | "first_name": { 9 | "type": "string" 10 | }, 11 | "last_name": { 12 | "type": "string" 13 | }, 14 | "dob": { 15 | "type": "string" 16 | }, 17 | "phone": { 18 | "type": "string" 19 | }, 20 | "email": { 21 | "type": "string" 22 | }, 23 | "created_at": { 24 | "type": "string" 25 | }, 26 | "address": { 27 | "type": "object", 28 | "properties": { 29 | "street": { 30 | "type": "string" 31 | }, 32 | "city": { 33 | "type": "string" 34 | }, 35 | "state": { 36 | "type": "string" 37 | }, 38 | "country": { 39 | "type": "string" 40 | }, 41 | "postal_code": { 42 | "type": "string" 43 | } 44 | }, 45 | "required": ["street", "city", "state", "country", "postal_code"], 46 | "additionalProperties": false 47 | } 48 | }, 49 | "required": [ 50 | "id", 51 | "first_name", 52 | "last_name", 53 | "dob", 54 | "phone", 55 | "email", 56 | "created_at", 57 | "address" 58 | ], 59 | "additionalProperties": false 60 | } 61 | -------------------------------------------------------------------------------- /src/api/product/productApi.ts: -------------------------------------------------------------------------------- 1 | import BaseApi, { Headers } from '@api/baseAPI'; 2 | import { APIRequestContext } from '@playwright/test'; 3 | import Env from '@api/lib/helpers/env'; 4 | 5 | export default class ProductApi extends BaseApi { 6 | private endpoint: string; 7 | 8 | constructor(request: APIRequestContext, headers: Headers) { 9 | super(request, headers); 10 | this.endpoint = Env.API_URL + 'products'; 11 | } 12 | 13 | async searchProduct(name: string, page = 1) { 14 | return await this.request.get(`${this.endpoint}/search`, { 15 | params: { 16 | q: name, 17 | page: page, 18 | }, 19 | }); 20 | } 21 | 22 | async getProducts(params?: { [key: string]: string | number }) { 23 | return await this.request.get(this.endpoint, { 24 | params: params, 25 | }); 26 | } 27 | 28 | async getProduct(productId: string) { 29 | return await this.request.get(`${this.endpoint}/${productId}`); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/user/types.ts: -------------------------------------------------------------------------------- 1 | export interface RegisterUser { 2 | first_name?: string; 3 | last_name?: string; 4 | dob?: string; 5 | phone?: string; 6 | email?: string; 7 | password?: string; 8 | address?: Address; 9 | } 10 | 11 | export interface Address { 12 | street?: string; 13 | city?: string; 14 | state?: string; 15 | country?: string; 16 | postal_code?: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/api/user/userApi.ts: -------------------------------------------------------------------------------- 1 | import { APIRequestContext } from '@playwright/test'; 2 | import Env from '@api/lib/helpers/env'; 3 | import { RegisterUser } from './types'; 4 | import BaseApi, { Headers } from '../baseAPI'; 5 | 6 | export default class UserApi extends BaseApi { 7 | static readonly USER = 'users'; 8 | static readonly LOGIN = `${UserApi.USER}/login`; 9 | static readonly LOGOUT = `${UserApi.USER}/logout`; 10 | static readonly REGISTER_USER = `${UserApi.USER}/register`; 11 | static readonly FORGOT_PASSWORD = `${UserApi.USER}/forgot-password`; 12 | static readonly CHANGE_PASSWORD = `${UserApi.USER}/change-password`; 13 | static readonly REFRESH_TOKEN = `${UserApi.USER}/refresh`; 14 | 15 | constructor(request: APIRequestContext, headers: Headers = {}) { 16 | super(request, headers); 17 | } 18 | 19 | async login(email: string, password: string) { 20 | return await this.request.post(`${Env.API_URL}${UserApi.LOGIN}`, { 21 | data: { 22 | email, 23 | password, 24 | }, 25 | }); 26 | } 27 | 28 | async logout(headers: Headers) { 29 | return await this.request.get(`${Env.API_URL}${UserApi.LOGOUT}`, { 30 | headers: headers, 31 | }); 32 | } 33 | 34 | async createUser(body: RegisterUser) { 35 | return await this.request.post(`${Env.API_URL}${UserApi.REGISTER_USER}`, { 36 | data: body, 37 | }); 38 | } 39 | 40 | async deleteUser(userId: string, headers: Headers) { 41 | return await this.request.delete( 42 | `${Env.API_URL}${UserApi.USER}/${userId}`, 43 | { 44 | headers: headers, 45 | } 46 | ); 47 | } 48 | 49 | async getUser(userId: string, headers: Headers) { 50 | return await this.request.get(`${Env.API_URL}${UserApi.USER}/${userId}`, { 51 | headers: headers, 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/fixtures/baseAPIFixture.ts: -------------------------------------------------------------------------------- 1 | import { mergeExpects } from '@playwright/test'; 2 | import { expect as typesExpect } from 'src/api/fixtures/typesExpect'; 3 | import { expect as statusesExpect } from 'src/api/fixtures/statusesExpect'; 4 | 5 | export { test } from '@playwright/test'; 6 | 7 | export const expect = mergeExpects(typesExpect, statusesExpect); 8 | -------------------------------------------------------------------------------- /src/fixtures/baseUIFixture.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test'; 2 | import AccountPage from '@pages/account/accountPage'; 3 | import NavigationComponent from '@pages/components/navigationBar'; 4 | import SideBarComponent from '@pages/components/sideBarComponent'; 5 | import CheckoutPage from '@pages/checkout/checkoutPage'; 6 | import LoginPage from '@pages/login/loginPage'; 7 | import SearchProduct from '@pages/product/searchProductPage'; 8 | import HomePage from '@pages/homePage'; 9 | import RegisterPage from '@pages/register/registerPage'; 10 | import ProductPage from '@pages/product/productPage'; 11 | import InvoicePage from '@pages/invoice/invoicePage'; 12 | import DashboardPage from '@pages/admin/dashboardPage'; 13 | import BrandPage from '@pages/admin/brand/brandPage'; 14 | 15 | export type BaseUIComponent = { 16 | navComponent: NavigationComponent; 17 | sideBarComponent: SideBarComponent; 18 | }; 19 | 20 | export type BaseUI = { 21 | homePage: HomePage; 22 | brandPage: BrandPage; 23 | dashboardPage: DashboardPage; 24 | loginPage: LoginPage; 25 | registerPage: RegisterPage; 26 | searchPage: SearchProduct; 27 | productPage: ProductPage; 28 | accountPage: AccountPage; 29 | checkoutPage: CheckoutPage; 30 | invoicePage: InvoicePage; 31 | }; 32 | 33 | export const test = base.extend< 34 | BaseUI & BaseUIComponent & { loggedInPage: any } 35 | >({ 36 | homePage: async ({ page }, use) => { 37 | await use(new HomePage(page)); 38 | }, 39 | brandPage: async ({ page }, use) => { 40 | await use(new BrandPage(page)); 41 | }, 42 | dashboardPage: async ({ page }, use) => { 43 | await use(new DashboardPage(page)); 44 | }, 45 | loginPage: async ({ page }, use) => { 46 | await use(new LoginPage(page)); 47 | }, 48 | registerPage: async ({ page }, use) => { 49 | await use(new RegisterPage(page)); 50 | }, 51 | productPage: async ({ page }, use) => { 52 | await use(new ProductPage(page)); 53 | }, 54 | searchPage: async ({ page }, use) => { 55 | await use(new SearchProduct(page)); 56 | }, 57 | accountPage: async ({ page }, use) => { 58 | await use(new AccountPage(page)); 59 | }, 60 | checkoutPage: async ({ page }, use) => { 61 | await use(new CheckoutPage(page)); 62 | }, 63 | invoicePage: async ({ page }, use) => { 64 | await use(new InvoicePage(page)); 65 | }, 66 | navComponent: async ({ page }, use) => { 67 | await use(new NavigationComponent(page)); 68 | }, 69 | }); 70 | 71 | export { expect } from '@playwright/test'; 72 | -------------------------------------------------------------------------------- /src/mock-api/common-mock-api.ts: -------------------------------------------------------------------------------- 1 | import test, { Page } from '@playwright/test'; 2 | import { Options } from './types'; 3 | 4 | export async function mockInvoicesResponse(options: Options, page: Page) { 5 | await test.step('Mocking invoice API response...', async () => { 6 | await page.route('**/invoices?page=*', async (route) => { 7 | await route.fulfill(options); 8 | }); 9 | }); 10 | } 11 | 12 | export async function mockInvoiceResponse(options: Options, page: Page) { 13 | await test.step('Mocking invoice API response...', async () => { 14 | await page.route('**/invoices/*', async (route) => { 15 | await route.fulfill(options); 16 | }); 17 | }); 18 | } 19 | 20 | export async function mockCartResponse(options: Options, page: Page) { 21 | await test.step('Mocking cart API response...', async () => { 22 | await page.route('**/carts/*', async (route) => { 23 | await route.fulfill(options); 24 | }); 25 | }); 26 | } 27 | 28 | export async function mockProductResponse(options: Options, page: Page) { 29 | await test.step('Mocking product API response', async () => { 30 | await page.route('**/products/*', async (route) => { 31 | await route.fulfill(options); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/mock-api/types.ts: -------------------------------------------------------------------------------- 1 | import { APIResponse } from '@playwright/test'; 2 | import { Serializable } from 'worker_threads'; 3 | 4 | export interface Options { 5 | body?: string | Buffer; 6 | contentType?: string; 7 | headers?: { 8 | [key: string]: string; 9 | }; 10 | json?: Serializable; 11 | path?: string; 12 | response?: APIResponse; 13 | status?: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/account/accountPage.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import InvoicePage from '@pages/invoice/invoicePage'; 3 | import { Page } from '@playwright/test'; 4 | import { step } from 'playwright-helpers'; 5 | 6 | export default class AccountPage extends BasePage { 7 | static readonly accountUri = 'account'; 8 | static readonly profileUri = `${AccountPage.accountUri}/accountUri`; 9 | static readonly dashboardUri = 'admin/dashboard'; 10 | 11 | constructor(page: Page) { 12 | super(page); 13 | } 14 | 15 | /** 16 | * Assertions 17 | */ 18 | @step() 19 | async expectNavigateToAccountPage() { 20 | await this.page.waitForURL(`**/${AccountPage.accountUri}`); 21 | } 22 | 23 | @step() 24 | async expectNavigateToProfilePage() { 25 | await this.page.waitForURL(`**/${AccountPage.profileUri}`); 26 | } 27 | 28 | @step() 29 | async expectNavigateToInvoicesPage() { 30 | await this.page.waitForURL(`**/${InvoicePage.invoiceUri}`); 31 | } 32 | 33 | @step() 34 | async expectNavigateToInvoicePage() { 35 | await this.page.waitForURL(`**/${InvoicePage.invoiceUri}/*`); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/admin/brand/brandPage.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { expect, Page } from '@playwright/test'; 3 | import { step } from 'playwright-helpers'; 4 | 5 | export default class BrandPage extends BasePage { 6 | static readonly brandUri = 'admin/brands'; 7 | 8 | constructor(page: Page) { 9 | super(page); 10 | } 11 | 12 | /** 13 | * Assertions 14 | */ 15 | @step() 16 | async expectBrandNameDisplayed(name: string) { 17 | await expect(this.page.getByText(name)).toBeVisible(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/admin/dashboardPage.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { Page } from '@playwright/test'; 3 | import { step } from 'playwright-helpers'; 4 | 5 | export default class DashboardPage extends BasePage { 6 | static readonly adminUri = 'admin'; 7 | static readonly dashboardUri = `${this.adminUri}/dashboard`; 8 | 9 | constructor(page: Page) { 10 | super(page); 11 | } 12 | 13 | /** 14 | * Assertions 15 | */ 16 | @step() 17 | async expectNavigateToDashboardPage() { 18 | await this.page.waitForURL(`**/${DashboardPage.dashboardUri}`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/basePage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@playwright/test'; 2 | 3 | export default abstract class BasePage { 4 | protected page: Page; 5 | 6 | constructor(page: Page) { 7 | this.page = page; 8 | } 9 | 10 | /** 11 | * Locators 12 | */ 13 | // private pageTitle = this.page.getByTestId('page-title'); 14 | 15 | /** 16 | * Actions 17 | */ 18 | 19 | /** 20 | * Assertions 21 | */ 22 | // async expectPageTitleDisplayed() { 23 | // await expect(this.pageTitle()) 24 | // } 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/checkout/checkoutPage.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from '@playwright/test'; 2 | import { step } from 'playwright-helpers'; 3 | 4 | import LoginComponent from '@pages/components/login'; 5 | import CartComponent from './components/cart'; 6 | import AddressComponent from '@pages/components/address'; 7 | import PaymentComponent from './components/payment'; 8 | import BasePage from '@pages/basePage'; 9 | 10 | export default class CheckoutPage extends BasePage { 11 | static readonly checkoutUri = 'checkout'; 12 | 13 | cartComponent: CartComponent; 14 | loginComponent: LoginComponent; 15 | addressComponent: AddressComponent; 16 | paymentComponent: PaymentComponent; 17 | 18 | constructor(page: Page) { 19 | super(page); 20 | this.cartComponent = new CartComponent(page); 21 | this.loginComponent = new LoginComponent(page); 22 | this.addressComponent = new AddressComponent(page); 23 | this.paymentComponent = new PaymentComponent(page); 24 | } 25 | 26 | /** 27 | * Application contents 28 | */ 29 | readonly processToCheckoutTxt = 'Proceed to checkout'; 30 | readonly cartTxt = 'Cart'; 31 | readonly loggedInTxt = 'you are already logged in'; 32 | readonly billingAddressTxt = 'Billing Address'; 33 | readonly paymentTxt = 'Payment'; 34 | readonly signedInSucceededTxt = 35 | 'you are already logged in. You can proceed to checkout.'; 36 | readonly paymentSuccessTxt = 'Payment was successful'; 37 | 38 | /** 39 | * Locators 40 | */ 41 | readonly checkoutForm = this.page.locator('//app-checkout'); 42 | readonly processToCheckoutBtn = this.page.getByRole('button', { 43 | name: this.processToCheckoutTxt, 44 | }); 45 | readonly signInMsg = this.page.locator('.login-form-1 > p'); 46 | readonly currentStep = this.page.locator('ul.steps-indicator > li.current'); 47 | readonly addressStepTitle = this.page.locator('app-address h3'); 48 | readonly paymentStepTitle = this.page.locator('app-payment h3'); 49 | readonly signInStep = this.page.locator('.login-form-1').nth(0); 50 | readonly confirmBtn = this.page.getByTestId('finish'); 51 | readonly paymentSuccessMsg = this.page.getByTestId('payment-success-message'); 52 | 53 | /** 54 | * Actions 55 | */ 56 | @step() 57 | async clickProcessToCheckout() { 58 | await this.processToCheckoutBtn.click(); 59 | } 60 | 61 | @step() 62 | async clickConfirm() { 63 | await this.confirmBtn.click(); 64 | } 65 | 66 | /** 67 | * Assertions 68 | */ 69 | @step() 70 | async expectProcessToCheckoutDisabled() { 71 | await expect(this.processToCheckoutBtn).toBeDisabled(); 72 | } 73 | 74 | @step() 75 | async expectNavigateToCartStep() { 76 | await expect(this.currentStep).toContainText(this.cartTxt); 77 | } 78 | 79 | @step() 80 | async expectNavigateToSignInStep() { 81 | await expect(this.signInStep).not.toContainText(this.loggedInTxt); 82 | } 83 | 84 | @step() 85 | async expectNavigateToSignedInStep() { 86 | await expect(this.signInStep).toContainText(this.loggedInTxt); 87 | } 88 | 89 | @step() 90 | async expectToNavigateToBillingAddressStep() { 91 | await expect(this.addressStepTitle).toHaveText(this.billingAddressTxt); 92 | } 93 | 94 | @step() 95 | async expectNavigateToPaymentStep() { 96 | await expect(this.paymentStepTitle).toHaveText(this.paymentTxt); 97 | } 98 | 99 | @step() 100 | async expectSignedInSuccess() { 101 | await expect(this.signInMsg).toContainText(this.signedInSucceededTxt); 102 | } 103 | 104 | @step() 105 | async expectPaymentSuccessMsg() { 106 | await expect(this.paymentSuccessMsg).toHaveText(this.paymentSuccessTxt); 107 | } 108 | 109 | @step() 110 | async expectPaymentStepOpened() { 111 | // await expect(this.payment) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/pages/checkout/components/cart.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from '@playwright/test'; 2 | import { ProductInputModel } from '../../product/types'; 3 | import { step } from 'playwright-helpers'; 4 | import BasePage from '@pages/basePage'; 5 | 6 | export default class CartComponent extends BasePage { 7 | constructor(page: Page) { 8 | super(page); 9 | } 10 | 11 | /** 12 | * Application contents 13 | */ 14 | 15 | /** 16 | * Locators 17 | */ 18 | readonly wizardSteps = this.page.locator('.wizard-steps'); 19 | readonly productItem = (productName: string) => 20 | this.wizardSteps.filter({ hasText: new RegExp(productName) }); 21 | readonly productName = (name: string) => 22 | this.productItem(name).getByTestId('product-title'); 23 | readonly productQtyField = (name: string) => 24 | this.productItem(name).getByTestId('product-quantity'); 25 | readonly productUnitPrice = (name: string) => 26 | this.productItem(name).getByTestId('product-price'); 27 | readonly productLinePrice = (name: string) => 28 | this.productItem(name).getByTestId('line-price'); 29 | readonly cartStepBtn = this.page.getByTestId('proceed-1'); 30 | readonly signInStepBtn = this.page.locator(''); 31 | readonly billingAddressBtn = this.page.locator(''); 32 | readonly paymentStepBtn = this.page.locator(''); 33 | readonly removeItemBtn = this.page.locator('.btn-danger'); 34 | readonly cartTotalPrice = this.page.getByTestId('cart-total'); 35 | 36 | /** 37 | * Actions 38 | */ 39 | async removeProductFromCart(productName: string) {} 40 | 41 | /** 42 | * Assertions 43 | */ 44 | @step() 45 | async expectProduct(product: ProductInputModel, quantity: number) { 46 | const linePrice = product.price * quantity; 47 | 48 | await expect(this.productName(product.name)).toHaveCount(1); 49 | await expect(this.productQtyField(product.name)).toHaveValue( 50 | quantity.toString() 51 | ); 52 | await expect(this.productUnitPrice(product.name)).toContainText( 53 | product.price.toString() 54 | ); 55 | await expect(this.productLinePrice(product.name)).toContainText( 56 | linePrice.toString() 57 | ); 58 | } 59 | 60 | @step() 61 | async expectTotalPrice() {} 62 | 63 | @step() 64 | async expectRemovedProductFromCart(productName: string) {} 65 | } 66 | -------------------------------------------------------------------------------- /src/pages/checkout/components/payment.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { expect, Page } from '@playwright/test'; 3 | import { 4 | BankDetailsInputModel, 5 | CreditCardInputModel, 6 | GiftCardInputModel, 7 | MonthlyInstallmentLabel, 8 | PaymentMethodLabel, 9 | } from '../types'; 10 | import { step } from 'playwright-helpers'; 11 | 12 | export default class PaymentComponent extends BasePage { 13 | constructor(page: Page) { 14 | super(page); 15 | } 16 | 17 | /** 18 | * Application contents 19 | */ 20 | 21 | readonly choosePaymentMethodTxt = 'Choose your payment method'; 22 | readonly thanksForYourOrderTxt = 23 | 'Thanks for your order! Your invoice number is'; 24 | 25 | /** 26 | * Locators 27 | */ 28 | readonly confirmOrder = this.page.locator('#order-confirmation'); 29 | readonly bankNameField = this.page.getByTestId('bank_name'); 30 | readonly accountNameField = this.page.getByTestId('account_name'); 31 | readonly accountNumberField = this.page.getByTestId('account_number'); 32 | readonly creditCardNumberField = this.page.getByTestId('credit_card_number'); 33 | readonly expDateField = this.page.getByTestId('expiration_date'); 34 | readonly cvvField = this.page.getByTestId('cvv'); 35 | readonly cardHolderNameField = this.page.getByTestId('card_holder_name'); 36 | readonly giftCardNumberField = this.page.getByTestId('gift_card_number'); 37 | readonly validationCodeField = this.page.getByTestId('validation_code'); 38 | 39 | /** 40 | * Actions 41 | */ 42 | @step() 43 | async selectPaymentMethod(paymentMethod: PaymentMethodLabel) { 44 | await this.page.selectOption('#payment-method', { 45 | label: paymentMethod, 46 | }); 47 | } 48 | 49 | @step() 50 | async selectMonthlyInstallments(value: MonthlyInstallmentLabel) { 51 | await this.page.selectOption('#monthly_installments', value); 52 | } 53 | 54 | /** 55 | * Methods 56 | */ 57 | @step() 58 | async fillBankDetails(bankDetails: BankDetailsInputModel) { 59 | const { bankName, accountName, accountNumber } = bankDetails; 60 | 61 | if (bankName) await this.bankNameField.fill(bankName); 62 | if (accountName) await this.accountNameField.fill(accountName); 63 | if (accountNumber) await this.accountNumberField.fill(accountNumber); 64 | } 65 | 66 | @step() 67 | async fillCreditCard(creditCard: CreditCardInputModel) { 68 | const { creditCardNumber, expirationDate, cvv, cardHolderName } = 69 | creditCard; 70 | 71 | if (creditCardNumber) 72 | await this.creditCardNumberField.fill(creditCardNumber); 73 | if (expirationDate) await this.expDateField.fill(expirationDate); 74 | if (cvv) await this.cvvField.fill(cvv); 75 | if (cardHolderName) await this.cardHolderNameField.fill(cardHolderName); 76 | } 77 | 78 | @step() 79 | async fillGiftCard(card: GiftCardInputModel) { 80 | const { giftCardNumber, validationCode } = card; 81 | 82 | if (giftCardNumber) await this.giftCardNumberField.fill(giftCardNumber); 83 | if (validationCode) await this.validationCodeField.fill(validationCode); 84 | } 85 | 86 | /** 87 | * Assertions 88 | */ 89 | @step() 90 | async expectOrderedSuccess() { 91 | await expect(this.confirmOrder).toContainText(this.thanksForYourOrderTxt); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/pages/checkout/data/paymentMethod.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BankDetailsInputModel, 3 | CreditCardInputModel, 4 | GiftCardInputModel, 5 | } from '../types'; 6 | 7 | export default class PaymentMethodData { 8 | static readonly bankTransfer: BankDetailsInputModel = { 9 | bankName: 'JCB Transfer', 10 | accountName: 'Test JCB Transfer', 11 | accountNumber: '123456789', 12 | }; 13 | 14 | static readonly creditCard: CreditCardInputModel = { 15 | creditCardNumber: '4242-4242-4242-4242', 16 | cardHolderName: 'Long Nguyen Van', 17 | cvv: '123', 18 | expirationDate: '12/2030', 19 | }; 20 | 21 | static readonly giftCard: GiftCardInputModel = { 22 | giftCardNumber: '3569 2341 3412', 23 | validationCode: '123456', 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/checkout/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enums 3 | */ 4 | // export enum PaymentMethodInputModel { 5 | // bankTransfer = 'bank-transfer', 6 | // cashOnDelivery = 'cash-on-delivery', 7 | // creditCard = 'credit-card', 8 | // buyNowPayLater = 'buy-now-pay-later', 9 | // giftCard = 'gift-card', 10 | // } 11 | 12 | export enum PaymentMethodLabel { 13 | bankTransfer = 'Bank Transfer', 14 | cashOnDelivery = 'Cash on Delivery', 15 | creditCard = 'Credit Card', 16 | buyNowPayLater = 'Buy Now Pay Later', 17 | giftCard = 'Gift Card', 18 | } 19 | 20 | export enum MonthlyInstallmentLabel { 21 | three = '3 Monthly Installments', 22 | six = '6 Monthly Installments', 23 | nine = '9 Monthly Installments', 24 | twelve = '12 Monthly Installments', 25 | } 26 | 27 | /** 28 | * Interfaces 29 | */ 30 | export interface BankDetailsInputModel { 31 | bankName?: string; 32 | accountName?: string; 33 | accountNumber?: string; 34 | } 35 | 36 | export interface CreditCardInputModel { 37 | cardHolderName?: string; 38 | creditCardNumber?: string; 39 | cvv?: string; 40 | expirationDate?: string; 41 | } 42 | 43 | export interface GiftCardInputModel { 44 | giftCardNumber?: string; 45 | validationCode?: string; 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/common/data/address.ts: -------------------------------------------------------------------------------- 1 | import { Address } from 'src/api/user/types'; 2 | 3 | export default class AddressData { 4 | static readonly address01: Address = { 5 | street: '123 Main St', 6 | city: 'New York', 7 | country: 'United State', 8 | state: 'New York', 9 | postal_code: '10001', 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/common/types.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/src/pages/common/types.ts -------------------------------------------------------------------------------- /src/pages/components/address.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { expect, Page } from '@playwright/test'; 3 | import { step } from 'playwright-helpers'; 4 | import { Address } from 'src/api/user/types'; 5 | 6 | export default class AddressFormComponent extends BasePage { 7 | constructor(page: Page) { 8 | super(page); 9 | } 10 | 11 | /** 12 | * Application contents 13 | */ 14 | readonly streetRequired = 'Street is required'; 15 | readonly postCodeRequired = 'Postcode is required'; 16 | readonly cityRequired = 'City is required'; 17 | readonly stateRequired = 'State is required'; 18 | readonly countryRequired = 'Country is required'; 19 | 20 | /** 21 | * Locators 22 | */ 23 | readonly streetField = this.page.getByTestId('street'); 24 | readonly streetError = this.page.getByTestId('street-error'); 25 | readonly cityField = this.page.getByTestId('city'); 26 | readonly cityError = this.page.getByTestId('city-error'); 27 | readonly stateField = this.page.getByTestId('state'); 28 | readonly stateError = this.page.getByTestId('state-error'); 29 | readonly countryDropdown = (value: string) => 30 | this.page.selectOption('[id="country"]', value); 31 | readonly countryField = this.page.getByTestId('country'); 32 | readonly countryError = this.page.getByTestId('country-error'); 33 | readonly postCodeField = this.page.getByTestId('postal_code'); 34 | readonly postCodeError = this.page.getByTestId('postal_code-error'); 35 | 36 | /** 37 | * Actions 38 | */ 39 | @step() 40 | async fillStreet(street: string) { 41 | await this.streetField.fill(street); 42 | } 43 | 44 | @step() 45 | async fillCity(city: string) { 46 | await this.cityField.fill(city); 47 | } 48 | 49 | @step() 50 | async fillState(state: string) { 51 | await this.stateField.fill(state); 52 | } 53 | 54 | @step() 55 | async fillCountry(country: string) { 56 | await this.countryField.fill(country); 57 | } 58 | 59 | @step() 60 | async selectCountry(country: string) { 61 | await this.countryField.fill(country); 62 | } 63 | 64 | @step() 65 | async fillPostCode(postCode: string) { 66 | await this.postCodeField.fill(postCode); 67 | } 68 | 69 | /** 70 | * Methods 71 | */ 72 | @step() 73 | async fillAddressForm(addressDetails: Address, isRegisterUser = false) { 74 | const { street, city, state, country, postal_code } = addressDetails; 75 | 76 | if (street) await this.fillStreet(street); 77 | if (city) await this.fillCity(city); 78 | if (country) { 79 | if (isRegisterUser) { 80 | await this.countryDropdown(country); 81 | } else { 82 | await this.fillCountry(country); 83 | } 84 | } 85 | if (state) await this.fillState(state); 86 | if (postal_code) await this.fillPostCode(postal_code); 87 | } 88 | 89 | /** 90 | * Assertions 91 | */ 92 | async expectStreetRequiredErrorMsg() { 93 | await expect(this.streetError).toContainText(this.streetRequired); 94 | } 95 | 96 | async expectCountryRequiredErrorMsg() { 97 | await expect(this.countryError).toContainText(this.countryRequired); 98 | } 99 | 100 | async expectStateRequiredErrorMsg() { 101 | await expect(this.stateError).toContainText(this.stateRequired); 102 | } 103 | 104 | async expectPostCodeRequiredErrorMsg() { 105 | await expect(this.postCodeError).toContainText(this.postCodeRequired); 106 | } 107 | 108 | async expectCityRequiredErrorMsg() { 109 | await expect(this.cityError).toContainText(this.cityRequired); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/pages/components/datePicker.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { Page } from '@playwright/test'; 3 | 4 | export default class DatePickerComponent extends BasePage { 5 | constructor(page: Page) { 6 | super(page); 7 | } 8 | 9 | /** 10 | * Application contents 11 | */ 12 | 13 | /** 14 | * Locators 15 | */ 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/components/dropdown.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { Page } from '@playwright/test'; 3 | 4 | export default class DropdownComponent extends BasePage { 5 | constructor(page: Page) { 6 | super(page); 7 | } 8 | 9 | async selectByName() {} 10 | 11 | async selectByLabel() {} 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/components/login.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { expect, Page } from '@playwright/test'; 3 | import Env from '@api/lib/helpers/env'; 4 | import { step } from 'playwright-helpers'; 5 | 6 | export default class LoginComponent extends BasePage { 7 | constructor(page: Page) { 8 | super(page); 9 | } 10 | 11 | /** 12 | * Application contents 13 | */ 14 | readonly requiredEmailTxt = 'Email is required'; 15 | readonly requiredPasswordTxt = 'Password is required'; 16 | readonly invalidEmailFormatTxt = 'Email format is invalid'; 17 | readonly invalidPasswordLengthTxt = 'Password length is invalid'; 18 | readonly invalidEmailOrPasswordTxt = 'Invalid email or password'; 19 | 20 | /** 21 | * Locators 22 | */ 23 | public readonly loginForm = this.page.locator('.auth-form'); 24 | readonly emailField = this.loginForm.getByTestId('email'); 25 | readonly emailError = this.loginForm.getByTestId('email-error'); 26 | readonly passwordField = this.loginForm.getByTestId('password'); 27 | readonly passwordError = this.loginForm.getByTestId('password-error'); 28 | readonly loginBtn = this.loginForm.getByTestId('login-submit'); 29 | readonly loginError = this.loginForm.getByTestId('login-error'); 30 | readonly registerAccountLink = this.loginForm.getByTestId('register-link'); 31 | readonly forgotPasswordLink = this.loginForm.getByTestId( 32 | 'forgot-password-link' 33 | ); 34 | 35 | /** 36 | * Actions 37 | */ 38 | async fillEmail(email: string) { 39 | await this.emailField.fill(email); 40 | } 41 | 42 | async fillPassword(password: string) { 43 | await this.passwordField.fill(password); 44 | } 45 | 46 | async clickLogin() { 47 | await this.loginBtn.click(); 48 | } 49 | 50 | /** 51 | * Methods 52 | */ 53 | async login(email: string, password: string) { 54 | await this.fillEmail(email); 55 | await this.fillPassword(password); 56 | await this.clickLogin(); 57 | } 58 | 59 | /** 60 | * Assertions 61 | */ 62 | @step() 63 | async expectRequiredEmailErrorMessage() { 64 | await expect(this.emailError).toHaveText(this.requiredEmailTxt); 65 | } 66 | 67 | @step() 68 | async expectRequiredPasswordErrorMessage() { 69 | await expect(this.passwordError).toHaveText(this.requiredPasswordTxt); 70 | } 71 | 72 | @step() 73 | async expectEmailFormatErrorMessage() { 74 | await expect(this.emailError).toHaveText(this.invalidEmailFormatTxt); 75 | } 76 | 77 | @step() 78 | async expectPasswordLengthErrorMessage() { 79 | await expect(this.passwordError).toHaveText(this.invalidPasswordLengthTxt); 80 | } 81 | 82 | @step() 83 | async expectLoginErrorMessage() { 84 | await expect(this.loginError).toHaveText(this.invalidEmailOrPasswordTxt); 85 | } 86 | 87 | @step() 88 | async expectLoggedInSuccess() {} 89 | 90 | @step() 91 | async expectLoggedInFail() { 92 | await expect(this.loginError).toHaveText(this.invalidEmailOrPasswordTxt); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/components/navigationBar.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@fixtures/baseUIFixture'; 2 | import AccountPage from '@pages/account/accountPage'; 3 | import BasePage from '@pages/basePage'; 4 | import CheckoutPage from '@pages/checkout/checkoutPage'; 5 | import InvoicePage from '@pages/invoice/invoicePage'; 6 | import LoginPage from '@pages/login/loginPage'; 7 | import ProductPage from '@pages/product/productPage'; 8 | import RegisterPage from '@pages/register/registerPage'; 9 | import { expect, Page } from '@playwright/test'; 10 | import Env from '@api/lib/helpers/env'; 11 | import { step } from 'playwright-helpers'; 12 | import DashboardPage from '@pages/admin/dashboardPage'; 13 | import BrandPage from '@pages/admin/brand/brandPage'; 14 | 15 | export default class NavigationComponent extends BasePage { 16 | private static readonly BASE_URL = Env.BASE_URL; 17 | 18 | constructor(page: Page) { 19 | super(page); 20 | } 21 | 22 | /** 23 | * Application contents 24 | */ 25 | 26 | /** 27 | * Locators 28 | */ 29 | readonly homeLink = this.page.getByTestId('nav-home'); 30 | public readonly navigationMenu = this.page.locator('#navbarSupportedContent'); 31 | public readonly signinLink = this.page.locator('a[href="/auth/login"]'); 32 | public readonly cartLink = this.page.getByTestId('nav-cart'); 33 | public readonly productLinks = this.page 34 | .locator('a[data-test*="product"]') 35 | .first(); 36 | public readonly cartQty = this.page.getByTestId('cart-quantity'); 37 | public readonly accountDropdownMenu = this.page.getByTestId('nav-menu'); 38 | public readonly accountDropdownBox = this.page.locator('.dropdown-menu.show'); 39 | public readonly signOutLink = this.page.getByTestId('nav-sign-out'); 40 | 41 | /** 42 | * Actions 43 | */ 44 | @step() 45 | private async openURL(uri: string) { 46 | await test.step(`Opening page ${NavigationComponent.BASE_URL + uri}...`, async () => { 47 | await this.page.goto(NavigationComponent.BASE_URL + uri); 48 | }); 49 | } 50 | 51 | @step() 52 | async clickShoppingCart() { 53 | await this.cartLink.click(); 54 | } 55 | 56 | @step() 57 | async clickSignInLink() { 58 | await this.signinLink.click(); 59 | } 60 | 61 | @step() 62 | async clickHome() { 63 | await this.homeLink.click(); 64 | } 65 | 66 | @step() 67 | async clickAccountDropdown() { 68 | await this.accountDropdownMenu.click(); 69 | } 70 | 71 | @step() 72 | async clickSignOutLink() { 73 | await this.signOutLink.click(); 74 | } 75 | 76 | @step() 77 | async openLoginPageURL() { 78 | await this.openURL(LoginPage.loginUri); 79 | } 80 | 81 | @step() 82 | async openRegisterPageURL() { 83 | await this.openURL(RegisterPage.registerUri); 84 | } 85 | 86 | @step() 87 | async openProductPageURL(productId: string) { 88 | await this.openURL(`${ProductPage.productUri}/${productId}`); 89 | } 90 | 91 | @step() 92 | async openCheckoutPageURL() { 93 | await this.openURL(CheckoutPage.checkoutUri); 94 | } 95 | 96 | @step() 97 | async openBrandPageURL() { 98 | await this.openURL(BrandPage.brandUri); 99 | } 100 | 101 | @step() 102 | async openInvoicesPageURL() { 103 | await this.openURL(InvoicePage.invoiceUri); 104 | } 105 | 106 | @step() 107 | async openInvoiceDetailsPageURL(id: string) { 108 | await this.openURL(`${InvoicePage.invoiceUri}/${id}`); 109 | } 110 | 111 | @step() 112 | async openProfilePageURL() { 113 | await this.openURL(AccountPage.profileUri); 114 | } 115 | 116 | /** 117 | * Methods 118 | */ 119 | 120 | /** 121 | * Assertions 122 | */ 123 | 124 | @step() 125 | private async expectNavigateTo(uri: string) { 126 | await this.page.waitForURL(new RegExp(`${uri}`)); 127 | } 128 | 129 | @step() 130 | async expectNavigateToLoginPage() { 131 | await this.expectNavigateTo(LoginPage.loginUri); 132 | } 133 | 134 | @step() 135 | async expectNavigateToRegisterPage() { 136 | await this.expectNavigateTo(RegisterPage.registerUri); 137 | } 138 | 139 | @step() 140 | async expectNavigateToProductPage() { 141 | await this.expectNavigateTo(ProductPage.productUri); 142 | } 143 | 144 | @step() 145 | async expectNavigateToCheckoutPage() { 146 | await this.expectNavigateTo(CheckoutPage.checkoutUri); 147 | } 148 | 149 | @step() 150 | async expectNavigateToBrandPage() { 151 | await this.expectNavigateTo(BrandPage.brandUri); 152 | } 153 | 154 | @step() 155 | async expectSignedInSuccess() { 156 | await expect(this.signinLink).toBeHidden(); 157 | // TODO: verify to contains logged in user info 158 | } 159 | 160 | @step() 161 | async expectCartQuantity(total: number) { 162 | await expect(this.cartQty).toHaveText(total.toString()); 163 | } 164 | 165 | @step() 166 | async expectShoppingCartHidden() { 167 | await expect(this.cartLink).toBeHidden(); 168 | } 169 | 170 | @step() 171 | async expectAccountDropdownShown() { 172 | await expect(this.accountDropdownBox).toBeVisible(); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/pages/components/sideBarComponent.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import { expect, Page } from '@playwright/test'; 3 | import { step } from 'playwright-helpers'; 4 | 5 | export default class SideBarComponent extends BasePage { 6 | constructor(page: Page) { 7 | super(page); 8 | } 9 | 10 | /** 11 | * Application contents 12 | */ 13 | 14 | /** 15 | * Locators 16 | */ 17 | readonly sortDropdown = this.page.getByTestId('sort'); 18 | readonly searchField = this.page.getByTestId('search-query'); 19 | readonly searchBtn = this.page.getByTestId('search-submit'); 20 | readonly clearSearchBtn = this.page.getByTestId('search-reset'); 21 | readonly filterByCategory = (categoryName: string) => 22 | this.page.locator('.checkbox', { hasText: categoryName }); 23 | readonly filterByBrand = (brandName: string) => 24 | this.page.locator('.checkbox', { hasText: brandName }); 25 | 26 | /** 27 | * Actions 28 | */ 29 | @step() 30 | async clearSearchBox() { 31 | await this.clearSearchBtn.click(); 32 | } 33 | 34 | /** 35 | * Methods 36 | */ 37 | @step() 38 | async searchProductName(name: string) { 39 | await this.searchField.fill(name); 40 | await this.searchBtn.click(); 41 | } 42 | 43 | /** 44 | * Assertions 45 | */ 46 | @step() 47 | async expectSearchBoxCleared() { 48 | await expect(this.searchField).toBeEmpty(); 49 | } 50 | 51 | @step() 52 | async expectListProductsPageShown() {} 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/components/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interfaces 3 | */ 4 | export interface SortProductInputModel { 5 | aToZ: 'Name (A - Z)'; 6 | zToA: 'Name (Z - A)'; 7 | lowToHigh: 'Price (Low - High)'; 8 | highToLow: 'Price (High - Low)'; 9 | } 10 | 11 | export interface HandToolsInputModel { 12 | hammer: 'Hammer'; 13 | handSaw: 'Hand Saw'; 14 | wrench: 'Wrench'; 15 | screwDriver: 'Screwdriver'; 16 | } 17 | 18 | export interface PowerToolsInputModel { 19 | grinder: 'Grinder'; 20 | sander: 'Sander'; 21 | saw: 'Saw'; 22 | drill: 'Drill'; 23 | } 24 | 25 | export interface ProductBrandInputModel { 26 | forgeFlex: 'ForgeFlex Tools'; 27 | mightyCraft: 'MightyCraft Hardware'; 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/fixtures/stringsExpect.ts: -------------------------------------------------------------------------------- 1 | // import { test as baseTest } from "@playwright/test"; 2 | 3 | // export const test = baseTest.extend({ 4 | // toHaveTrimmedText(received: any, value) { 5 | // const isMatchesText = received.trim 6 | // } 7 | // }); 8 | -------------------------------------------------------------------------------- /src/pages/homePage.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from '@playwright/test'; 2 | import NavigationComponent from '@pages/components/navigationBar'; 3 | import SideBarComponent from '@pages/components/sideBarComponent'; 4 | import CartComponent from './checkout/components/cart'; 5 | import { step } from 'playwright-helpers'; 6 | import BasePage from './basePage'; 7 | 8 | export default class HomePage extends BasePage { 9 | navComponent: NavigationComponent; 10 | sideBarComponent: SideBarComponent; 11 | cartComponent: CartComponent; 12 | 13 | constructor(page: Page) { 14 | super(page); 15 | this.navComponent = new NavigationComponent(page); 16 | this.sideBarComponent = new SideBarComponent(page); 17 | this.cartComponent = new CartComponent(page); 18 | } 19 | 20 | static getProductURL(id: string) { 21 | return `product/${id}`; 22 | } 23 | 24 | /** 25 | * Locators 26 | */ 27 | readonly productLink = (name: string) => 28 | this.page.locator('[data-test="product-name"]', { hasText: name }); 29 | readonly productLinks = this.page.locator('a[data-test*="product"]').first(); 30 | 31 | /** 32 | * Actions 33 | */ 34 | @step() 35 | async open() { 36 | await this.page.goto('/', { 37 | waitUntil: 'commit', 38 | timeout: 60_000, 39 | }); 40 | } 41 | 42 | @step() 43 | async clickProductName(name: string) { 44 | await this.productLink(name).click(); 45 | } 46 | 47 | @step() 48 | async navigateToProductByUrl(id: string) { 49 | await this.page.goto(HomePage.getProductURL(id)); 50 | } 51 | 52 | /** 53 | * Assertions 54 | */ 55 | @step() 56 | async expectNavigateToHomePage() { 57 | await expect(this.navComponent.navigationMenu).toBeVisible({ 58 | timeout: 60_000, 59 | }); 60 | await expect(this.productLinks).toBeVisible(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/pages/invoice/invoicePage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@playwright/test'; 2 | import BasePage from '../basePage'; 3 | 4 | export default class InvoicePage extends BasePage { 5 | static readonly invoiceUri = 'account/invoices'; 6 | 7 | constructor(page: Page) { 8 | super(page); 9 | } 10 | 11 | /** 12 | * Application contents 13 | */ 14 | 15 | /** 16 | * Locators 17 | */ 18 | readonly invoicesTable = this.page.locator('//app-invoices'); 19 | 20 | /** 21 | * Assertions 22 | */ 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/login/loginPage.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import LoginComponent from '@pages/components/login'; 3 | import { expect, Page } from '@playwright/test'; 4 | import { step } from 'playwright-helpers'; 5 | 6 | export default class LoginPage extends BasePage { 7 | static readonly loginUri = 'auth/login'; 8 | 9 | loginComponent: LoginComponent; 10 | 11 | constructor(page: Page) { 12 | super(page); 13 | this.loginComponent = new LoginComponent(page); 14 | } 15 | 16 | /*** 17 | * Application contents 18 | */ 19 | static readonly LOGIN = 'Login'; 20 | 21 | /** 22 | * Locators 23 | */ 24 | readonly headingPage = this.page.locator('.auth-form > h3'); 25 | readonly registerLink = this.page.getByTestId('register-link'); 26 | 27 | /** 28 | * Actions 29 | */ 30 | async clickRegisterLink() { 31 | await this.registerLink.click(); 32 | } 33 | 34 | /** 35 | * Methods 36 | */ 37 | 38 | /** 39 | * Assertions 40 | */ 41 | @step() 42 | async expectLoginPageOpened() { 43 | await expect(this.loginComponent.loginBtn).toBeVisible(); 44 | await expect(this.loginComponent.loginBtn).toHaveText(LoginPage.LOGIN); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/product/data/product.ts: -------------------------------------------------------------------------------- 1 | import { ProductInputModel } from '../types'; 2 | 3 | export default class ProductData { 4 | static readonly basicProduct01: ProductInputModel = { 5 | name: 'Bolt Cutters', 6 | price: 48.41, 7 | }; 8 | 9 | static readonly basicProduct02: ProductInputModel = { 10 | name: 'Claw Hammer with Shock Reduction Grip', 11 | price: 13.41, 12 | }; 13 | 14 | static readonly outOfStockProduct01: ProductInputModel = { 15 | name: 'Long Nose Pliers', 16 | price: 14.24, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/pages/product/productPage.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from '@playwright/test'; 2 | import BasePage from '../basePage'; 3 | import { ProductInputModel } from './types'; 4 | import { step } from 'playwright-helpers'; 5 | 6 | export default class ProductPage extends BasePage { 7 | static readonly productUri = 'product'; 8 | 9 | constructor(page: Page) { 10 | super(page); 11 | } 12 | 13 | /** 14 | * Application contents 15 | */ 16 | readonly dd = 'Unauthorized, can not add product to your favorite list.'; 17 | 18 | /** 19 | * Locators 20 | */ 21 | public readonly productDetails = this.page.locator('.my-3'); 22 | readonly productName = this.page.getByTestId('product-name'); 23 | readonly unitPrice = this.page.getByTestId('unit-price'); 24 | readonly qtyField = this.page.getByTestId('quantity'); 25 | readonly minusQtyBtn = this.page.getByTestId('decrease-quantity'); 26 | readonly plusQtyBtn = this.page.getByTestId('increase-quantity'); 27 | readonly addToCartBtn = this.page.getByTestId('add-to-cart'); 28 | readonly addToFavoritesBtn = this.page.getByTestId('add-to-favorites'); 29 | readonly toastBox = this.page.locator('#toast-container'); 30 | readonly toastSucceedMsg = this.toastBox.locator('.toast-success'); 31 | readonly toastErrorMsg = this.toastBox.locator('.toast-error'); 32 | readonly outOfStockMsg = this.page.getByTestId('out-of-stock'); 33 | 34 | /** 35 | * Actions 36 | */ 37 | @step() 38 | async clickAddToCart() { 39 | await this.addToCartBtn.click(); 40 | } 41 | 42 | @step() 43 | async clickAddToFavourites() { 44 | await this.addToFavoritesBtn.click(); 45 | } 46 | 47 | @step() 48 | async closeToastMessageBox() { 49 | await this.toastBox.click(); 50 | await this.toastBox.waitFor({ state: 'hidden' }); 51 | } 52 | 53 | /** 54 | * Assertions 55 | */ 56 | @step() 57 | async expectProductDetails(product: ProductInputModel) { 58 | await expect(this.productName).toHaveText(product.name); 59 | await expect(this.unitPrice).toHaveText(product.price.toString()); 60 | } 61 | 62 | @step() 63 | async expectAddedProductToCartSuccessMsg() { 64 | await expect(this.toastSucceedMsg).toBeVisible(); 65 | } 66 | 67 | @step() 68 | async expectAddedProductToCartMessageHidden() { 69 | await expect(this.toastSucceedMsg).toBeHidden(); 70 | } 71 | 72 | @step() 73 | async expectOutOfStockMessageShown() { 74 | await expect(this.outOfStockMsg).toBeVisible(); 75 | } 76 | 77 | @step() 78 | async expectSetQuantityDisabled() { 79 | await expect(this.qtyField).toHaveValue('1'); 80 | await expect(this.qtyField).toBeDisabled(); 81 | await expect(this.minusQtyBtn).toBeDisabled(); 82 | await expect(this.plusQtyBtn).toBeDisabled(); 83 | } 84 | 85 | @step() 86 | async expectOutOfStockMessageHidden() { 87 | await expect(this.outOfStockMsg).toBeHidden(); 88 | } 89 | 90 | @step() 91 | async expectAddToCartButtonDisabled() { 92 | await expect(this.addToCartBtn).toBeDisabled(); 93 | } 94 | 95 | @step() 96 | async expectAddToCartButtonEnabled() { 97 | await expect(this.addToCartBtn).toBeEnabled(); 98 | } 99 | 100 | @step() 101 | async expectAddToFavouritesButtonEnabled() { 102 | await expect(this.addToFavoritesBtn).toBeEnabled(); 103 | } 104 | 105 | @step() 106 | async expectAddedToFavouritesErrorMsg() { 107 | await expect(this.toastErrorMsg).toBeVisible(); 108 | } 109 | 110 | @step() 111 | async expectToastMessageBoxHidden() { 112 | await expect(this.toastBox).toBeHidden(); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/pages/product/searchProductPage.ts: -------------------------------------------------------------------------------- 1 | import BasePage from '@pages/basePage'; 2 | import SideBarComponent from '@pages/components/sideBarComponent'; 3 | import { expect, Page } from '@playwright/test'; 4 | import { step } from 'playwright-helpers'; 5 | 6 | export default class SearchProduct extends BasePage { 7 | sideBarComponent: SideBarComponent; 8 | 9 | constructor(page: Page) { 10 | super(page); 11 | this.sideBarComponent = new SideBarComponent(page); 12 | } 13 | 14 | /** 15 | * Application contents 16 | */ 17 | readonly headingPage = this.page.getByTestId('search-caption'); 18 | readonly listProductNames = this.page.getByTestId('product-name').all(); 19 | 20 | /** 21 | * Locators 22 | */ 23 | 24 | /** 25 | * Actions 26 | */ 27 | 28 | /** 29 | * Methods 30 | */ 31 | 32 | /** 33 | * Assertions 34 | */ 35 | @step() 36 | async expectNavigateToSearchPage() { 37 | await expect(this.headingPage).toBeVisible(); 38 | } 39 | 40 | @step() 41 | async expectSearchResults(searchProduct: string) { 42 | const matchedProducts = await this.listProductNames; 43 | for (const productName of matchedProducts) { 44 | await expect(productName).toContainText(searchProduct); 45 | } 46 | } 47 | 48 | @step() 49 | async expectTotalMatchesProducts(count: number) { 50 | // TODO: 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/pages/product/types.ts: -------------------------------------------------------------------------------- 1 | export interface ProductInputModel { 2 | name?: string; 3 | description?: string; 4 | price?: number; 5 | status?: ProductStatusLabel; 6 | } 7 | 8 | export enum ProductStatusLabel { 9 | inStock = 'In Stock', 10 | outOfStock = 'Out Of Stock', 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/register/registerPage.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from '@playwright/test'; 2 | import BasePage from '../basePage'; 3 | import { step } from 'playwright-helpers'; 4 | import AddressFormComponent from '@pages/components/address'; 5 | import { RegisterUser } from 'src/api/user/types'; 6 | 7 | export default class RegisterPage extends BasePage { 8 | static readonly registerUri = 'auth/register'; 9 | 10 | addressFormComponent: AddressFormComponent; 11 | 12 | constructor(page: Page) { 13 | super(page); 14 | this.addressFormComponent = new AddressFormComponent(page); 15 | } 16 | 17 | /** 18 | * Application contents 19 | */ 20 | readonly CUSTOMER_REGISTRATION = 'Customer registration'; 21 | readonly REGISTER = 'Register'; 22 | readonly FIRST_NAME_REQUIRED = 'First name is required'; 23 | readonly LAST_NAME_REQUIRED = 'fields.last-name.required'; 24 | readonly DOB_REQUIRED = 'Date of Birth is required'; 25 | readonly PHONE_REQUIRED = 'Phone is required.'; 26 | readonly EMAIL_REQUIRED = 'Email is required'; 27 | readonly PASSWORD_REQUIRED = 'Password is required'; 28 | readonly PASSWORD_MIN_LENGTH = 'Password must be minimal 6 characters long.'; 29 | readonly INVALID_PASSWORD_CHARS = 30 | 'Password can not include invalid characters.'; 31 | 32 | /** 33 | * Locators 34 | */ 35 | public readonly registerForm = this.page.locator('.auth-form'); 36 | readonly firstNameField = this.page.getByTestId('first-name'); 37 | readonly firstNameError = this.page.getByTestId('first-name-error'); 38 | readonly lastNameField = this.page.getByTestId('last-name'); 39 | readonly lastNameError = this.page.getByTestId('last-name-error'); 40 | readonly dobPicker = this.page.getByTestId('dob'); 41 | readonly dobError = this.page.getByTestId('dob-error'); 42 | readonly phoneField = this.page.getByTestId('phone'); 43 | readonly phoneError = this.page.getByTestId('phone-error'); 44 | readonly emailField = this.page.getByTestId('email'); 45 | readonly emailError = this.page.getByTestId('email-error'); 46 | readonly passwordField = this.page.getByTestId('password'); 47 | readonly passwordError = this.page.getByTestId('password-error'); 48 | readonly registerBtn = this.page.getByTestId('register-submit'); 49 | readonly errorField = this.page.locator('[data-test*="error"]'); 50 | 51 | /** 52 | * Actions 53 | */ 54 | @step() 55 | async clickRegister() { 56 | await this.registerBtn.click(); 57 | } 58 | 59 | /** 60 | * Methods 61 | */ 62 | @step() 63 | async fillRegisterUser(userData: RegisterUser) { 64 | const { first_name, last_name, dob, address, phone, password, email } = 65 | userData; 66 | 67 | if (first_name) await this.firstNameField.fill(first_name); 68 | if (last_name) await this.lastNameField.fill(last_name); 69 | if (dob) await this.dobPicker.fill(dob); 70 | if (address) await this.addressFormComponent.fillAddressForm(address, true); 71 | if (phone) await this.phoneField.fill(phone); 72 | if (password) await this.passwordField.fill(password); 73 | if (email) await this.emailField.fill(email); 74 | } 75 | 76 | /** 77 | * Assertions 78 | */ 79 | @step() 80 | async expectFirstNameRequiredErrorMsg() { 81 | await expect(this.firstNameError).toContainText(this.FIRST_NAME_REQUIRED); 82 | } 83 | 84 | @step() 85 | async expectLastNameRequiredErrorMsg() { 86 | await expect(this.lastNameError).toContainText(this.LAST_NAME_REQUIRED); 87 | } 88 | 89 | @step() 90 | async expectDOBRequiredErrorMsg() { 91 | await expect(this.dobError).toContainText(this.DOB_REQUIRED); 92 | } 93 | 94 | @step() 95 | async expectEmailRequiredErrorMsg() { 96 | await expect(this.emailError).toContainText(this.EMAIL_REQUIRED); 97 | } 98 | 99 | @step() 100 | async expectPhoneRequiredErrorMsg() { 101 | await expect(this.phoneError).toContainText(this.PHONE_REQUIRED); 102 | } 103 | 104 | @step() 105 | async expectPasswordRequiredErrorMsg() { 106 | await expect(this.passwordError).toContainText(this.PASSWORD_REQUIRED); 107 | } 108 | 109 | @step() 110 | async expectAllFieldsRequiredErrorMsgs() { 111 | await this.expectFirstNameRequiredErrorMsg(); 112 | await this.expectLastNameRequiredErrorMsg(); 113 | await this.expectDOBRequiredErrorMsg(); 114 | await this.expectPhoneRequiredErrorMsg(); 115 | await this.expectEmailRequiredErrorMsg(); 116 | await this.expectPasswordRequiredErrorMsg(); 117 | await this.addressFormComponent.expectStreetRequiredErrorMsg(); 118 | await this.addressFormComponent.expectPostCodeRequiredErrorMsg(); 119 | await this.addressFormComponent.expectCityRequiredErrorMsg(); 120 | await this.addressFormComponent.expectStateRequiredErrorMsg(); 121 | await this.addressFormComponent.expectCountryRequiredErrorMsg(); 122 | } 123 | 124 | @step() 125 | async expectRegisterFormWithNoErrorMsgs() { 126 | await expect(this.errorField).toHaveCount(0); 127 | } 128 | 129 | @step() 130 | async expectRegisterPageOpened() { 131 | await expect(this.registerBtn).toBeVisible(); 132 | await expect(this.registerBtn).toHaveText(this.REGISTER); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export enum TestType { 2 | Test = 'test', 3 | Issue = 'issue', 4 | } 5 | -------------------------------------------------------------------------------- /test-data/snapshots/account.spec.ts/login-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/account.spec.ts/login-form.png -------------------------------------------------------------------------------- /test-data/snapshots/account.spec.ts/register-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/account.spec.ts/register-form.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/bank-transfer-payment-method.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/bank-transfer-payment-method.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/billing-address-step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/billing-address-step.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/cart-step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/cart-step.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/credit-card-payment-method.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/credit-card-payment-method.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/gitf-card-payment-method.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/gitf-card-payment-method.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/login-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/login-form.png -------------------------------------------------------------------------------- /test-data/snapshots/checkout.spec.ts/register-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/checkout.spec.ts/register-form.png -------------------------------------------------------------------------------- /test-data/snapshots/invoice.spec.ts/list-invoices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/invoice.spec.ts/list-invoices.png -------------------------------------------------------------------------------- /test-data/snapshots/product.spec.ts/product-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/longautoqa/e2e-automation-playwright/7530f0f4947dc9866cc3fd7eef0db4edb79098a9/test-data/snapshots/product.spec.ts/product-details.png -------------------------------------------------------------------------------- /tests/api/auth/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@fixtures/baseAPIFixture'; 2 | import Env from '@api/lib/helpers/env'; 3 | import { extractField } from 'src/api/lib/helpers/responseHelpers'; 4 | import { validateJsonSchema } from 'src/api/lib/helpers/validateJsonSchema'; 5 | import UserApi from 'src/api/user/userApi'; 6 | import { TestType } from 'src/types'; 7 | import { randomPassword, randomString } from 'utils/randomize'; 8 | 9 | test.describe('Login user @auth', async () => { 10 | [ 11 | { email: Env.ADMIN_EMAIL, password: Env.ADMIN_PASSWORD, role: 'Admin' }, 12 | { 13 | email: Env.CUSTOMER_01_EMAIL, 14 | password: Env.CUSTOMER_01_PASSWORD, 15 | role: 'Customer', 16 | }, 17 | ].forEach(({ email, password, role }) => { 18 | test( 19 | `Login user as ${role} with valid credentials`, 20 | { 21 | tag: ['@smoke'], 22 | annotation: { 23 | type: TestType.Test, 24 | description: 'https://demo.atlassian.net/browse/JIRA-30', 25 | }, 26 | }, 27 | async ({ request }) => { 28 | const userApi = new UserApi(request); 29 | const resp = await userApi.login(email, password); 30 | 31 | expect(resp).toHaveOKStatus(); 32 | const body = await resp.json(); 33 | 34 | await validateJsonSchema('GET_login_user', 'user', body); 35 | } 36 | ); 37 | }); 38 | 39 | test( 40 | 'Login with valid email and invalid password', 41 | { 42 | annotation: { 43 | type: TestType.Test, 44 | description: 'https://demo.atlassian.net/browse/JIRA-31', 45 | }, 46 | }, 47 | async ({ request }) => { 48 | const userApi = new UserApi(request); 49 | const resp = await userApi.login(Env.CUSTOMER_01_EMAIL, randomPassword()); 50 | expect(resp).toHaveUnauthorizedStatus(); 51 | 52 | const errorField = await extractField('error', resp); 53 | expect(errorField).toEqual('Unauthorized'); 54 | } 55 | ); 56 | 57 | test( 58 | 'Login with invalid email and password', 59 | { 60 | annotation: { 61 | type: TestType.Test, 62 | description: 'https://demo.atlassian.net/browse/JIRA-31', 63 | }, 64 | }, 65 | async ({ request }) => { 66 | const userApi = new UserApi(request); 67 | const resp = await userApi.login(randomString(), randomString()); 68 | expect(resp).toHaveUnauthorizedStatus(); 69 | 70 | const errorField = await extractField('error', resp); 71 | expect(errorField).toEqual('Unauthorized'); 72 | } 73 | ); 74 | 75 | test( 76 | 'Login with invalid email and valid password', 77 | { 78 | annotation: { 79 | type: TestType.Test, 80 | description: 'https://demo.atlassian.net/browse/JIRA-31', 81 | }, 82 | }, 83 | async ({ request }) => { 84 | const userApi = new UserApi(request); 85 | const resp = await userApi.login(randomString(), Env.ADMIN_PASSWORD); 86 | expect(resp).toHaveUnauthorizedStatus(); 87 | 88 | const errorField = await extractField('error', resp); 89 | expect(errorField).toEqual('Unauthorized'); 90 | } 91 | ); 92 | 93 | test( 94 | 'Login with no email and valid password', 95 | { 96 | annotation: { 97 | type: TestType.Test, 98 | description: 'https://demo.atlassian.net/browse/JIRA-31', 99 | }, 100 | }, 101 | async ({ request }) => { 102 | const userApi = new UserApi(request); 103 | const resp = await userApi.login(null, Env.ADMIN_PASSWORD); 104 | expect(resp).toHaveUnauthorizedStatus(); 105 | 106 | const errorField = await extractField('error', resp); 107 | expect(errorField).toEqual('Invalid login request'); 108 | } 109 | ); 110 | }); 111 | -------------------------------------------------------------------------------- /tests/api/auth/logout.spec.ts: -------------------------------------------------------------------------------- 1 | import { createHeaders } from '@api/lib/helpers/authHelpers'; 2 | import Env from '@api/lib/helpers/env'; 3 | import { expect, test } from '@fixtures/baseAPIFixture'; 4 | import { extractField } from 'src/api/lib/helpers/responseHelpers'; 5 | import UserApi from 'src/api/user/userApi'; 6 | import { TestType } from 'src/types'; 7 | 8 | test.describe('Logout user @auth', async () => { 9 | test( 10 | 'Logout with valid token', 11 | { 12 | tag: ['@smoke'], 13 | annotation: { 14 | type: TestType.Test, 15 | description: 'https://demo.atlassian.net/browse/JIRA-35', 16 | }, 17 | }, 18 | async ({ request }) => { 19 | const headers = await createHeaders( 20 | Env.CUSTOMER_01_EMAIL, 21 | Env.CUSTOMER_01_PASSWORD 22 | ); 23 | const userApi = new UserApi(request); 24 | const resp = await userApi.logout(headers); 25 | 26 | expect(resp).toHaveOKStatus(); 27 | const message = await extractField('message', resp); 28 | 29 | expect(message).toEqual('Successfully logged out'); 30 | } 31 | ); 32 | 33 | test( 34 | 'Logout with non-existed token', 35 | { 36 | tag: ['@smoke'], 37 | annotation: { 38 | type: TestType.Test, 39 | description: 'https://demo.atlassian.net/browse/JIRA-36', 40 | }, 41 | }, 42 | async ({ request }) => { 43 | const userApi = new UserApi(request); 44 | const resp = await userApi.logout({}); 45 | 46 | expect(resp).toHaveUnauthorizedStatus(); 47 | const message = await extractField('message', resp); 48 | 49 | expect(message).toEqual('Unauthorized'); 50 | } 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/api/auth/register.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@fixtures/baseAPIFixture'; 2 | import { createRandomUserBody } from '@api/lib/dataFactory/auth'; 3 | import { createHeaders } from '@api/lib/helpers/authHelpers'; 4 | import { extractField } from '@apiHelpers/responseHelpers'; 5 | import { validateJsonSchema } from '@apiHelpers/validateJsonSchema'; 6 | import UserApi from 'src/api/user/userApi'; 7 | import { TestType } from 'src/types'; 8 | 9 | test.describe('Register user API @auth', async () => { 10 | test( 11 | 'Register new user', 12 | { 13 | tag: ['@smoke'], 14 | annotation: { 15 | type: TestType.Test, 16 | description: 'https://demo.atlassian.net/browse/JIRA-28', 17 | }, 18 | }, 19 | async ({ request }) => { 20 | const userApi = new UserApi(request); 21 | const userBody = await createRandomUserBody(); 22 | const createUserRes = await userApi.createUser(userBody); 23 | 24 | expect(createUserRes).toHaveCreatedStatus(); 25 | const createUserBody = await createUserRes.json(); 26 | 27 | await validateJsonSchema('POST_register_user', 'user', createUserBody); 28 | 29 | const headers = await createHeaders( 30 | createUserBody.email, 31 | userBody.password 32 | ); 33 | 34 | const userId = await extractField('id', createUserRes); 35 | const getUserRes = await userApi.getUser(userId, headers); 36 | 37 | expect(getUserRes).toHaveOKStatus(); 38 | const getUserBody = await getUserRes.json(); 39 | 40 | await validateJsonSchema('GET_register_user', 'user', getUserBody); 41 | } 42 | ); 43 | 44 | test( 45 | 'Register new user with existed email', 46 | { 47 | tag: ['@smoke'], 48 | annotation: { 49 | type: TestType.Test, 50 | description: 'https://demo.atlassian.net/browse/JIRA-29', 51 | }, 52 | }, 53 | async ({ request }) => { 54 | const userApi = new UserApi(request); 55 | // Create 1st user 56 | const user1Body = await createRandomUserBody(); 57 | const createUser1Res = await userApi.createUser(user1Body); 58 | const email = await extractField('email', createUser1Res); 59 | 60 | // Create 2nd user 61 | const user2Body = await createRandomUserBody(email); 62 | const createUser2Res = await userApi.createUser(user2Body); 63 | const emailError = await extractField('email', createUser2Res); 64 | 65 | expect(createUser2Res).toHaveUnprocessableContentStatus(); 66 | expect(emailError[0]).toEqual( 67 | 'A customer with this email address already exists.' 68 | ); 69 | } 70 | ); 71 | }); 72 | -------------------------------------------------------------------------------- /tests/api/cart/cart.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@fixtures/baseUIFixture'; 2 | import CartApi from 'src/api/cart/cartApi'; 3 | import { createCart } from '@api/lib/dataFactory/cart'; 4 | import { extractField } from 'src/api/lib/helpers/responseHelpers'; 5 | import { getRandomInStockProduct } from '@api/lib/dataFactory/product'; 6 | import { validateJsonSchema } from '@apiHelpers/validateJsonSchema'; 7 | import { randomString } from 'utils/randomize'; 8 | import { createHeaders } from '@api/lib/helpers/authHelpers'; 9 | import Env from '@api/lib/helpers/env'; 10 | import { Headers } from '@api/baseAPI'; 11 | 12 | test.describe('Cart API feature', async () => { 13 | let headers: Headers; 14 | let cartId: string; 15 | let productId: string; 16 | 17 | test.beforeAll(async () => { 18 | headers = await createHeaders( 19 | Env.CUSTOMER_01_EMAIL, 20 | Env.CUSTOMER_01_PASSWORD 21 | ); 22 | cartId = await createCart(headers); 23 | const getProductRes = await getRandomInStockProduct(); 24 | productId = getProductRes.id; 25 | }); 26 | 27 | test( 28 | 'Create an empty cart', 29 | { 30 | tag: ['@smoke', '@positive'], 31 | annotation: { 32 | type: 'test', 33 | description: 'https://demo.atlassian.net/browse/JIRA-19', 34 | }, 35 | }, 36 | async ({ request }) => { 37 | const cartApi = new CartApi(request, headers); 38 | const createCartRes = await cartApi.createCart(); 39 | 40 | expect(createCartRes).toHaveCreatedStatus(); 41 | const cartId = await extractField('id', createCartRes); 42 | const getCartRes = await cartApi.getCart(cartId); 43 | 44 | expect(getCartRes).toHaveOKStatus(); 45 | const getCartBody = await getCartRes.json(); 46 | 47 | await validateJsonSchema('GET_cart', 'cart', getCartBody); 48 | } 49 | ); 50 | 51 | test( 52 | 'Add product to cart', 53 | { 54 | tag: ['@smoke', '@positive'], 55 | annotation: { 56 | type: 'test', 57 | description: 'https://demo.atlassian.net/browse/JIRA-20', 58 | }, 59 | }, 60 | async ({ request }) => { 61 | const cartApi = new CartApi(request, headers); 62 | 63 | const cartId = await createCart(headers); 64 | const addProductRes = await cartApi.addProductToCart(cartId, { 65 | product_id: productId, 66 | quantity: 1, 67 | }); 68 | 69 | expect(addProductRes).toHaveOKStatus(); 70 | const successMsg = await extractField('result', addProductRes); 71 | 72 | expect(successMsg).toEqual('item added or updated'); 73 | 74 | const getCartRes = await cartApi.getCart(cartId); 75 | const getCartBody = await getCartRes.json(); 76 | const addedProduct = getCartBody.cart_items[0]; 77 | 78 | expect(addedProduct.quantity).toEqual(1); 79 | expect(addedProduct.cart_id).toEqual(cartId); 80 | expect(addedProduct.product_id).toEqual(productId); 81 | await validateJsonSchema('GET_cart', 'cart', getCartBody); 82 | } 83 | ); 84 | 85 | test( 86 | 'Add non-existing product to cart', 87 | { 88 | tag: ['@smoke', '@negative'], 89 | annotation: { 90 | type: 'test', 91 | description: 'https://demo.atlassian.net/browse/JIRA-21', 92 | }, 93 | }, 94 | async ({ request }) => { 95 | const cartApi = new CartApi(request, headers); 96 | 97 | const addProductRes = await cartApi.addProductToCart(cartId, { 98 | product_id: randomString(), 99 | quantity: 1, 100 | }); 101 | 102 | expect(addProductRes).toHaveUnprocessableContentStatus(); 103 | const addProductBody = await addProductRes.json(); 104 | 105 | expect(addProductBody.errors.product_id).toEqual([ 106 | 'The selected product id is invalid.', 107 | ]); 108 | } 109 | ); 110 | 111 | [ 112 | { 113 | quantity: -1, 114 | errorMessage: 'The quantity field must be at least 1.', 115 | }, 116 | { 117 | quantity: 0, 118 | errorMessage: 'The quantity field must be at least 1.', 119 | }, 120 | { 121 | quantity: 'string', 122 | errorMessage: 'The quantity field must be an integer.', 123 | }, 124 | ].forEach(({ quantity, errorMessage }) => { 125 | test( 126 | `Add product with invalid quantity ${quantity} to cart @negative`, 127 | { 128 | tag: '@negative', 129 | annotation: { 130 | type: 'test', 131 | description: 'https://demo.atlassian.net/browse/JIRA-22', 132 | }, 133 | }, 134 | async ({ request }) => { 135 | const cartApi = new CartApi(request, headers); 136 | const addProductRes = await cartApi.addProductToCart(cartId, { 137 | product_id: productId, 138 | quantity: quantity, 139 | }); 140 | 141 | expect(addProductRes).toHaveUnprocessableContentStatus(); 142 | const addProductBody = await addProductRes.json(); 143 | 144 | expect(addProductBody.message).toEqual(errorMessage); 145 | } 146 | ); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /tests/api/product/product.spec.ts: -------------------------------------------------------------------------------- 1 | import { extractField } from '@apiHelpers/responseHelpers'; 2 | import { validateJsonSchema } from '@apiHelpers/validateJsonSchema'; 3 | import { test, expect } from '@fixtures/baseUIFixture'; 4 | import ProductApi from 'src/api/product/productApi'; 5 | import { TestType } from 'src/types'; 6 | import { randomString } from 'utils/randomize'; 7 | 8 | test.describe('Product API feature @product', async () => { 9 | test( 10 | 'Get list products', 11 | { 12 | tag: ['@smoke'], 13 | annotation: { 14 | type: TestType.Test, 15 | description: 'https://demo.atlassian.net/browse/JIRA-23', 16 | }, 17 | }, 18 | async ({ request }) => { 19 | const productApi = new ProductApi(request, {}); 20 | const listProductRes = await productApi.getProducts(); 21 | 22 | expect(listProductRes).toHaveOKStatus(); 23 | const listProductBody = await listProductRes.json(); 24 | 25 | expect(listProductBody.current_page).toEqual(1); 26 | expect(listProductBody.from).toEqual(1); 27 | expect(listProductBody.last_page).toEqual(6); 28 | expect(listProductBody.per_page).toEqual(9); 29 | expect(listProductBody.to).toEqual(9); 30 | expect(listProductBody.total).toEqual(50); 31 | 32 | await validateJsonSchema('GET_list_product', 'product', listProductBody); 33 | } 34 | ); 35 | 36 | test( 37 | 'Get a product', 38 | { 39 | tag: ['@smoke'], 40 | annotation: { 41 | type: TestType.Test, 42 | description: 'https://demo.atlassian.net/browse/JIRA-24', 43 | }, 44 | }, 45 | async ({ request }) => { 46 | const productApi = new ProductApi(request, {}); 47 | const listProductRes = await productApi.getProducts(); 48 | 49 | expect(listProductRes).toHaveOKStatus(); 50 | const listProductBody = await listProductRes.json(); 51 | const getOneProduct = listProductBody.data[0]; 52 | 53 | await validateJsonSchema('GET_product', 'product', getOneProduct); 54 | } 55 | ); 56 | 57 | test( 58 | 'Get a non-existing product', 59 | { 60 | annotation: { 61 | type: TestType.Test, 62 | description: 'https://demo.atlassian.net/browse/JIRA-25', 63 | }, 64 | }, 65 | async ({ request }) => { 66 | const productApi = new ProductApi(request, {}); 67 | const productId = randomString(); 68 | const getProductRes = await productApi.getProduct(productId); 69 | 70 | expect(getProductRes).toHaveNotFoundStatus(); 71 | const errorMsg = await extractField('message', getProductRes); 72 | 73 | expect(errorMsg).toEqual('Requested item not found'); 74 | } 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/auth.setup.ts: -------------------------------------------------------------------------------- 1 | import UserApi from '@api/user/userApi'; 2 | import { test as setup } from '@fixtures/baseUIFixture'; 3 | import { expect } from '@fixtures/baseAPIFixture'; 4 | import Env from '@api/lib/helpers/env'; 5 | 6 | const adminAuthFile = './playwright/.auth/admin.json'; 7 | const customer1AuthFile = './playwright/.auth/customer01.json'; 8 | const customer2AuthFile = './playwright/.auth/customer02.json'; 9 | 10 | setup( 11 | 'Create admin user auth', 12 | async ({ page, request, navComponent, loginPage, dashboardPage }) => { 13 | const userApi = new UserApi(request); 14 | const resp = await userApi.login(Env.ADMIN_EMAIL, Env.ADMIN_PASSWORD); 15 | 16 | expect(resp).toHaveOKStatus(); 17 | 18 | await navComponent.openLoginPageURL(); 19 | await navComponent.expectNavigateToLoginPage(); 20 | await loginPage.loginComponent.login(Env.ADMIN_EMAIL, Env.ADMIN_PASSWORD); 21 | await dashboardPage.expectNavigateToDashboardPage(); 22 | 23 | await page.context().storageState({ path: adminAuthFile }); 24 | } 25 | ); 26 | 27 | setup( 28 | 'Create customer 01 auth', 29 | async ({ page, request, navComponent, loginPage, accountPage }) => { 30 | const userApi = new UserApi(request); 31 | const resp = await userApi.login( 32 | Env.CUSTOMER_01_EMAIL, 33 | Env.CUSTOMER_01_PASSWORD 34 | ); 35 | 36 | expect(resp).toHaveOKStatus(); 37 | 38 | await navComponent.openLoginPageURL(); 39 | await navComponent.expectNavigateToLoginPage(); 40 | await loginPage.loginComponent.login( 41 | Env.CUSTOMER_01_EMAIL, 42 | Env.CUSTOMER_01_PASSWORD 43 | ); 44 | 45 | await accountPage.expectNavigateToAccountPage(); 46 | 47 | await page.context().storageState({ path: customer1AuthFile }); 48 | } 49 | ); 50 | 51 | setup( 52 | 'Create customer 02 auth', 53 | async ({ page, request, navComponent, loginPage, accountPage }) => { 54 | const userApi = new UserApi(request); 55 | const resp = await userApi.login( 56 | Env.CUSTOMER_02_EMAIL, 57 | Env.CUSTOMER_02_PASSWORD 58 | ); 59 | 60 | expect(resp).toHaveOKStatus(); 61 | 62 | await navComponent.openLoginPageURL(); 63 | await navComponent.expectNavigateToLoginPage(); 64 | await loginPage.loginComponent.login( 65 | Env.CUSTOMER_01_EMAIL, 66 | Env.CUSTOMER_01_PASSWORD 67 | ); 68 | await accountPage.expectNavigateToAccountPage(); 69 | 70 | await page.context().storageState({ path: customer2AuthFile }); 71 | } 72 | ); 73 | -------------------------------------------------------------------------------- /tests/e2e/auth/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@fixtures/baseUIFixture'; 2 | import { TestType } from 'src/types'; 3 | import { randomEmail, randomPassword, randomString } from 'utils/randomize'; 4 | 5 | test.describe('Login feature @auth', async () => { 6 | test( 7 | 'Login with invalid username and/or password', 8 | { 9 | annotation: { 10 | type: TestType.Test, 11 | description: 'https://demo.atlassian.net/browse/JIRA-09', 12 | }, 13 | }, 14 | async ({ homePage, loginPage, navComponent }) => { 15 | await homePage.open(); 16 | await homePage.expectNavigateToHomePage(); 17 | await homePage.navComponent.clickSignInLink(); 18 | await navComponent.expectNavigateToLoginPage(); 19 | await loginPage.loginComponent.login('', ''); 20 | await loginPage.loginComponent.expectRequiredEmailErrorMessage(); 21 | await loginPage.loginComponent.expectRequiredPasswordErrorMessage(); 22 | await loginPage.loginComponent.fillEmail(randomString()); 23 | await loginPage.loginComponent.expectEmailFormatErrorMessage(); 24 | await loginPage.loginComponent.fillPassword('123456'); 25 | // await loginPage.loginComponent.expectPasswordLengthErrorMessage(); 26 | await loginPage.loginComponent.login(randomEmail(), randomPassword()); 27 | await loginPage.loginComponent.expectLoginErrorMessage(); 28 | } 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/e2e/auth/register.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomUserBody } from '@api/lib/dataFactory/auth'; 2 | import { test } from '@fixtures/baseUIFixture'; 3 | import { TestType } from 'src/types'; 4 | 5 | test.describe('Register user feature @auth', async () => { 6 | test( 7 | 'Register new user successfully', 8 | { 9 | tag: '@smoke', 10 | annotation: { 11 | type: TestType.Test, 12 | description: 'https://demo.atlassian.net/browse/JIRA-26', 13 | }, 14 | }, 15 | async ({ 16 | homePage, 17 | navComponent, 18 | loginPage, 19 | registerPage, 20 | accountPage, 21 | }) => { 22 | const userData = await createRandomUserBody(); 23 | 24 | await homePage.open(); 25 | await homePage.expectNavigateToHomePage(); 26 | await navComponent.clickSignInLink(); 27 | await navComponent.expectNavigateToLoginPage(); 28 | await loginPage.clickRegisterLink(); 29 | await navComponent.expectNavigateToRegisterPage(); 30 | await registerPage.fillRegisterUser(userData); 31 | await registerPage.clickRegister(); 32 | await navComponent.expectNavigateToLoginPage(); 33 | await loginPage.loginComponent.login(userData.email, userData.password); 34 | await accountPage.expectNavigateToAccountPage(); 35 | } 36 | ); 37 | 38 | test( 39 | 'Validate register user', 40 | { 41 | tag: '@smoke', 42 | annotation: { 43 | type: TestType.Test, 44 | description: 'https://demo.atlassian.net/browse/JIRA-27', 45 | }, 46 | }, 47 | async ({ registerPage, navComponent }) => { 48 | const userData = await createRandomUserBody(); 49 | 50 | await navComponent.openRegisterPageURL(); 51 | await navComponent.expectNavigateToRegisterPage(); 52 | await registerPage.clickRegister(); 53 | await registerPage.expectAllFieldsRequiredErrorMsgs(); 54 | await registerPage.fillRegisterUser(userData); 55 | await registerPage.expectRegisterFormWithNoErrorMsgs(); 56 | } 57 | ); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/e2e/cart/cart.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRandomInStockProductId, 3 | getRandomInStockProductIds, 4 | } from '@api/lib/dataFactory/product'; 5 | import { test } from '@fixtures/baseUIFixture'; 6 | import { TestType } from 'src/types'; 7 | 8 | test.describe('Product cart feature as logged in user @cart', async () => { 9 | test.use({ storageState: './playwright/.auth/customer02.json' }); 10 | 11 | test( 12 | 'Add one product to cart', 13 | { 14 | tag: '@smoke', 15 | annotation: { 16 | type: TestType.Test, 17 | description: 'https://demo.atlassian.net/browse/JIRA-01', 18 | }, 19 | }, 20 | async ({ productPage, navComponent }) => { 21 | const productId = await getRandomInStockProductId(); 22 | 23 | await navComponent.openProductPageURL(productId); 24 | await navComponent.expectNavigateToProductPage(); 25 | await productPage.clickAddToCart(); 26 | await productPage.expectAddedProductToCartSuccessMsg(); 27 | await productPage.closeToastMessageBox(); 28 | await productPage.expectToastMessageBoxHidden(); 29 | await navComponent.expectCartQuantity(1); 30 | } 31 | ); 32 | 33 | test( 34 | 'Add same product to cart', 35 | { 36 | tag: '@smoke', 37 | annotation: { 38 | type: TestType.Test, 39 | description: 'https://demo.atlassian.net/browse/JIRA-02', 40 | }, 41 | }, 42 | async ({ productPage, navComponent }) => { 43 | const productId = await getRandomInStockProductId(); 44 | 45 | await test.step(`Add product with ${productId} - quantity (1) to cart`, async () => { 46 | await navComponent.openProductPageURL(productId); 47 | await navComponent.expectNavigateToProductPage(); 48 | await productPage.clickAddToCart(); 49 | await productPage.expectAddedProductToCartSuccessMsg(); 50 | await productPage.closeToastMessageBox(); 51 | await productPage.expectToastMessageBoxHidden(); 52 | await navComponent.expectCartQuantity(1); 53 | }); 54 | await test.step(`Add product with ${productId} - quantity (1) again to cart`, async () => { 55 | await navComponent.openProductPageURL(productId); 56 | await navComponent.expectNavigateToProductPage(); 57 | await productPage.clickAddToCart(); 58 | await productPage.expectAddedProductToCartSuccessMsg(); 59 | await productPage.closeToastMessageBox(); 60 | await productPage.expectToastMessageBoxHidden(); 61 | await navComponent.expectCartQuantity(2); 62 | }); 63 | } 64 | ); 65 | 66 | test( 67 | 'Add two products to cart', 68 | { 69 | tag: '@smoke', 70 | annotation: { 71 | type: TestType.Test, 72 | description: 'https://demo.atlassian.net/browse/JIRA-03', 73 | }, 74 | }, 75 | async ({ productPage, navComponent }) => { 76 | const productIds = await getRandomInStockProductIds(2); 77 | 78 | await test.step(`Add product with ${productIds[0]} with quantity is 1 to cart`, async () => { 79 | await navComponent.openProductPageURL(productIds[0]); 80 | await navComponent.expectNavigateToProductPage(); 81 | await productPage.clickAddToCart(); 82 | await productPage.expectAddedProductToCartSuccessMsg(); 83 | await productPage.closeToastMessageBox(); 84 | await productPage.expectToastMessageBoxHidden(); 85 | await navComponent.expectCartQuantity(1); 86 | }); 87 | await test.step(`Add product with ${productIds[1]} with quantity is 1 to cart`, async () => { 88 | await navComponent.openProductPageURL(productIds[1]); 89 | await navComponent.expectNavigateToProductPage(); 90 | await productPage.clickAddToCart(); 91 | await productPage.expectAddedProductToCartSuccessMsg(); 92 | await productPage.closeToastMessageBox(); 93 | await productPage.expectToastMessageBoxHidden(); 94 | await navComponent.expectCartQuantity(2); 95 | }); 96 | } 97 | ); 98 | }); 99 | -------------------------------------------------------------------------------- /tests/e2e/checkout/checkout.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@fixtures/baseUIFixture'; 2 | 3 | import AddressData from '@pages/common/data/address'; 4 | import ProductData from '@pages/product/data/product'; 5 | import { 6 | PaymentMethodLabel, 7 | MonthlyInstallmentLabel, 8 | } from '@pages/checkout/types'; 9 | import { TestType } from 'src/types'; 10 | import PaymentMethodData from '@pages/checkout/data/paymentMethod'; 11 | 12 | test.describe('Checkout order feature with logged in user @checkout', async () => { 13 | test.use({ storageState: './playwright/.auth/customer01.json' }); 14 | 15 | test( 16 | `Checkout order with payment method ${PaymentMethodLabel.cashOnDelivery}`, 17 | { 18 | tag: '@smoke', 19 | annotation: { 20 | type: TestType.Test, 21 | description: 'https://demo.atlassian.net/browse/JIRA-04', 22 | }, 23 | }, 24 | async ({ homePage, navComponent, productPage, checkoutPage }) => { 25 | await homePage.open(); 26 | await homePage.expectNavigateToHomePage(); 27 | await homePage.clickProductName(ProductData.basicProduct01.name); 28 | await navComponent.expectNavigateToProductPage(); 29 | await productPage.clickAddToCart(); 30 | await productPage.expectAddedProductToCartSuccessMsg(); 31 | await productPage.closeToastMessageBox(); 32 | await productPage.expectToastMessageBoxHidden(); 33 | await navComponent.expectCartQuantity(1); 34 | await navComponent.clickShoppingCart(); 35 | await checkoutPage.expectNavigateToCartStep(); 36 | await checkoutPage.cartComponent.expectProduct( 37 | ProductData.basicProduct01, 38 | 1 39 | ); 40 | await checkoutPage.clickProcessToCheckout(); 41 | await checkoutPage.expectNavigateToSignedInStep(); 42 | await checkoutPage.clickProcessToCheckout(); 43 | await checkoutPage.expectToNavigateToBillingAddressStep(); 44 | await checkoutPage.expectProcessToCheckoutDisabled(); 45 | await checkoutPage.addressComponent.fillAddressForm( 46 | AddressData.address01 47 | ); 48 | await checkoutPage.clickProcessToCheckout(); 49 | await checkoutPage.expectNavigateToPaymentStep(); 50 | await checkoutPage.paymentComponent.selectPaymentMethod( 51 | PaymentMethodLabel.cashOnDelivery 52 | ); 53 | await checkoutPage.clickConfirm(); 54 | await checkoutPage.expectPaymentSuccessMsg(); 55 | // Issue: https://demo.atlassian.net/browse/JIRA-111 56 | // await checkoutPage.clickConfirm(); 57 | // await checkoutPage.paymentComponent.expectOrderedSuccess(); 58 | // await navComponent.expectShoppingCartHidden(); 59 | } 60 | ); 61 | 62 | test( 63 | `Checkout order with payment method ${PaymentMethodLabel.bankTransfer}`, 64 | { 65 | tag: '@smoke', 66 | annotation: { 67 | type: TestType.Test, 68 | description: 'https://demo.atlassian.net/browse/JIRA-05', 69 | }, 70 | }, 71 | async ({ homePage, navComponent, productPage, checkoutPage }) => { 72 | await homePage.open(); 73 | await homePage.expectNavigateToHomePage(); 74 | await homePage.clickProductName(ProductData.basicProduct01.name); 75 | await navComponent.expectNavigateToProductPage(); 76 | await productPage.clickAddToCart(); 77 | await productPage.expectAddedProductToCartSuccessMsg(); 78 | await productPage.closeToastMessageBox(); 79 | await productPage.expectToastMessageBoxHidden(); 80 | await navComponent.expectCartQuantity(1); 81 | await navComponent.clickShoppingCart(); 82 | await checkoutPage.expectNavigateToCartStep(); 83 | await checkoutPage.cartComponent.expectProduct( 84 | ProductData.basicProduct01, 85 | 1 86 | ); 87 | await checkoutPage.clickProcessToCheckout(); 88 | await checkoutPage.expectNavigateToSignedInStep(); 89 | await checkoutPage.clickProcessToCheckout(); 90 | await checkoutPage.expectToNavigateToBillingAddressStep(); 91 | await checkoutPage.addressComponent.fillAddressForm( 92 | AddressData.address01 93 | ); 94 | await checkoutPage.clickProcessToCheckout(); 95 | await checkoutPage.expectNavigateToPaymentStep(); 96 | await checkoutPage.paymentComponent.selectPaymentMethod( 97 | PaymentMethodLabel.bankTransfer 98 | ); 99 | await checkoutPage.paymentComponent.fillBankDetails( 100 | PaymentMethodData.bankTransfer 101 | ); 102 | await checkoutPage.clickConfirm(); 103 | await checkoutPage.expectPaymentSuccessMsg(); 104 | // Issue: https://demo.atlassian.net/browse/JIRA-111 105 | // await checkoutPage.clickConfirm(); 106 | // await checkoutPage.paymentComponent.expectOrderedSuccess(); 107 | // await navComponent.expectShoppingCartHidden(); 108 | } 109 | ); 110 | 111 | test( 112 | `Checkout order with payment method ${PaymentMethodLabel.creditCard}`, 113 | { 114 | tag: '@smoke', 115 | annotation: { 116 | type: TestType.Test, 117 | description: 'https://demo.atlassian.net/browse/JIRA-06', 118 | }, 119 | }, 120 | async ({ homePage, navComponent, productPage, checkoutPage }) => { 121 | await homePage.open(); 122 | await homePage.clickProductName(ProductData.basicProduct02.name); 123 | await navComponent.expectNavigateToProductPage(); 124 | await productPage.clickAddToCart(); 125 | await productPage.expectAddedProductToCartSuccessMsg(); 126 | await productPage.closeToastMessageBox(); 127 | await productPage.expectToastMessageBoxHidden(); 128 | await navComponent.expectCartQuantity(1); 129 | await navComponent.clickShoppingCart(); 130 | await checkoutPage.expectNavigateToCartStep(); 131 | await checkoutPage.cartComponent.expectProduct( 132 | ProductData.basicProduct02, 133 | 1 134 | ); 135 | await checkoutPage.clickProcessToCheckout(); 136 | await checkoutPage.expectNavigateToSignedInStep(); 137 | await checkoutPage.clickProcessToCheckout(); 138 | await checkoutPage.expectToNavigateToBillingAddressStep(); 139 | await checkoutPage.addressComponent.fillAddressForm( 140 | AddressData.address01 141 | ); 142 | await checkoutPage.clickProcessToCheckout(); 143 | await checkoutPage.expectNavigateToPaymentStep(); 144 | await checkoutPage.paymentComponent.selectPaymentMethod( 145 | PaymentMethodLabel.creditCard 146 | ); 147 | await checkoutPage.paymentComponent.fillCreditCard( 148 | PaymentMethodData.creditCard 149 | ); 150 | await checkoutPage.clickConfirm(); 151 | await checkoutPage.expectPaymentSuccessMsg(); 152 | // Issue: https://demo.atlassian.net/browse/JIRA-111 153 | // await checkoutPage.clickConfirm(); 154 | // await checkoutPage.paymentComponent.expectOrderedSuccess(); 155 | // await navComponent.expectShoppingCartHidden(); 156 | } 157 | ); 158 | 159 | test( 160 | `Checkout order with payment method ${PaymentMethodLabel.buyNowPayLater}`, 161 | { 162 | tag: '@smoke', 163 | annotation: { 164 | type: TestType.Test, 165 | description: 'https://demo.atlassian.net/browse/JIRA-07', 166 | }, 167 | }, 168 | async ({ homePage, navComponent, productPage, checkoutPage }) => { 169 | await homePage.open(); 170 | await homePage.clickProductName(ProductData.basicProduct02.name); 171 | await navComponent.expectNavigateToProductPage(); 172 | await productPage.clickAddToCart(); 173 | await productPage.expectAddedProductToCartSuccessMsg(); 174 | await productPage.closeToastMessageBox(); 175 | await productPage.expectToastMessageBoxHidden(); 176 | await navComponent.expectCartQuantity(1); 177 | await navComponent.clickShoppingCart(); 178 | await checkoutPage.expectNavigateToCartStep(); 179 | await checkoutPage.cartComponent.expectProduct( 180 | ProductData.basicProduct02, 181 | 1 182 | ); 183 | await checkoutPage.clickProcessToCheckout(); 184 | await checkoutPage.expectNavigateToSignedInStep(); 185 | await checkoutPage.expectSignedInSuccess(); 186 | await checkoutPage.clickProcessToCheckout(); 187 | await checkoutPage.expectToNavigateToBillingAddressStep(); 188 | await checkoutPage.addressComponent.fillAddressForm( 189 | AddressData.address01 190 | ); 191 | await checkoutPage.clickProcessToCheckout(); 192 | await checkoutPage.expectNavigateToPaymentStep(); 193 | await checkoutPage.paymentComponent.selectPaymentMethod( 194 | PaymentMethodLabel.buyNowPayLater 195 | ); 196 | await checkoutPage.paymentComponent.selectMonthlyInstallments( 197 | MonthlyInstallmentLabel.three 198 | ); 199 | await checkoutPage.clickConfirm(); 200 | await checkoutPage.expectPaymentSuccessMsg(); 201 | // Issue: https://demo.atlassian.net/browse/JIRA-111 202 | // await checkoutPage.clickConfirm(); 203 | // await checkoutPage.paymentComponent.expectOrderedSuccess(); 204 | // await navComponent.expectShoppingCartHidden(); 205 | } 206 | ); 207 | }); 208 | -------------------------------------------------------------------------------- /tests/e2e/product/product.spec.ts: -------------------------------------------------------------------------------- 1 | import ProductData from '@pages/product/data/product'; 2 | import { test } from '@fixtures/baseUIFixture'; 3 | import { TestType } from 'src/types'; 4 | 5 | test.describe('Product feature', async () => { 6 | test.use({ storageState: './playwright/.auth/customer01.json' }); 7 | 8 | test( 9 | 'View one product details', 10 | { 11 | tag: '@smoke', 12 | annotation: { 13 | type: TestType.Test, 14 | description: 'https://demo.atlassian.net/browse/JIRA-12', 15 | }, 16 | }, 17 | async ({ homePage, productPage, navComponent }) => { 18 | await test.step('View in-stock product details', async () => { 19 | await homePage.open(); 20 | await homePage.clickProductName(ProductData.basicProduct01.name); 21 | await navComponent.expectNavigateToProductPage(); 22 | await productPage.expectOutOfStockMessageHidden(); 23 | await productPage.expectAddToCartButtonEnabled(); 24 | await productPage.expectAddToFavouritesButtonEnabled(); 25 | }); 26 | await test.step('View out-of-stock product details', async () => { 27 | await homePage.open(); 28 | await homePage.clickProductName(ProductData.outOfStockProduct01.name); 29 | await navComponent.expectNavigateToProductPage(); 30 | await productPage.expectSetQuantityDisabled(); 31 | await productPage.expectOutOfStockMessageShown(); 32 | await productPage.expectAddToCartButtonDisabled(); 33 | await productPage.expectAddToFavouritesButtonEnabled(); 34 | }); 35 | } 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/visual/auth/account.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@fixtures/baseUIFixture'; 2 | import { TestType } from 'src/types'; 3 | 4 | test.describe('[Visual tests] User page', async () => { 5 | test( 6 | 'Register page', 7 | { 8 | tag: '@visual', 9 | annotation: { 10 | type: TestType.Test, 11 | description: 'https://demo.atlassian.net/browse/JIRA-32', 12 | }, 13 | }, 14 | async ({ navComponent, registerPage }) => { 15 | await navComponent.openRegisterPageURL(); 16 | await navComponent.expectNavigateToRegisterPage(); 17 | await registerPage.expectRegisterPageOpened(); 18 | 19 | await expect(registerPage.registerForm).toHaveScreenshot( 20 | 'register-form.png' 21 | ); 22 | } 23 | ); 24 | 25 | test( 26 | 'Login page', 27 | { 28 | tag: '@visual', 29 | annotation: { 30 | type: TestType.Test, 31 | description: 'https://demo.atlassian.net/browse/JIRA-33', 32 | }, 33 | }, 34 | async ({ navComponent, loginPage }) => { 35 | await navComponent.openLoginPageURL(); 36 | await navComponent.expectNavigateToLoginPage(); 37 | await loginPage.expectLoginPageOpened(); 38 | 39 | await expect(loginPage.loginComponent.loginForm).toHaveScreenshot( 40 | 'login-form.png' 41 | ); 42 | } 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/visual/checkout/checkout.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@fixtures/baseUIFixture'; 2 | import { mockCartResponse } from 'src/mock-api/common-mock-api'; 3 | import cartItems from '../fixtures/cartItems.json'; 4 | import AddressData from '@pages/common/data/address'; 5 | import { PaymentMethodLabel } from '@pages/checkout/types'; 6 | 7 | test.describe('[Visual tests] Checkout feature', async () => { 8 | test.use({ storageState: './playwright/.auth/customer01.json' }); 9 | 10 | test('Checkout flow', async ({ page, navComponent, checkoutPage }) => { 11 | await test.step('Snapshot cart step...', async () => { 12 | await mockCartResponse( 13 | { 14 | status: 200, 15 | json: cartItems, 16 | }, 17 | page 18 | ); 19 | await navComponent.openCheckoutPageURL(); 20 | await navComponent.expectNavigateToCheckoutPage(); 21 | await expect(checkoutPage.checkoutForm).toHaveScreenshot('cart-step.png'); 22 | }); 23 | 24 | await checkoutPage.clickProcessToCheckout(); 25 | await checkoutPage.expectNavigateToSignedInStep(); 26 | 27 | await test.step('Snapshot billing address step', async () => { 28 | await checkoutPage.clickProcessToCheckout(); 29 | await checkoutPage.expectToNavigateToBillingAddressStep(); 30 | await checkoutPage.addressComponent.fillAddressForm( 31 | AddressData.address01 32 | ); 33 | await expect(checkoutPage.checkoutForm).toHaveScreenshot( 34 | 'billing-address-step.png' 35 | ); 36 | }); 37 | 38 | await checkoutPage.clickProcessToCheckout(); 39 | await checkoutPage.expectNavigateToPaymentStep(); 40 | 41 | await test.step('Snapshot payment method: Bank Transfer', async () => { 42 | await checkoutPage.paymentComponent.selectPaymentMethod( 43 | PaymentMethodLabel.bankTransfer 44 | ); 45 | await expect(checkoutPage.checkoutForm).toHaveScreenshot( 46 | 'bank-transfer-payment-method.png' 47 | ); 48 | }); 49 | 50 | await test.step('Snapshot payment method: Credit Card', async () => { 51 | await checkoutPage.paymentComponent.selectPaymentMethod( 52 | PaymentMethodLabel.bankTransfer 53 | ); 54 | await expect(checkoutPage.checkoutForm).toHaveScreenshot( 55 | 'credit-card-payment-method.png' 56 | ); 57 | }); 58 | 59 | await test.step('Snapshot payment method: Gift Card', async () => { 60 | await checkoutPage.paymentComponent.selectPaymentMethod( 61 | PaymentMethodLabel.bankTransfer 62 | ); 63 | await expect(checkoutPage.checkoutForm).toHaveScreenshot( 64 | 'gitf-card-payment-method.png' 65 | ); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/visual/fixtures/cartItems.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "01jqkws8dct066wbqjccff5jxn", 3 | "additional_discount_percentage": null, 4 | "lat": null, 5 | "lng": null, 6 | "cart_items": [ 7 | { 8 | "id": "01jqkws9r994g1jnsr560dz9nj", 9 | "quantity": 2, 10 | "discount_percentage": null, 11 | "cart_id": "01jqkws8dct066wbqjccff5jxn", 12 | "product_id": "01JQKWGNJAP61JBKDPK6C2YG79", 13 | "product": { 14 | "id": "01JQKWGNJAP61JBKDPK6C2YG79", 15 | "name": "Combination Pliers", 16 | "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris viverra felis nec pellentesque feugiat. Donec faucibus arcu maximus, convallis nisl eu, placerat dolor. Morbi finibus neque nec tincidunt pharetra. Sed eget tortor malesuada, mollis enim id, condimentum nisi. In viverra quam at bibendum ultricies. Aliquam quis eros ex. Etiam at pretium massa, ut pharetra tortor. Sed vel metus sem. Suspendisse ac molestie turpis. Duis luctus justo massa, faucibus ornare eros elementum et. Vestibulum quis nisl vitae ante dapibus tempor auctor ut leo. Mauris consectetur et magna at ultricies. Proin a aliquet turpis.", 17 | "price": 14.15, 18 | "is_location_offer": false, 19 | "is_rental": false, 20 | "in_stock": true 21 | } 22 | }, 23 | { 24 | "id": "01jqkwsgh39xexnhmy0nkyd5bb", 25 | "quantity": 2, 26 | "discount_percentage": null, 27 | "cart_id": "01jqkws8dct066wbqjccff5jxn", 28 | "product_id": "01JQKWGNJWKC5MX2HH794C7CTX", 29 | "product": { 30 | "id": "01JQKWGNJWKC5MX2HH794C7CTX", 31 | "name": "Bolt Cutters", 32 | "description": "Aliquam viverra scelerisque tempus. Ut vehicula, ex sed elementum rhoncus, sem neque vehicula turpis, sit amet accumsan mauris justo non magna. Cras ut vulputate lectus, sit amet sollicitudin enim. Quisque sit amet turpis ut orci pulvinar vestibulum non at velit. Quisque ultrices malesuada felis non rutrum. Sed molestie lobortis nisl, in varius arcu dictum vel. In sit amet fringilla orci. Quisque ac magna dui. Nam pulvinar nulla sed commodo ultricies. Suspendisse aliquet quis eros sit amet gravida. Aenean vitae arcu in sapien sodales commodo.", 33 | "price": 48.41, 34 | "is_location_offer": true, 35 | "is_rental": false, 36 | "in_stock": true 37 | } 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /tests/visual/fixtures/invoices.json: -------------------------------------------------------------------------------- 1 | { 2 | "current_page": 1, 3 | "data": [ 4 | { 5 | "id": "01jqfxrw3vmbeh4ax60bests99", 6 | "invoice_date": "2025-03-28 03:05:20", 7 | "additional_discount_percentage": null, 8 | "additional_discount_amount": 0, 9 | "invoice_number": "INV-2025000003", 10 | "billing_street": "32652 Ohio 123", 11 | "billing_city": "New Orleans", 12 | "billing_state": "Massachusetts", 13 | "billing_country": "American Samoa", 14 | "billing_postal_code": "01382-9618", 15 | "subtotal": 14.15, 16 | "total": 14.15, 17 | "status": "AWAITING_FULFILLMENT", 18 | "status_message": null, 19 | "created_at": "2025-03-28T03:05:20.000000Z", 20 | "user_id": "01jqfxj9mb4e9ga412f28epgpj", 21 | "invoicelines": [ 22 | { 23 | "id": "01jqfxrw4s5eqf5v0bf6vgx5qd", 24 | "unit_price": 14.15, 25 | "quantity": 1, 26 | "discount_percentage": null, 27 | "discounted_price": null, 28 | "invoice_id": "01jqfxrw3vmbeh4ax60bests99", 29 | "product_id": "01JQFXFQGNVJHE2FZ4D9H3VFQ4", 30 | "product": { 31 | "id": "01JQFXFQGNVJHE2FZ4D9H3VFQ4", 32 | "name": "Combination Pliers", 33 | "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris viverra felis nec pellentesque feugiat. Donec faucibus arcu maximus, convallis nisl eu, placerat dolor. Morbi finibus neque nec tincidunt pharetra. Sed eget tortor malesuada, mollis enim id, condimentum nisi. In viverra quam at bibendum ultricies. Aliquam quis eros ex. Etiam at pretium massa, ut pharetra tortor. Sed vel metus sem. Suspendisse ac molestie turpis. Duis luctus justo massa, faucibus ornare eros elementum et. Vestibulum quis nisl vitae ante dapibus tempor auctor ut leo. Mauris consectetur et magna at ultricies. Proin a aliquet turpis.", 34 | "price": 14.15, 35 | "is_location_offer": false, 36 | "is_rental": false, 37 | "in_stock": true 38 | } 39 | } 40 | ], 41 | "payment": null 42 | }, 43 | { 44 | "id": "01jqfxrnesx0tp49yvehmj0zbd", 45 | "invoice_date": "2025-03-29 03:05:13", 46 | "additional_discount_percentage": null, 47 | "additional_discount_amount": 0, 48 | "invoice_number": "INV-2025000002", 49 | "billing_street": "32652 Onie Trace Suite 970", 50 | "billing_city": "New Orleans", 51 | "billing_state": "Massachusetts", 52 | "billing_country": "American Samoa", 53 | "billing_postal_code": "01382-9618", 54 | "subtotal": 14.15, 55 | "total": 14.15, 56 | "status": "AWAITING_FULFILLMENT", 57 | "status_message": null, 58 | "created_at": "2025-03-29T03:05:13.000000Z", 59 | "user_id": "01jqfxj9mb4e9ga412f28epgpj", 60 | "invoicelines": [ 61 | { 62 | "id": "01jqfxrnfmr2qm88qh5qmj8kh6", 63 | "unit_price": 14.15, 64 | "quantity": 1, 65 | "discount_percentage": null, 66 | "discounted_price": null, 67 | "invoice_id": "01jqfxrnesx0tp49yvehmj0zbd", 68 | "product_id": "01JQFXFQGNVJHE2FZ4D9H3VFQ4", 69 | "product": { 70 | "id": "01JQFXFQGNVJHE2FZ4D9H3VFQ4", 71 | "name": "Combination Pliers", 72 | "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris viverra felis nec pellentesque feugiat. Donec faucibus arcu maximus, convallis nisl eu, placerat dolor. Morbi finibus neque nec tincidunt pharetra. Sed eget tortor malesuada, mollis enim id, condimentum nisi. In viverra quam at bibendum ultricies. Aliquam quis eros ex. Etiam at pretium massa, ut pharetra tortor. Sed vel metus sem. Suspendisse ac molestie turpis. Duis luctus justo massa, faucibus ornare eros elementum et. Vestibulum quis nisl vitae ante dapibus tempor auctor ut leo. Mauris consectetur et magna at ultricies. Proin a aliquet turpis.", 73 | "price": 14.15, 74 | "is_location_offer": false, 75 | "is_rental": false, 76 | "in_stock": true 77 | } 78 | } 79 | ], 80 | "payment": null 81 | } 82 | ], 83 | "from": 1, 84 | "last_page": 1, 85 | "per_page": 15, 86 | "to": 2, 87 | "total": 2 88 | } 89 | -------------------------------------------------------------------------------- /tests/visual/fixtures/product.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "01JQHA3W63868CBYPDPD8GCSFP", 3 | "name": "Combination Pliers", 4 | "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris viverra felis nec pellentesque feugiat. Donec faucibus arcu maximus, convallis nisl eu, placerat dolor. Morbi finibus neque nec tincidunt pharetra. Sed eget tortor malesuada, mollis enim id, condimentum nisi. In viverra quam at bibendum ultricies. Aliquam quis eros ex. Etiam at pretium massa, ut pharetra tortor. Sed vel metus sem. Suspendisse ac molestie turpis. Duis luctus justo massa, faucibus ornare eros elementum et. Vestibulum quis nisl vitae ante dapibus tempor auctor ut leo. Mauris consectetur et magna at ultricies. Proin a aliquet turpis.", 5 | "price": 14.15, 6 | "is_location_offer": false, 7 | "is_rental": false, 8 | "in_stock": true, 9 | "product_image": { 10 | "id": "01JQHA3W529WH2N0WPZ1WPP5ZG", 11 | "by_name": "Helinton Fantin", 12 | "by_url": "https:\/\/unsplash.com\/@fantin", 13 | "source_name": "Unsplash", 14 | "source_url": "https:\/\/unsplash.com\/photos\/W8BNwvOvW4M", 15 | "file_name": "pliers01.avif", 16 | "title": "Combination pliers" 17 | }, 18 | "category": { 19 | "id": "01JQHA3W4ET1P6WF6VSJVKN50E", 20 | "name": "Pliers", 21 | "slug": "pliers", 22 | "parent_id": "01JQHA3W41RWBN4S069RP0DDEH" 23 | }, 24 | "brand": { 25 | "id": "01JQHA3W32S8H9SJ1G8A7Q944F", 26 | "name": "ForgeFlex Tools", 27 | "slug": "forgeflex-tools" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/visual/invoice/invoice.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@fixtures/baseUIFixture'; 2 | import { 3 | mockInvoicesResponse, 4 | mockInvoiceResponse, 5 | } from 'src/mock-api/common-mock-api'; 6 | import invoices from '../fixtures/invoices.json'; 7 | 8 | test.describe('[Visual tests] Invoice feature', async () => { 9 | test.use({ storageState: './playwright/.auth/customer01.json' }); 10 | 11 | test.skip('View invoice details', async ({ 12 | page, 13 | navComponent, 14 | accountPage, 15 | invoicePage, 16 | }) => { 17 | await mockInvoicesResponse( 18 | { 19 | status: 200, 20 | json: invoices, 21 | }, 22 | page 23 | ); 24 | 25 | await navComponent.openInvoicesPageURL(); 26 | await accountPage.expectNavigateToInvoicesPage(); 27 | 28 | const invoice = invoices.data[0]; 29 | await mockInvoiceResponse( 30 | { 31 | status: 200, 32 | json: invoice, 33 | }, 34 | page 35 | ); 36 | 37 | await navComponent.openInvoiceDetailsPageURL(invoice.id); 38 | await accountPage.expectNavigateToInvoicePage(); 39 | await expect(invoicePage.invoicesTable).toHaveScreenshot( 40 | 'invoice-details.png' 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "esModuleInterop": true, 5 | "resolveJsonModule": true, 6 | "paths": { 7 | "@api/*": ["src/api/*"], 8 | "@fixtures/*": ["src/fixtures/*"], 9 | "@pages/*": ["src/pages/*"], 10 | "@helpers/*": ["src/helpers/*"], 11 | "@data/*": ["src/data/*"], 12 | "@components/*": ["src/pages/components/*"], 13 | "@resources/*": ["tests/api/textResources/*"], 14 | "@dataFactory/*": ["src/api/lib/dataFactory/*"], 15 | "@apiHelpers/*": ["src/api/lib/helpers/*"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utils/date.ts: -------------------------------------------------------------------------------- 1 | import { addDays, formatDate, subDays, subYears } from 'date-fns'; 2 | 3 | const basicDateFmt = 'yyyy-MM-dd'; 4 | 5 | export function currentDate(format = basicDateFmt) { 6 | return formatDate(Date.now(), format); 7 | } 8 | 9 | export function plusDays(days: number, format = basicDateFmt) { 10 | return formatDate(addDays(Date.now(), days), format); 11 | } 12 | 13 | export function minusDays(days: number, format = basicDateFmt) { 14 | return formatDate(subDays(Date.now(), days), format); 15 | } 16 | 17 | export function minusYears(years: number, format = basicDateFmt) { 18 | return formatDate(subYears(Date.now(), years), format); 19 | } 20 | -------------------------------------------------------------------------------- /utils/env.ts: -------------------------------------------------------------------------------- 1 | export default class Env { 2 | // Urls 3 | static readonly BASE_URL = process.env.BASE_URL; 4 | static readonly API_URL = process.env.API_URL; 5 | // Account tests 6 | static readonly CUSTOMER_01_EMAIL = process.env.CUSTOMER_01_EMAIL; 7 | static readonly CUSTOMER_01_PASSWORD = process.env.CUSTOMER_01_PASSWORD; 8 | static readonly CUSTOMER_02_EMAIL = process.env.CUSTOMER_02_EMAIL; 9 | static readonly CUSTOMER_02_PASSWORD = process.env.CUSTOMER_02_PASSWORD; 10 | static readonly ADMIN_EMAIL = process.env.ADMIN_EMAIL; 11 | static readonly ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; 12 | // Playwright configs 13 | static readonly WORKERS = Number(process.env.WORKERS); 14 | static readonly BROWSER = process.env.BROWSER; 15 | static readonly ACTION_TIMEOUT = Number(process.env.ACTION_TIMEOUT); 16 | static readonly NAVIGATION_TIMEOUT = Number(process.env.NAVIGATION_TIMEOUT); 17 | static readonly EXPECT_TIMEOUT = Number(process.env.EXPECT_TIMEOUT); 18 | static readonly RETRY_ON_CI = Number(process.env.RETRY_ON_CI); 19 | static readonly RETRY = Number(process.env.RETRY); 20 | static readonly HEADLESS = process.env.HEADLESS; 21 | static readonly HTML_REPORT_DIR = process.env.HTML_REPORT_DIR; 22 | static readonly JUNIT_REPORT_DIR = process.env.JUNIT_REPORT_DIR; 23 | } 24 | -------------------------------------------------------------------------------- /utils/randomize.ts: -------------------------------------------------------------------------------- 1 | import { en, Faker } from '@faker-js/faker'; 2 | 3 | const faker = new Faker({ locale: en }); 4 | 5 | export function randomEmail() { 6 | return `${Date.now()}_${randomString(5)}@gmail.com`; 7 | } 8 | 9 | export function randomString(length = 15) { 10 | return faker.string.alphanumeric({ length: length }); 11 | } 12 | 13 | export function randomWords(options = { min: 3, max: 5 }) { 14 | return faker.word.words({ count: options }); 15 | } 16 | 17 | export function stringToSlug(value: string) { 18 | return value 19 | .toLowerCase() 20 | .replace(/ /g, '-') 21 | .replace(/[^\w-]+/g, ''); 22 | } 23 | 24 | export function randomPassword() { 25 | return faker.internet.password({ 26 | length: 10, 27 | prefix: '@95Lonv', 28 | }); 29 | } 30 | --------------------------------------------------------------------------------