├── .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 |
16 |
17 |
18 |
19 |
20 |
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 |
--------------------------------------------------------------------------------