From 0f3bae008c00eebe99a624fedafe55fab67ff830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAnton?= <“alantukh@“jwplayer.com> Date: Thu, 16 Jun 2022 13:50:24 +0200 Subject: [PATCH] refactor(e2e): e2e-report - fixed types in e2e tests - fixed failing e2e tests - added allure report - added artifact for allure - node version updated - tests folder refactoring - retries added --- .depcheckrc.yaml | 18 +- .github/workflows/codeceptjs.yml | 12 +- .github/workflows/lhci.yml | 2 +- .github/workflows/main.yml | 3 +- docs/features/e2e.md | 59 +++ package.json | 16 +- src/i18n/locales/en_US/video.json | 1 - src/i18n/locales/nl_NL/video.json | 1 - src/screens/Movie/Movie.tsx | 2 - test-e2e/codecept.desktop.js | 51 +- test-e2e/codecept.mobile.js | 49 +- .../index_test.ts} | 196 ++++---- .../index_test.ts} | 2 +- .../{home_test.ts => home/index_test.ts} | 24 +- .../{login_test.ts => login/login_account.ts} | 116 +---- test-e2e/tests/login/login_home.ts | 52 ++ .../tests/payments/payments_coupons_test.ts | 59 +++ .../payments/payments_subscription_test.ts | 179 +++++++ test-e2e/tests/payments_test.ts | 324 ------------- .../index_test.ts} | 28 +- .../index_test.ts} | 40 +- .../{search_test.ts => search/index_test.ts} | 19 +- .../tests/{seo_test.ts => seo/index_test.ts} | 66 +-- .../{series_test.ts => series/index_test.ts} | 22 +- .../index_test.ts} | 25 +- test-e2e/tests/watch_history/local_test.ts | 73 +++ .../tests/watch_history/logged_in_test.ts | 67 +++ test-e2e/tests/watch_history_test.ts | 194 -------- test-e2e/utils/constants.ts | 5 +- test-e2e/utils/login.ts | 48 ++ test-e2e/utils/password_utils.ts | 30 +- test-e2e/utils/payments.ts | 103 ++++ test-e2e/utils/steps_file.ts | 453 +++++++++--------- test-e2e/utils/watch_history.ts | 64 +++ yarn.lock | 131 ++++- 35 files changed, 1404 insertions(+), 1130 deletions(-) create mode 100644 docs/features/e2e.md rename test-e2e/tests/{account_test.ts => account/index_test.ts} (70%) rename test-e2e/tests/{favorites_test.ts => favorites/index_test.ts} (97%) rename test-e2e/tests/{home_test.ts => home/index_test.ts} (87%) rename test-e2e/tests/{login_test.ts => login/login_account.ts} (50%) create mode 100644 test-e2e/tests/login/login_home.ts create mode 100644 test-e2e/tests/payments/payments_coupons_test.ts create mode 100644 test-e2e/tests/payments/payments_subscription_test.ts delete mode 100644 test-e2e/tests/payments_test.ts rename test-e2e/tests/{playlist_test.ts => playlist/index_test.ts} (73%) rename test-e2e/tests/{register_test.ts => register/index_test.ts} (84%) rename test-e2e/tests/{search_test.ts => search/index_test.ts} (93%) rename test-e2e/tests/{seo_test.ts => seo/index_test.ts} (81%) rename test-e2e/tests/{series_test.ts => series/index_test.ts} (79%) rename test-e2e/tests/{video_detail_test.ts => video_detail/index_test.ts} (86%) create mode 100644 test-e2e/tests/watch_history/local_test.ts create mode 100644 test-e2e/tests/watch_history/logged_in_test.ts delete mode 100644 test-e2e/tests/watch_history_test.ts create mode 100644 test-e2e/utils/login.ts create mode 100644 test-e2e/utils/payments.ts create mode 100644 test-e2e/utils/watch_history.ts diff --git a/.depcheckrc.yaml b/.depcheckrc.yaml index bb7c58e7f..78284aaf5 100644 --- a/.depcheckrc.yaml +++ b/.depcheckrc.yaml @@ -1,15 +1,17 @@ ignores: [ # These are dependencies for vite and vite plugins that depcheck doesn't recognize as being used - "postcss-scss", - "stylelint-order", - "stylelint-config-recommended-scss", - "stylelint-declaration-strict-value", - "stylelint-scss", + 'postcss-scss', + 'stylelint-order', + 'stylelint-config-recommended-scss', + 'stylelint-declaration-strict-value', + 'stylelint-scss', # This is used by commitlint in .commitlintrc.js - " @commitlint/config-conventional", + ' @commitlint/config-conventional', # These are vite aliases / tsconfig paths that point to specific local directories # Note the \\ is apparently necessary to escape the # or the ignore doesn't work "\\#src", "\\#test", - "\\#types" -] + "\\#types", + # To support e2e-reports + 'allure-commandline', + ] diff --git a/.github/workflows/codeceptjs.yml b/.github/workflows/codeceptjs.yml index ad96a1a4a..22f7eec81 100644 --- a/.github/workflows/codeceptjs.yml +++ b/.github/workflows/codeceptjs.yml @@ -4,13 +4,12 @@ on: [pull_request] jobs: build: - runs-on: ubuntu-latest strategy: fail-fast: false matrix: - node-version: [14.x] + node-version: [16.x] config: [desktop, mobile] steps: @@ -27,5 +26,10 @@ jobs: run: yarn start & - name: Run tests run: wait-on -v -t 30000 -c ./scripts/waitOnConfig.js http-get://localhost:8080 && yarn codecept:${{ matrix.config }} - env: - CI: true + - name: Uploading artifact + if: always() + uses: actions/upload-artifact@v3 + with: + name: allure-report-${{ matrix.config }} + path: ./test-e2e/output/${{ matrix.config }} + retention-days: 7 diff --git a/.github/workflows/lhci.yml b/.github/workflows/lhci.yml index 2cf0bf911..87951c491 100644 --- a/.github/workflows/lhci.yml +++ b/.github/workflows/lhci.yml @@ -9,7 +9,7 @@ jobs: - name: Use Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 14.x + node-version: 16.x - name: yarn install, build run: | yarn install diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bb0bf7de8..ecbdcfffa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,12 +4,11 @@ on: [pull_request] jobs: build: - runs-on: macos-latest strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - uses: actions/checkout@v1 diff --git a/docs/features/e2e.md b/docs/features/e2e.md new file mode 100644 index 000000000..3c33ea3d4 --- /dev/null +++ b/docs/features/e2e.md @@ -0,0 +1,59 @@ +# e2e tests + +## Instruments used + +We use several libraries for e2e-tests: + +- CodeceptJS as a test launcher +- Playwright as a cross-browser e2e engine +- Allure to build reports + +[Read more.](https://codecept.io/playwright/#setup) + +## Folder structure + +We store e2e logic in `test-e2e` folder. Tests suites are located in `tests` folder, where each subfolder represents the component / page being tested. + +There are two config files for desktop and mobile testing. By default each test suite works for both mobile and desktop pack. In order to limit test suite as one suitable only for one platform, it is possible to write `(@mobile-only)` in the Scenario description. + +In the `data` folder we store ott-app configs necessary for testing purposes. To load config in the test suite it is possible to use `I.useConfig(__CONFIG_NAME__);` function. + +`output` folder consists of allure test report and screenshot of failed tests (with `mobile` and `desktop` subfolders to separate test results). + +`utils` folder can be used to store common utils / asserts necessary for test suits. + +## Test suite + +Each test suite is a separate file located in one of the `tests` subfolders. It is necessary to label the suite with the following feature code: `Feature('account').retry(3);` . In order to reduce the chance of unintended failures it is also better to define retry count. This way a test will be relaunched several times in case it failed. + +**TODO:** use `allure.createStep` to have readable steps in allure report. [Read more.](https://codecept.io/plugins/#allure) + +## Tests launching + +We use several workers to launch tests for each platform. That increases the speed and guaranties the autonomy of each Scenario. + +**(!)** In order to support allure reports it is necessary to install Java 8. + +Basic commands: + +`yarn codecept:mobile` - to run tests for mobile device +`yarn codecept:desktop`: - to run tests for desktop +`yarn serve-report:mobile` - to serve allure report from "./output/mobile" folder +`yarn serve-report:desktop` - to serve allure report from "./output/desktop" folder +`yarn codecept-serve:mobile` - to run desktop tests and serve the report +`yarn codecept-serve:desktop` - to run mobile tests and serve the report + +## GitHub Actions + +We have two actions: one for desktop and one for mobile device. Each one runs independently. After the action run it is possible to download an artifact with an allure report and build a nice report locally. + +To do it on Mac: `allure serve ~/Downloads/allure-report-desktop` + +To serve allure reports locally `allure-commandline` package should be installed globally. + +## Simple steps to run tests locally for desktop + +1. Install Java 8 (for Mac homebrew with `adoptopenjdk8` package can be used package) +2. `yarn install` +3. Install `allure-commandline` globally (can help in the future to serve downloaded artifacts) +4. Run `yarn codecept-serve:desktop` diff --git a/package.json b/package.json index b9470c062..c69cdcc1d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "repository": "https://github.com/jwplayer/ott-web-app.git", "author": "Robin van Zanten", "private": true, + "engines": { + "node": ">=16.0.0" + }, "scripts": { "prepare": "husky install", "start": "vite", @@ -18,11 +21,16 @@ "lint:styles": "stylelint \"src/**/*.scss\"", "commit-msg": "commitlint --edit $1", "pre-commit": "depcheck && lint-staged && TZ=UTC yarn test --coverage", - "codecept:mobile": "cd test-e2e && codeceptjs -c codecept.mobile.js run --steps", - "codecept:desktop": "cd test-e2e && codeceptjs -c codecept.desktop.js run --steps", + "codecept:mobile": "cd test-e2e && rm -rf \"./output/mobile\" && codeceptjs --config ./codecept.mobile.js run-workers --suites 2 ", + "codecept:desktop": "cd test-e2e && rm -rf \"./output/desktop\" && codeceptjs --config ./codecept.desktop.js run-workers --suites 2 ", + "serve-report:mobile": "cd test-e2e && allure serve \"./output/mobile\"", + "serve-report:desktop": "cd test-e2e && allure serve \"./output/desktop\"", + "codecept-serve:mobile": "yarn codecept:mobile ; yarn serve-report:mobile", + "codecept-serve:desktop": "yarn codecept:desktop ; yarn serve-report:desktop", "deploy:github": "node ./scripts/deploy-github.js" }, "dependencies": { + "allure-commandline": "^2.17.2", "classnames": "^2.3.1", "history": "^4.10.1", "i18next": "^20.3.1", @@ -59,7 +67,7 @@ "@typescript-eslint/parser": "^5.17.0", "@vitejs/plugin-react": "^1.0.7", "c8": "^7.11.2", - "codeceptjs": "^3.2.3", + "codeceptjs": "3.3.0", "confusing-browser-globals": "^1.0.10", "depcheck": "^1.4.3", "eslint": "^7.31.0", @@ -117,4 +125,4 @@ "glob-parent": "^5.1.2", "codeceptjs/**/ansi-regex": "^4.1.1" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en_US/video.json b/src/i18n/locales/en_US/video.json index e54b78035..ea0b30888 100644 --- a/src/i18n/locales/en_US/video.json +++ b/src/i18n/locales/en_US/video.json @@ -6,7 +6,6 @@ "continue_watching": "Continue watching", "copied_url": "Copied url", "current_episode": "Current episode", - "currently_playing": "Current video", "episode_not_found": "Episode not found", "episodes": "Episodes", "favorite": "Favorite", diff --git a/src/i18n/locales/nl_NL/video.json b/src/i18n/locales/nl_NL/video.json index 4b28020ba..7b8c63bea 100644 --- a/src/i18n/locales/nl_NL/video.json +++ b/src/i18n/locales/nl_NL/video.json @@ -6,7 +6,6 @@ "continue_watching": "", "copied_url": "", "current_episode": "", - "currently_playing": "", "episode_not_found": "", "episodes": "", "favorite": "", diff --git a/src/screens/Movie/Movie.tsx b/src/screens/Movie/Movie.tsx index f854d29e0..da2ae2d68 100644 --- a/src/screens/Movie/Movie.tsx +++ b/src/screens/Movie/Movie.tsx @@ -158,8 +158,6 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. playlist={playlist.playlist} onCardClick={onCardClick} isLoading={isLoading} - currentCardItem={item} - currentCardLabel={t('currently_playing')} enableCardTitles={styling.shelfTitles} accessModel={accessModel} isLoggedIn={!!user} diff --git a/test-e2e/codecept.desktop.js b/test-e2e/codecept.desktop.js index f362c3334..4bc599b2c 100644 --- a/test-e2e/codecept.desktop.js +++ b/test-e2e/codecept.desktop.js @@ -8,35 +8,40 @@ setHeadlessWhen(process.env.HEADLESS); exports.config = { grep: '(?=.*)^(?!.*@mobile-only)', - tests : [ - './tests/*.js', - './tests/*.ts' - ], - output : './output/desktop', - helpers : { + tests: ['./tests/**/*.js', './tests/**/*.ts'], + output: './output/desktop', + timeout: 60, + helpers: { Playwright: { - url : 'http://localhost:8080', - show : false, - channel: 'chrome' - } + url: 'http://localhost:8080', + show: false, + channel: 'chrome', + }, }, - include : { - I: './utils/steps_file.ts' + include: { + I: './utils/steps_file.ts', }, bootstrap: null, - mocha : {}, - name : 'desktop', - plugins : { - pauseOnFail : {}, - retryFailedStep : { + mocha: {}, + name: 'desktop', + plugins: { + pauseOnFail: {}, + retryFailedStep: { + minTimeout: 3000, + enabled: true, + retries: 3, + }, + autoDelay: { enabled: true, - retries: 2, }, - tryTo : { - enabled: true + tryTo: { + enabled: true, }, screenshotOnFail: { - enabled: !process.env.CI - } - } + enabled: !process.env.CI, + }, + allure: { + enabled: true, + }, + }, }; diff --git a/test-e2e/codecept.mobile.js b/test-e2e/codecept.mobile.js index 821c03a12..ae1d15054 100644 --- a/test-e2e/codecept.mobile.js +++ b/test-e2e/codecept.mobile.js @@ -9,36 +9,41 @@ setHeadlessWhen(process.env.HEADLESS); exports.config = { grep: '(?=.*)^(?!.*@desktop-only)', - tests : [ - './tests/*.js', - './tests/*.ts' - ], - output : './output/mobile', - helpers : { + tests: ['./tests/**/*.js', './tests/**/*.ts'], + output: './output/mobile', + timeout: 60, + helpers: { Playwright: { - url : 'http://localhost:8080', - show : false, + url: 'http://localhost:8080', + show: false, channel: 'chrome', emulate: devices['Pixel 5'], - } + }, }, - include : { - I: './utils/steps_file.ts' + include: { + I: './utils/steps_file.ts', }, bootstrap: null, - mocha : {}, - name : 'mobile', - plugins : { - pauseOnFail : {}, - retryFailedStep : { + mocha: {}, + name: 'mobile', + plugins: { + pauseOnFail: {}, + retryFailedStep: { + minTimeout: 3000, + enabled: true, + retries: 3, + }, + autoDelay: { enabled: true, - retries: 2, }, - tryTo : { - enabled: true + tryTo: { + enabled: true, }, screenshotOnFail: { - enabled: !process.env.CI - } - } + enabled: !process.env.CI, + }, + allure: { + enabled: true, + }, + }, }; diff --git a/test-e2e/tests/account_test.ts b/test-e2e/tests/account/index_test.ts similarity index 70% rename from test-e2e/tests/account_test.ts rename to test-e2e/tests/account/index_test.ts index d95aa823c..e82f46fce 100644 --- a/test-e2e/tests/account_test.ts +++ b/test-e2e/tests/account/index_test.ts @@ -1,5 +1,5 @@ -import passwordUtils, {LoginContext} from "../utils/password_utils"; -import constants from '../utils/constants'; +import passwordUtils, { LoginContext } from '../../utils/password_utils'; +import constants from '../../utils/constants'; const editAccount = 'Edit account'; const editDetials = 'Edit information'; @@ -7,29 +7,21 @@ const emailField = 'email'; const passwordField = 'confirmationPassword'; const firstNameField = 'firstName'; const lastNameField = 'lastName'; -const consentCheckbox = 'Yes, I want to receive Blender updates by email.'; +const consentCheckbox = 'Yes, I want to receive Blender updates by email'; let loginContext: LoginContext; const firstName = 'John Q.'; const lastName = 'Tester'; -Feature('account'); +Feature('account').retry(3); -Before(({I})=> { +Before(({ I }) => { I.useConfig('test--subscription'); - - loginContext = I.registerOrLogin(loginContext, () => { - I.fillField('firstName', firstName); - I.fillField('lastName', lastName); - - I.click('Continue'); - I.waitForLoaderDone(10); - - I.clickCloseButton(); - }); }); Scenario('I can see my account data', async ({ I }) => { + registerOrLogin(I); + I.seeCurrentUrlEquals(constants.baseUrl); await I.openMainMenu(); @@ -53,60 +45,69 @@ Scenario('I can see my account data', async ({ I }) => { I.see('Terms & tracking'); I.see('I accept the Terms and Conditions of Cleeng.'); - I.see('Yes, I want to receive Blender updates by email.'); + I.see(consentCheckbox); I.seeCurrentUrlEquals(constants.accountsUrl); }); Scenario('I can cancel Edit account', async ({ I }) => { + registerOrLogin(I); + editAndCancel(I, editAccount, [ - {name: emailField, startingValue: loginContext.email, newValue: 'user@email.nl'}, - {name: passwordField, startingValue: '', newValue: 'pass123!?'}, + { name: emailField, startingValue: loginContext.email, newValue: 'user@email.nl' }, + { name: passwordField, startingValue: '', newValue: 'pass123!?' }, ]); }); Scenario('I get a duplicate email warning', async ({ I }) => { + registerOrLogin(I); + editAndCancel(I, editAccount, [ { name: emailField, startingValue: loginContext.email, newValue: constants.username, - expectedError: 'Email already exists!' - }, { + expectedError: 'Email already exists!', + }, + { name: passwordField, startingValue: '', - newValue: loginContext.password - } + newValue: loginContext.password, + }, ]); }); Scenario('I get a wrong password warning', async ({ I }) => { + registerOrLogin(I); + editAndCancel(I, editAccount, [ { name: emailField, startingValue: loginContext.email, - newValue: loginContext.email - }, { + newValue: loginContext.email, + }, + { name: passwordField, startingValue: '', newValue: 'ABCDEF123!', - expectedError: 'Password incorrect!' - } + expectedError: 'Password incorrect!', + }, ]); }); Scenario('I can toggle to view/hide my password', async ({ I }) => { + registerOrLogin(I); I.amOnPage(constants.accountsUrl); - - I.click('Edit account'); + I.click(editAccount); await passwordUtils.testPasswordToggling(I, 'confirmationPassword'); }); Scenario('I can reset my password', async ({ I }) => { + registerOrLogin(I); I.amOnPage(constants.accountsUrl); I.click('Edit password'); - I.see('If you want to edit your password, click \'YES, Reset\' to receive password reset instruction on your mail'); + I.see("If you want to edit your password, click 'YES, Reset' to receive password reset instruction on your mail"); I.see('Yes, reset'); I.see('No, thanks'); @@ -127,107 +128,120 @@ Scenario('I can reset my password', async ({ I }) => { I.see('Sign in'); I.clickCloseButton(); - I.login({email: loginContext.email, password: loginContext.password}); -}) + I.login({ email: loginContext.email, password: loginContext.password }); +}); Scenario('I can update firstName', async ({ I }) => { + registerOrLogin(I); + editAndSave(I, editDetials, [ { name: firstNameField, - newValue: '' - } + newValue: '', + }, ]); editAndSave(I, editDetials, [ { name: firstNameField, - newValue: 'Jack' - } + newValue: 'Jack', + }, ]); editAndSave(I, editDetials, [ { name: firstNameField, - newValue: firstName - } + newValue: firstName, + }, ]); }); Scenario('I can update lastName', async ({ I }) => { + registerOrLogin(I); + editAndSave(I, editDetials, [ { name: lastNameField, - newValue: '' - } + newValue: '', + }, ]); editAndSave(I, editDetials, [ { name: lastNameField, - newValue: 'Jones' - } + newValue: 'Jones', + }, ]); editAndSave(I, editDetials, [ { name: lastNameField, - newValue: lastName - } + newValue: lastName, + }, ]); }); Scenario('I can update details', async ({ I }) => { + registerOrLogin(I); + editAndSave(I, editDetials, [ { name: firstNameField, - newValue: '' - }, { + newValue: '', + }, + { name: lastNameField, - newValue: '' - } + newValue: '', + }, ]); editAndSave(I, editDetials, [ { name: firstNameField, - newValue: 'Newname' - }, { + newValue: 'Newname', + }, + { name: lastNameField, - newValue: 'McName' - } + newValue: 'McName', + }, ]); editAndSave(I, editDetials, [ { name: firstNameField, - newValue: firstName - }, { + newValue: firstName, + }, + { name: lastNameField, - newValue: lastName - } + newValue: lastName, + }, ]); }); -Scenario('I see name limit errors', async ({ I })=> { +Scenario('I see name limit errors', async ({ I }) => { + registerOrLogin(I); + editAndCancel(I, editDetials, [ { name: firstNameField, startingValue: firstName, newValue: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - expectedError: 'Please limit First Name to 50 characters or fewer.' - }, { + expectedError: 'Please limit First Name to 50 characters or fewer.', + }, + { name: lastNameField, startingValue: lastName, newValue: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - expectedError: 'Please limit Last Name to 50 characters or fewer.' - } - ]) + expectedError: 'Please limit Last Name to 50 characters or fewer.', + }, + ]); }); -Scenario('I can update my consents', async ({ I })=> { +Scenario('I can update my consents', async ({ I }) => { + registerOrLogin(I); I.amOnPage(constants.accountsUrl); - I.dontSeeCheckboxIsChecked(consentCheckbox) + I.dontSeeCheckboxIsChecked(consentCheckbox); I.dontSee('Save'); I.dontSee('Cancel'); @@ -237,9 +251,9 @@ Scenario('I can update my consents', async ({ I })=> { I.see('Save'); I.see('Cancel'); - I.click("Cancel"); + I.click('Cancel'); - I.dontSeeCheckboxIsChecked(consentCheckbox) + I.dontSeeCheckboxIsChecked(consentCheckbox); I.dontSee('Save'); I.dontSee('Cancel'); @@ -255,29 +269,37 @@ Scenario('I can update my consents', async ({ I })=> { }); Scenario('I can change email', async ({ I }) => { + registerOrLogin(I); const newEmail = passwordUtils.createRandomEmail(); editAndSave(I, editAccount, [ - {name: emailField, newValue: newEmail}, - {name: passwordField, newValue: loginContext.password}, + { name: emailField, newValue: newEmail }, + { name: passwordField, newValue: loginContext.password }, ]); await I.logout(); - I.login({email: newEmail, password: loginContext.password}); + I.login({ email: newEmail, password: loginContext.password }); editAndSave(I, editAccount, [ - {name: emailField, newValue: loginContext.email}, - {name: passwordField, newValue: loginContext.password}, + { name: emailField, newValue: loginContext.email }, + { name: passwordField, newValue: loginContext.password }, ]); }); -function editAndSave( - I: CodeceptJS.I, - editButton: string, - fields: {name: string, newValue: string, expectedError?: string}[] -) { +function registerOrLogin(I: CodeceptJS.I) { + loginContext = I.registerOrLogin(loginContext, () => { + I.fillField('firstName', firstName); + I.fillField('lastName', lastName); + I.click('Continue'); + I.waitForLoaderDone(10); + + I.clickCloseButton(); + }); +} + +function editAndSave(I: CodeceptJS.I, editButton: string, fields: { name: string; newValue: string; expectedError?: string }[]) { I.amOnPage(constants.accountsUrl); I.click(editButton); @@ -285,7 +307,9 @@ function editAndSave( I.see('Save'); I.see('Cancel'); - const fieldsWithPaths = fields.map(f => { return {...f, xpath: `//input[@name='${f.name}']`};}); + const fieldsWithPaths = fields.map((f) => { + return { ...f, xpath: `//input[@name='${f.name}']` }; + }); for (const field of fieldsWithPaths) { I.seeElement(field.xpath); @@ -294,7 +318,7 @@ function editAndSave( I.fillField(field.xpath, field.newValue); } else { I.click(field.xpath); - I.pressKey(['Control', 'a']); + I.pressKey(['CommandOrControl', 'a']); I.pressKey('Backspace'); } } @@ -305,7 +329,7 @@ function editAndSave( I.dontSee('Save'); I.dontSee('Cancel'); - fieldsWithPaths.forEach(field => { + fieldsWithPaths.forEach((field) => { I.dontSee(field.xpath); if (field.newValue && field.name !== passwordField) { @@ -323,18 +347,16 @@ function editAndSave( I.click('Cancel'); } -function editAndCancel( - I: CodeceptJS.I, - editButton: string, - fields: {name: string, startingValue: string, newValue: string, expectedError?: string}[] -) { +function editAndCancel(I: CodeceptJS.I, editButton: string, fields: { name: string; startingValue: string; newValue: string; expectedError?: string }[]) { I.amOnPage(constants.accountsUrl); I.click(editButton); I.see('Save'); I.see('Cancel'); - const fieldsWithPaths = fields.map(f => { return {...f, xpath: `//input[@name='${f.name}']`};}); + const fieldsWithPaths = fields.map((f) => { + return { ...f, xpath: `//input[@name='${f.name}']` }; + }); for (const field of fieldsWithPaths) { I.seeElement(field.xpath); @@ -343,7 +365,7 @@ function editAndCancel( } // If expecting errors, try to save first - if (fieldsWithPaths.some(field => field.expectedError)) { + if (fieldsWithPaths.some((field) => field.expectedError)) { I.click('Save'); I.waitForLoaderDone(); @@ -367,7 +389,7 @@ function editAndCancel( I.dontSee('Save'); I.dontSee('Cancel'); - fieldsWithPaths.forEach(field => { + fieldsWithPaths.forEach((field) => { I.dontSee(field.xpath); if (field.name !== passwordField) { I.see(field.startingValue); @@ -380,4 +402,4 @@ function editAndCancel( I.seeElement(field.xpath); I.waitForValue(field.xpath, field.name === passwordField ? '' : field.startingValue, 0); } -} \ No newline at end of file +} diff --git a/test-e2e/tests/favorites_test.ts b/test-e2e/tests/favorites/index_test.ts similarity index 97% rename from test-e2e/tests/favorites_test.ts rename to test-e2e/tests/favorites/index_test.ts index 2183d77e4..8eaed36ab 100644 --- a/test-e2e/tests/favorites_test.ts +++ b/test-e2e/tests/favorites/index_test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; -Feature('favorites'); +Feature('favorites').retry(3); Scenario('I can add a video to my favorites', async ({ I }) => { addVideoToFavorites(I); diff --git a/test-e2e/tests/home_test.ts b/test-e2e/tests/home/index_test.ts similarity index 87% rename from test-e2e/tests/home_test.ts rename to test-e2e/tests/home/index_test.ts index 74222b049..54dcd8183 100644 --- a/test-e2e/tests/home_test.ts +++ b/test-e2e/tests/home/index_test.ts @@ -1,8 +1,8 @@ -import constants from "../utils/constants"; +import constants from '../../utils/constants'; -Feature('home'); +Feature('home').retry(3); -Before(({I}) => { +Before(({ I }) => { I.useConfig('blender'); }); @@ -31,7 +31,7 @@ Scenario('Header button navigates to playlist screen', async ({ I }) => { } I.see('Films'); - I.click({text: 'Films'}); + I.click('Films'); I.amOnPage(`${constants.baseUrl}p/${constants.filmsPlaylistId}`); I.see('All Films'); I.see('The Daily Dweebs'); @@ -44,7 +44,7 @@ Scenario('I can slide within the featured shelf', async ({ I }) => { if (isDesktop) { I.click({ css: 'div[aria-label="Slide right"]' }); } else { - await I.swipeLeft({text:swipeText}); + await I.swipeLeft({ text: swipeText }); } } @@ -78,7 +78,7 @@ Scenario('I can slide within non-featured shelves', async ({ I }) => { if (isDesktop) { I.click({ css: 'div[aria-label="Slide right"]' }, `div[data-mediaid="${constants.filmsPlaylistId}"]`); } else { - await I.swipeLeft({text:swipeText}); + await I.swipeLeft({ text: swipeText }); } } @@ -86,20 +86,18 @@ Scenario('I can slide within non-featured shelves', async ({ I }) => { if (isDesktop) { I.click({ css: 'div[aria-label="Slide left"]' }, `div[data-mediaid="${constants.filmsPlaylistId}"]`); } else { - await I.swipeRight({text:swipeText}); + await I.swipeRight({ text: swipeText }); } } - const rightMedia = isDesktop - ? {name: 'Cosmos Laundromat', duration: '13 min'} - : {name: 'Big Buck Bunny', duration: '10 min'}; + const rightMedia = isDesktop ? { name: 'Cosmos Laundromat', duration: '13 min' } : { name: 'Big Buck Bunny', duration: '10 min' }; I.see('All Films'); I.see('Agent 327'); I.see('4 min'); I.dontSee(rightMedia.name); I.dontSee(rightMedia.duration); - await slideRight( 'Agent 327'); + await slideRight('Agent 327'); I.waitForElement(`text="${rightMedia.name}"`, 3); I.see(rightMedia.duration); I.dontSee('Agent 327'); @@ -110,7 +108,7 @@ Scenario('I can slide within non-featured shelves', async ({ I }) => { await slideLeft(rightMedia.name); I.waitForElement('text="Agent 327"', 3); - I.dontSee(rightMedia); + I.dontSee(rightMedia.name); // Without this extra wait, the second slide action happens too fast after the first and even though the // expected elements are present, the slide doesn't work. I think there must be a debounce on the carousel. @@ -126,4 +124,4 @@ Scenario('I can see the footer', ({ I }) => { I.see('© Blender Foundation'); I.see('cloud.blender.org'); I.click('cloud.blender.org'); -}); \ No newline at end of file +}); diff --git a/test-e2e/tests/login_test.ts b/test-e2e/tests/login/login_account.ts similarity index 50% rename from test-e2e/tests/login_test.ts rename to test-e2e/tests/login/login_account.ts index 7abdccb09..3e1032519 100644 --- a/test-e2e/tests/login_test.ts +++ b/test-e2e/tests/login/login_account.ts @@ -1,67 +1,19 @@ -import constants from '../utils/constants'; -import passwordUtils from '../utils/password_utils'; +import constants from '../../utils/constants'; +import passwordUtils from '../../utils/password_utils'; +import { tryToSubmitForm, fillAndCheckField, checkField } from '../../utils/login'; const fieldRequired = 'This field is required'; const invalidEmail = 'Please re-enter your email details and try again.'; const incorrectLogin = 'Incorrect email/password combination'; const formFeedback = 'div[class*=formFeedback]'; -Feature('login'); +Feature('login - account').retry(3); -Scenario('Sign-in buttons show for accounts config', async ({ I }) => { - I.useConfig('test--accounts'); - - if (await I.isMobile()) { - I.openMenuDrawer(); - } - - I.see('Sign in'); - I.see('Sign up'); -}); - -Scenario('Sign-in buttons don\'t show for config without accounts', async ({ I }) => { - I.useConfig('test--no-cleeng'); - - I.dontSee('Sign in'); - I.dontSee('Sign up'); - - if (await I.isMobile()) { - I.openMenuDrawer(); - - I.dontSee('Sign in'); - I.dontSee('Sign up'); - } -}); - -Scenario('I can open the log in modal', async ({ I }) => { - I.useConfig('test--accounts'); - - if (await I.isMobile()) { - I.openMenuDrawer(); - } - - I.click('Sign in'); - I.waitForElement(constants.loginFormSelector, 15); - - I.seeCurrentUrlEquals(constants.loginUrl); - - I.see('Sign in'); - I.see('Email'); - I.see('Password'); - I.see('Forgot password'); - I.see('New to Blender?'); - I.see('Sign up'); -}); - -Feature('login - start from login page'); - -Before(({I}) => { +Before(({ I }) => { I.useConfig('test--accounts', constants.loginUrl); - I.waitForElement(constants.loginFormSelector, 10); }); - Scenario('I can close the modal', async ({ I }) => { I.clickCloseButton(); I.dontSee('Email'); @@ -150,7 +102,7 @@ Scenario('I see a login error message', async ({ I }) => { I.submitForm(); I.see(incorrectLogin); - I.seeCssPropertiesOnElements(formFeedback, {'background-color': 'rgb(255, 12, 62)'}); + I.seeCssPropertiesOnElements(formFeedback, { 'background-color': 'rgb(255, 12, 62)' }); checkField(I, 'email', true); checkField(I, 'password', true); @@ -166,7 +118,7 @@ Scenario('I can login', async ({ I }) => { I.dontSee('Sign in'); I.dontSee('Sign up'); - await I.openMainMenu() + await I.openMainMenu(); I.dontSee('Sign in'); I.dontSee('Sign up'); @@ -176,7 +128,7 @@ Scenario('I can login', async ({ I }) => { I.see('Log out'); }); -Scenario('I can log out', async ({ I })=> { +Scenario('I can log out', async ({ I }) => { I.login(); const isMobile = await I.openMainMenu(); @@ -190,55 +142,3 @@ Scenario('I can log out', async ({ I })=> { I.see('Sign in'); I.see('Sign up'); }); - -function tryToSubmitForm(I: CodeceptJS.I) { - I.submitForm(false); - I.dontSeeElement(formFeedback); - I.dontSee('Incorrect email/password combination'); -} - -function fillAndCheckField(I: CodeceptJS.I, field, value, error: string | boolean = false) { - if (value === '') { - // For some reason the Codecept/playwright clear and fillField with empty string do not fire the change events - // so use key presses to clear the field to avoid test-induced bugs - I.click(`input[name="${field}"]`); - I.pressKey(['Control', 'a']); - I.pressKey('Backspace'); - } else { - I.fillField(field, value); - } - - I.mo - - checkField(I, field, error); -} - -function checkField(I: CodeceptJS.I, field, error: string | boolean = false) { - const hoverColor = 'rgba(255, 255, 255, 0.7)'; - const activeColor = error ? 'rgb(255, 12, 62)' : 'rgb(255, 255, 255)'; - const restingColor = error ? 'rgb(255, 12, 62)' : 'rgba(255, 255, 255, 0.34)'; - - // If error === true, there's an error, but no associated message - if (error && error !== true) { - I.see(error, `[data-testid=login-${field}-input]`); - I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=helperText]`, { 'color': '#ff0c3e'}); - } else { - I.dontSeeElement('[class*=helperText]', `[data-testid="${field}-input"]`); - } - - // There are 3 css states for the input fields, hover, active, and 'resting'. Check all 3. - // This isn't so much for testing functionality, as it is to avoid test bugs caused by codecept placing the mouse - // different places and accidentally triggering the wrong css color - // Hover: - I.click(`input[name="${field}"]`); - I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=container]`, - {'border-color': hoverColor}); - // Active (no hover): - I.moveCursorTo('button[type=submit]'); - I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=container]`, - {'border-color': activeColor}); - // Resting: - I.click('div[class*=banner]'); - I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=container]`, - {'border-color': restingColor}); -} diff --git a/test-e2e/tests/login/login_home.ts b/test-e2e/tests/login/login_home.ts new file mode 100644 index 000000000..2cf20fe2c --- /dev/null +++ b/test-e2e/tests/login/login_home.ts @@ -0,0 +1,52 @@ +import constants from '../../utils/constants'; + +Feature('login - home').retry(3); + +Before(({ I }) => { + I.useConfig('test--accounts', constants.loginUrl); +}); + +Scenario('Sign-in buttons show for accounts config', async ({ I }) => { + I.useConfig('test--accounts'); + + if (await I.isMobile()) { + I.openMenuDrawer(); + } + + I.see('Sign in'); + I.see('Sign up'); +}); + +Scenario('Sign-in buttons don`t show for config without accounts', async ({ I }) => { + I.useConfig('test--no-cleeng'); + + I.dontSee('Sign in'); + I.dontSee('Sign up'); + + if (await I.isMobile()) { + I.openMenuDrawer(); + + I.dontSee('Sign in'); + I.dontSee('Sign up'); + } +}); + +Scenario('I can open the log in modal', async ({ I }) => { + I.useConfig('test--accounts'); + + if (await I.isMobile()) { + I.openMenuDrawer(); + } + + I.click('Sign in'); + I.waitForElement(constants.loginFormSelector, 15); + + I.seeCurrentUrlEquals(constants.loginUrl); + + I.see('Sign in'); + I.see('Email'); + I.see('Password'); + I.see('Forgot password'); + I.see('New to Blender?'); + I.see('Sign up'); +}); diff --git a/test-e2e/tests/payments/payments_coupons_test.ts b/test-e2e/tests/payments/payments_coupons_test.ts new file mode 100644 index 000000000..24974bb47 --- /dev/null +++ b/test-e2e/tests/payments/payments_coupons_test.ts @@ -0,0 +1,59 @@ +import { LoginContext } from '../../utils/password_utils'; +import { overrideIP, goToCheckout, formatPrice, finishAndCheckSubscription, addYear, cancelPlan, renewPlan } from '../../utils/payments'; + +let couponLoginContext: LoginContext; + +const today = new Date(); + +const cardInfo = Array.of('Card number', '•••• •••• •••• 1111', 'Expiry date', '03/2030', 'CVC / CVV', '******'); + +// This is written as a second test suite so that the login context is a different user. +// Otherwise there's no way to re-enter payment info and add a coupon code +Feature('payments-coupon').retry(3); + +Before(async ({ I }) => { + // This gets used in checkoutService.getOffer to make sure the offers are geolocated for NL + overrideIP(I); + I.useConfig('test--subscription'); +}); + +Scenario('I can redeem coupons', async ({ I }) => { + couponLoginContext = await I.registerOrLogin(couponLoginContext); + + goToCheckout(I); + + I.click('Redeem coupon'); + I.seeElement('input[name="couponCode"]'); + I.see('Apply'); + + I.click('div[aria-label="Close coupon form"]'); + I.dontSee('Coupon code'); + + I.click('Redeem coupon'); + I.fillField('couponCode', 'test75'); + I.click('Apply'); + I.waitForLoaderDone(); + I.see('Your coupon code has been applied'); + I.see(formatPrice(-37.5)); + I.see(formatPrice(12.5)); + I.see(formatPrice(2.17)); + + I.fillField('couponCode', 'test100'); + I.click('Apply'); + I.waitForLoaderDone(); + I.dontSee(formatPrice(12.5)); + + finishAndCheckSubscription(I, addYear(today), today); +}); + +Scenario('I can cancel a free subscription', async ({ I }) => { + couponLoginContext = await I.registerOrLogin(couponLoginContext); + + cancelPlan(I, addYear(today)); +}); + +Scenario('I can renew a free subscription', async ({ I }) => { + couponLoginContext = await I.registerOrLogin(couponLoginContext); + + renewPlan(I, addYear(today)); +}); diff --git a/test-e2e/tests/payments/payments_subscription_test.ts b/test-e2e/tests/payments/payments_subscription_test.ts new file mode 100644 index 000000000..ae4eb3bc2 --- /dev/null +++ b/test-e2e/tests/payments/payments_subscription_test.ts @@ -0,0 +1,179 @@ +import { LoginContext } from '../../utils/password_utils'; +import constants from '../../utils/constants'; +import { overrideIP, goToCheckout, formatPrice, finishAndCheckSubscription, cancelPlan, renewPlan, addDays } from '../../utils/payments'; + +let paidLoginContext: LoginContext; + +const today = new Date(); + +const monthlyPrice = formatPrice(6.99); +const yearlyPrice = formatPrice(50); + +const monthlyLabel = `label[for="S970187168_NL"]`; +const yearlyLabel = `label[for="S467691538_NL"]`; + +const cardInfo = Array.of('Card number', '•••• •••• •••• 1111', 'Expiry date', '03/2030', 'CVC / CVV', '******'); + +Feature('payments').retry(3); + +Before(async ({ I }) => { + // This gets used in checkoutService.getOffer to make sure the offers are geolocated for NL + overrideIP(I); + I.useConfig('test--subscription'); +}); + +Scenario('I can see my payments data', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + await I.openMainMenu(); + + I.click('Payments'); + I.see('Subscription details'); + I.see('You have no subscription. Complete your subscription to start watching all movies and series.'); + I.see('Complete subscription'); + + I.see('Payment method'); + I.see('No payment methods'); + + I.see('Transactions'); + I.see('No transactions'); +}); + +Scenario('I can see offered subscriptions', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + I.amOnPage(constants.paymentsUrl); + + I.click('Complete subscription'); + I.see('Choose plan'); + I.see('Watch this on Blender'); + + await within(monthlyLabel, () => { + I.see('Monthly'); + I.see('First month free'); + I.see('Cancel anytime'); + I.see('Watch on all devices'); + I.see(monthlyPrice); + I.see('/month'); + }); + + await within(yearlyLabel, () => { + I.see('Cancel anytime'); + I.see('Watch on all devices'); + I.see(yearlyPrice); + I.see('/year'); + }); + + I.see('Continue'); +}); + +Scenario('I can choose an offer', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + I.amOnPage(constants.offersUrl); + + I.click(monthlyLabel); + I.seeCssPropertiesOnElements(monthlyLabel, { color: '#000000' }); + I.seeCssPropertiesOnElements(yearlyLabel, { color: '#ffffff' }); + + I.click(yearlyLabel); + I.seeCssPropertiesOnElements(monthlyLabel, { color: '#ffffff' }); + I.seeCssPropertiesOnElements(yearlyLabel, { color: '#000000' }); + + I.click('Continue'); + I.waitForLoaderDone(); + + I.see('Yearly subscription'); + I.see(yearlyPrice); + I.see('/year'); + + I.see('Redeem coupon'); + I.see(formatPrice(50)); + I.see('Payment method fee'); + I.see(formatPrice(0)); + I.see('Total'); + I.see('Applicable tax (21%)'); + I.clickCloseButton(); +}); + +Scenario('I can see payment types', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + goToCheckout(I); + + I.see('Credit Card'); + I.see('PayPal'); + + I.see('Card number'); + I.see('Expiry date'); + I.see('CVC / CVV'); + I.see('Continue'); + I.dontSee("Clicking 'continue' will bring you to the PayPal site."); + + I.click('PayPal'); + + I.see("Clicking 'continue' will bring you to the PayPal site."); + I.dontSee('Card number'); + I.dontSee('Expiry date'); + I.dontSee('CVC / CVV'); + I.see('Continue'); +}); + +Scenario('I can open the PayPal site', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + goToCheckout(I); + + I.click('PayPal'); + I.click('Continue'); + + I.waitInUrl('paypal.com', 15); + // I'm sorry, I don't know why, but this test ends in a way that causes the next test to fail + I.amOnPage(constants.baseUrl); +}); + +Scenario('I can finish my subscription', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + goToCheckout(I); + + I.see('Credit Card'); + + // Adyen credit card form is loaded asynchronously, so wait for it + I.waitForElement('[class*=adyen-checkout__field--cardNumber]', 5); + + // Each of the 3 credit card fields is a separate iframe + I.switchTo('[class*="adyen-checkout__field--cardNumber"] iframe'); + I.fillField('Card number', '5555444433331111'); + // @ts-expect-error + I.switchTo(null); // Exit the iframe context back to the main document + + I.switchTo('[class*="adyen-checkout__field--expiryDate"] iframe'); + I.fillField('Expiry date', '03/30'); + // @ts-expect-error + I.switchTo(null); // Exit the iframe context back to the main document + + I.switchTo('[class*="adyen-checkout__field--securityCode"] iframe'); + I.fillField('Security code', '737'); + // @ts-expect-error + I.switchTo(null); // Exit the iframe context back to the main document + + finishAndCheckSubscription(I, addDays(today, 365), today); + + I.seeAll(cardInfo); +}); + +Scenario('I can cancel my subscription', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + cancelPlan(I, addDays(today, 365)); + + // Still see payment info + I.seeAll(cardInfo); +}); + +Scenario('I can renew my subscription', async ({ I }) => { + paidLoginContext = await I.registerOrLogin(paidLoginContext); + + renewPlan(I, addDays(today, 365)); +}); diff --git a/test-e2e/tests/payments_test.ts b/test-e2e/tests/payments_test.ts deleted file mode 100644 index 65f084fec..000000000 --- a/test-e2e/tests/payments_test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import {LoginContext} from "../utils/password_utils"; -import constants from "../utils/constants"; - -let paidLoginContext: LoginContext; -let couponLoginContext: LoginContext; - -const today = new Date(); - -const monthlyPrice = formatPrice(6.99); -const yearlyPrice = formatPrice(50); - -const cardInfo = Array.of( - 'Card number', - '•••• •••• •••• 1111', - 'Expiry date', - '03/2030', - 'CVC / CVV', - '******' -); - -Feature('payments'); - -Before(async ({I}) => { - // This gets used in checkoutService.getOffer to make sure the offers are geolocated for NL - overrideIP(I); - - I.useConfig('test--subscription'); - paidLoginContext = await I.registerOrLogin(paidLoginContext); -}); - -Scenario('I can see my payments data', async ({ I }) => { - await I.openMainMenu(); - - I.click('Payments'); - I.see('Subscription details'); - I.see('You have no subscription. Complete your subscription to start watching all movies and series.'); - I.see('Complete subscription'); - - I.see('Payment method'); - I.see('No payment methods'); - - I.see('Transactions'); - I.see('No transactions'); -}); - -Scenario('I can see offered subscriptions', async ({ I })=> { - I.amOnPage(constants.paymentsUrl); - - I.click('Complete subscription'); - I.see('Subscription'); - I.see('All movies and series of Blender'); - - await within('label[for="monthly"]', () => { - I.see('Monthly'); - I.see('First month free'); - I.see('Cancel anytime'); - I.see('Watch on all devices'); - I.see(monthlyPrice); - I.see('/month'); - }); - - await within('label[for="yearly"]', () => { - I.see('Yearly'); - I.see('First 14 days free'); - I.see('Cancel anytime'); - I.see('Watch on all devices'); - I.see(yearlyPrice); - I.see('/year'); - }); - - I.see('Continue'); -}); - -Scenario('I can choose an offer', ({ I }) => { - I.amOnPage(constants.offersUrl); - - I.click('label[for="monthly"]'); - I.seeCssPropertiesOnElements('label[for="monthly"]', { 'color': '#000000' }); - I.seeCssPropertiesOnElements('label[for="yearly"]', { 'color': '#ffffff' }); - - I.click('label[for="yearly"]'); - I.seeCssPropertiesOnElements('label[for="monthly"]', { 'color': '#ffffff' }); - I.seeCssPropertiesOnElements('label[for="yearly"]', { 'color': '#000000' }); - - I.click('Continue'); - I.waitForLoaderDone(); - - I.see('Yearly subscription'); - I.see('You will be charged after 14 days.'); - I.see(yearlyPrice); - I.see('/year'); - - I.see('Redeem coupon'); - I.see('Free trial'); - I.see(formatPrice(-50)); - I.see('Payment method fee'); - I.see(formatPrice(0)); - I.see('Total'); - I.see('Applicable tax (21%)'); - I.clickCloseButton(); -}); - -Scenario('I can see payment types', ({I}) => { - goToCheckout(I); - - I.see('Credit Card'); - I.see('PayPal'); - - I.see('Card number'); - I.see('Expiry date'); - I.see('CVC / CVV'); - I.see('Continue'); - I.dontSee('Clicking \'continue\' will bring you to the PayPal site.'); - - I.click('PayPal'); - - I.see('Clicking \'continue\' will bring you to the PayPal site.'); - I.dontSee('Card number'); - I.dontSee('Expiry date'); - I.dontSee('CVC / CVV'); - I.see('Continue'); -}); - -Scenario('I can open the PayPal site', ({ I })=> { - goToCheckout(I); - - I.click('PayPal'); - I.click('Continue'); - - I.waitInUrl('paypal.com', 15); - // I'm sorry, I don't know why, but this test ends in a way that causes the next test to fail - I.amOnPage(constants.baseUrl); -}); - -Scenario('I can finish my subscription', ({ I }) => { - goToCheckout(I); - - I.see('Credit Card'); - - // Adyen credit card form is loaded asynchronously, so wait for it - I.waitForElement('[class*=adyen-checkout__field--cardNumber]', 5); - - // Each of the 3 credit card fields is a separate iframe - I.switchTo('[class*="adyen-checkout__field--cardNumber"] iframe'); - I.fillField('Card number', '5555444433331111'); - I.switchTo(null); // Exit the iframe context back to the main document - - I.switchTo('[class*="adyen-checkout__field--expiryDate"] iframe'); - I.fillField('Expiry date', '03/30'); - I.switchTo(null); // Exit the iframe context back to the main document - - I.switchTo('[class*="adyen-checkout__field--securityCode"] iframe'); - I.fillField('Security code', '737'); - I.switchTo(null); // Exit the iframe context back to the main document - - finishAndCheckSubscription(I, addDays(today, 14)); - - I.seeAll(cardInfo); -}); - -Scenario('I can cancel my subscription', ({ I }) => { - cancelPlan(I, addDays(today, 14)); - - // Still see payment info - I.seeAll(cardInfo); -}); - -Scenario('I can renew my subscription', ({ I })=> { - renewPlan(I, addDays(today, 14)); -}); - -// This is written as a second test suite so that the login context is a different user. -// Otherwise there's no way to re-enter payment info and add a coupon code -Feature('payments-coupon'); - -Before(async ({I}) => { - // This gets used in checkoutService.getOffer to make sure the offers are geolocated for NL - overrideIP(I); - - I.useConfig('test--subscription'); - - couponLoginContext = await I.registerOrLogin(couponLoginContext); -}); - - -Scenario('I can redeem coupons', async ({I}) => { - goToCheckout(I); - - I.click('Redeem coupon'); - I.seeElement('input[name="couponCode"]'); - I.see('Apply'); - - I.click('div[aria-label="Close coupon form"]'); - I.dontSee('Coupon code'); - - I.click('Redeem coupon'); - I.fillField('couponCode', 'test75'); - I.click('Apply'); - I.waitForLoaderDone(); - I.see('Your coupon code has been applied'); - I.see(formatPrice(-37.50)); - I.see(formatPrice(12.50)); - I.see(formatPrice(2.17)); - - I.fillField('couponCode', 'test100'); - I.click('Apply'); - I.waitForLoaderDone(); - I.see('No payment needed') - I.dontSee(formatPrice(12.50)); - - finishAndCheckSubscription(I, addYear(today)); - - I.see('No payment methods'); -}); - -Scenario('I can cancel a free subscription', async ({I}) => { - cancelPlan(I, addYear(today)); - - I.see('No payment methods'); -}); - -Scenario('I can renew a free subscription', ({I}) => { - renewPlan(I, addYear(today)); -}); - -function goToCheckout(I: CodeceptJS.I) { - I.amOnPage(constants.offersUrl); - - I.click('Continue'); - I.waitForLoaderDone(); -} - -function formatPrice(price: number) { - // eslint-disable-next-line no-irregular-whitespace - return `€ ${price.toFixed(2).replace('.', ',')}`; -} - -function addDays(date: Date, days: number) { - const newDate = new Date(date.valueOf()) - newDate.setDate(newDate.getDate() + days); - return newDate; -} - -function addYear(date: Date) { - const newDate = new Date(date.valueOf()) - newDate.setFullYear(newDate.getFullYear() + 1); - return newDate; -} - -function formatDate(date: Date) { - return date.toLocaleDateString('en-US'); -} - -function finishAndCheckSubscription(I: CodeceptJS.I, billingDate: Date) { - I.click('Continue'); - I.waitForLoaderDone(15); - I.see('Welcome to Blender'); - I.see('Thank you for subscribing to Blender. Please enjoy all our content.'); - - I.click('Start watching'); - - I.waitForLoaderDone(); - I.see('Annual subscription'); - I.see(yearlyPrice); - I.see('/year'); - I.see('Next billing date is on ' + formatDate(billingDate)); - I.see('Cancel subscription'); - - I.see('Annual subscription (recurring) to JW Player'); - I.see(formatPrice(0) + ' payed with card'); - I.see(formatDate(today)); -} - -function cancelPlan(I: CodeceptJS.I, expirationDate: Date) { - I.amOnPage(constants.paymentsUrl); - I.click('Cancel subscription'); - I.see('We are sorry to see you go.'); - I.see('You will be unsubscribed from your current plan by clicking the unsubscribe button below.'); - I.see('Unsubscribe'); - // Make sure the cancel button works - I.click('No, thanks'); - - I.dontSee('This plan will expire'); - - I.click('Cancel subscription'); - I.click('Unsubscribe'); - I.waitForLoaderDone(10); - I.see('Miss you already.'); - I.see('You have been successfully unsubscribed. Your current plan will expire on ' + formatDate(expirationDate)); - I.click('Return to profile'); - I.see('Renew subscription'); - I.see('This plan will expire on ' + formatDate(expirationDate)); -} - -function renewPlan(I: CodeceptJS.I, billingDate: Date) { - I.amOnPage(constants.paymentsUrl); - I.click('Renew subscription'); - I.see('Renew your subscription'); - I.see('By clicking the button below you can renew your plan.'); - I.see('Annual subscription (recurring) to JW Player'); - I.see('Next billing date will be ' + formatDate(billingDate)); - I.see(yearlyPrice); - I.see('/year'); - - I.click('No, thanks'); - I.see('Renew subscription'); - - I.click('Renew subscription'); - - I.click('Renew subscription', '[class*=_dialog]'); - I.waitForLoaderDone(10); - I.see('Your subscription has been renewed'); - I.see(`You have been successfully resubscribed. Your fee will be ${yearlyPrice} starting from ${formatDate(billingDate)}`) - I.click('Back to profile'); - - I.see('Annual subscription'); - I.see('Next billing date is on'); - I.see('Cancel subscription'); -} - -function overrideIP(I: CodeceptJS.I) { - // Set this as a cookie so it persists between page navigations (local storage would also work, but the permissions don't work) - I.setCookie({name: 'overrideIP', value: '101.33.29.0', domain: 'localhost', path: '/'}) -} \ No newline at end of file diff --git a/test-e2e/tests/playlist_test.ts b/test-e2e/tests/playlist/index_test.ts similarity index 73% rename from test-e2e/tests/playlist_test.ts rename to test-e2e/tests/playlist/index_test.ts index 634cbf8a4..60dec887a 100644 --- a/test-e2e/tests/playlist_test.ts +++ b/test-e2e/tests/playlist/index_test.ts @@ -1,14 +1,14 @@ -import constants from "../utils/constants"; +import constants from '../../utils/constants'; -Feature('playlist'); +Feature('playlist').retry(3); -Before(({I}) => { +Before(({ I }) => { I.amOnPage(constants.filmsPlaylistUrl); I.seeAll(actionFilms); I.seeAll(comedyFilms); I.seeAll(dramaFilms); -}) +}); const allFilters = ['Action', 'Fantasy', 'Comedy', 'Drama', 'All']; const actionFilms = ['Agent 327', 'Coffee Run', 'Tears of Steel']; @@ -16,11 +16,11 @@ const comedyFilms = ['Big Buck Bunny', 'Caminandes 1: Llama Drama', 'Caminandes const dramaFilms = ['Elephants Dream', 'Glass Half']; Scenario('Playlist screen loads', async ({ I }) => { - await checkSelectedFilterButton(I,'All'); + await checkSelectedFilterButton(I, 'All'); }); Scenario('I can change the filter to "action"', async ({ I }) => { - await checkSelectedFilterButton(I,'All'); + await checkSelectedFilterButton(I, 'All'); await selectFilterAndCheck(I, 'Action'); @@ -52,7 +52,7 @@ Scenario('I can filter and click on a card and navigate to the video screen', as await selectFilterAndCheck(I, 'Comedy'); I.click({ css: 'div[aria-label="Play Big Buck Bunny"]' }); - I.seeInCurrentUrl(constants.bigBuckBunnyDetailUrl) + I.seeInCurrentUrl(constants.bigBuckBunnyDetailUrl); }); async function selectFilterAndCheck(I: CodeceptJS.I, option) { @@ -68,16 +68,20 @@ async function selectFilterAndCheck(I: CodeceptJS.I, option) { async function checkSelectedFilterButton(I: CodeceptJS.I, expectedButton) { if (await I.isMobile()) { I.see(expectedButton); - I.waitForAllInvisible(allFilters.filter(f => f !== expectedButton), 0); + I.waitForAllInvisible( + allFilters.filter((f) => f !== expectedButton), + 0, + ); } else { I.seeAll(allFilters); I.see(expectedButton, 'div[class*=filterRow] button[class*=active]'); // Check that the 'All' button is visually active - I.seeCssPropertiesOnElements({xpath: `//button[contains(., "${expectedButton}")]`}, - {color: 'rgb(0, 0, 0)', 'background-color': 'rgb(255, 255, 255)'}); + I.seeCssPropertiesOnElements({ xpath: `//button[contains(., "${expectedButton}")]` }, { color: 'rgb(0, 0, 0)', 'background-color': 'rgb(255, 255, 255)' }); // Check that the other filter buttons are not visually active - I.seeCssPropertiesOnElements({xpath: `//div[contains(@class, "filterRow")]/button[not(contains(., "${expectedButton}"))]`}, - {color: 'rgb(255, 255, 255)', 'background-color': 'rgba(0, 0, 0, 0.6)'}); + I.seeCssPropertiesOnElements( + { xpath: `//div[contains(@class, "filterRow")]/button[not(contains(., "${expectedButton}"))]` }, + { color: 'rgb(255, 255, 255)', 'background-color': 'rgba(0, 0, 0, 0.6)' }, + ); } } diff --git a/test-e2e/tests/register_test.ts b/test-e2e/tests/register/index_test.ts similarity index 84% rename from test-e2e/tests/register_test.ts rename to test-e2e/tests/register/index_test.ts index 8c31a3160..bf8223811 100644 --- a/test-e2e/tests/register_test.ts +++ b/test-e2e/tests/register/index_test.ts @@ -1,10 +1,14 @@ -import constants from '../utils/constants'; -import passwordUtils from "../utils/password_utils"; +import constants from '../../utils/constants'; +import passwordUtils from '../../utils/password_utils'; -Feature('register'); +Feature('register').retry(3); + +Before(async ({ I }) => { + I.useConfig('test--accounts', constants.registerUrl); +}); Scenario('I can open the register modal', async ({ I }) => { - I.useConfig('test--accounts'); + I.amOnPage(constants.baseUrl); I.seeCurrentUrlEquals(constants.baseUrl); if (await I.isMobile()) { @@ -30,12 +34,6 @@ Scenario('I can open the register modal', async ({ I }) => { I.seeElement(constants.registrationFormSelector); }); -Feature('register'); - -Before(async ({I}) => { - I.useConfig('test--accounts', constants.registerUrl); -}); - Scenario('I can close the modal', async ({ I }) => { I.clickCloseButton(); I.dontSeeElement(constants.registrationFormSelector); @@ -57,12 +55,12 @@ Scenario('I can switch to the Sign In modal', ({ I }) => { I.dontSee(constants.registrationFormSelector); I.click('Sign up', constants.loginFormSelector); I.seeElement(constants.registrationFormSelector); - I.see('Already have an account?') + I.see('Already have an account?'); I.dontSeeElement(constants.loginFormSelector); }); Scenario('The submit button is disabled when the form is incompletely filled in', async ({ I }) => { - I.seeAttributesOnElements('button[type="submit"]', {disabled: true}); + I.seeAttributesOnElements('button[type="submit"]', { disabled: true }); }); Scenario('I get warned when filling in incorrect credentials', async ({ I }) => { @@ -73,8 +71,7 @@ Scenario('I get warned when filling in incorrect credentials', async ({ I }) => I.dontSee('Please re-enter your email details'); function checkColor(expectedColor) { - I.seeCssPropertiesOnElements('text="Use a minimum of 8 characters (case sensitive) with at least one number"', - {color: expectedColor}); + I.seeCssPropertiesOnElements('text="Use a minimum of 8 characters (case sensitive) with at least one number"', { color: expectedColor }); } checkColor('rgb(255, 255, 255)'); @@ -92,13 +89,12 @@ Scenario('I get strength feedback when typing in a password', async ({ I }) => { function checkFeedback(password, expectedColor, expectedText) { I.fillField('password', password); - I.seeCssPropertiesOnElements('div[class*="passwordStrengthFill"]', - {'background-color': expectedColor}); + I.seeCssPropertiesOnElements('div[class*="passwordStrengthFill"]', { 'background-color': expectedColor }); I.see(expectedText); - I.seeCssPropertiesOnElements(`text="${expectedText}"`, {color: expectedColor}); + I.seeCssPropertiesOnElements(`text="${expectedText}"`, { color: expectedColor }); - textOptions.filter(opt => opt !== expectedText).forEach(opt => I.dontSee(opt)); + textOptions.filter((opt) => opt !== expectedText).forEach((opt) => I.dontSee(opt)); } checkFeedback('1111aaaa', 'orangered', 'Weak'); @@ -109,15 +105,15 @@ Scenario('I get strength feedback when typing in a password', async ({ I }) => { Scenario('I can toggle to view password', async ({ I }) => { await passwordUtils.testPasswordToggling(I); -}) +}); -Scenario('I can\'t submit without checking required consents', async ({ I }) => { +Scenario('I can`t submit without checking required consents', async ({ I }) => { I.fillField('Email', 'test@123.org'); I.fillField('Password', 'pAssword123!'); I.click('Continue'); - I.seeCssPropertiesOnElements('input[name="terms"]', { 'border-color': '#ff0c3e'}); + I.seeCssPropertiesOnElements('input[name="terms"]', { 'border-color': '#ff0c3e' }); }); Scenario('I get warned for duplicate users', ({ I }) => { @@ -146,4 +142,4 @@ Scenario('I can register', async ({ I }) => { I.waitForLoaderDone(10); I.see('Welcome to Blender'); -}); \ No newline at end of file +}); diff --git a/test-e2e/tests/search_test.ts b/test-e2e/tests/search/index_test.ts similarity index 93% rename from test-e2e/tests/search_test.ts rename to test-e2e/tests/search/index_test.ts index 704318e69..71bfd313c 100644 --- a/test-e2e/tests/search_test.ts +++ b/test-e2e/tests/search/index_test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; -import constants from "../utils/constants"; +import constants from '../../utils/constants'; const openSearchLocator = { css: 'div[aria-label="Open search"]' }; const searchBarLocator = { css: 'input[aria-label="Search"]' }; @@ -8,9 +8,9 @@ const emptySearchPrompt = 'Type something in the search box to start searching'; const clearSearchLocator = { css: 'div[aria-label="Clear search"]' }; const closeSearchLocator = { css: 'div[aria-label="Close search"]' }; -Feature('search'); +Feature('search').retry(3); -Before(({I}) => { +Before(({ I }) => { I.amOnPage('http://localhost:8080'); verifyOnHomePage(I); }); @@ -91,11 +91,11 @@ Scenario('I get empty search results when no videos match', async ({ I }) => { I.dontSee(emptySearchPrompt); }); -Scenario('The search URL is encoded', async({ I }) => { +Scenario('The search URL is encoded', async ({ I }) => { await openSearch(I); I.fillField(searchBarLocator, 'Hello/World! How are you? 這是中國人'); - I.seeCurrentUrlEquals('http://localhost:8080/q/Hello%2FWorld!%20How%20are%20you%3F%20%E9%80%99%E6%98%AF%E4%B8%AD%E5%9C%8B%E4%BA%BA') + I.seeCurrentUrlEquals('http://localhost:8080/q/Hello%2FWorld!%20How%20are%20you%3F%20%E9%80%99%E6%98%AF%E4%B8%AD%E5%9C%8B%E4%BA%BA'); }); Scenario('I can clear the search phrase with the clear button', async ({ I }) => { @@ -114,9 +114,9 @@ Scenario('I can clear the search phrase with the clear button', async ({ I }) => Scenario('I can clear the search phrase manually', async ({ I }) => { await openSearch(I); I.fillField(searchBarLocator, 'Hello'); - I.seeElement(clearSearchLocator); + I.click(searchBarLocator); - I.pressKey(['Control', 'a']); + I.pressKey(['CommandOrControl', 'A']); I.pressKey('Backspace'); I.see(emptySearchPrompt); @@ -132,8 +132,7 @@ function checkSearchResults(I: CodeceptJS.I, expectedResults: string[]) { I.see('Search results'); I.dontSee(emptySearchPrompt); I.dontSee('No results found'); - expectedResults.forEach(result => I.see(result)); - + expectedResults.forEach((result) => I.see(result)); } else { I.dontSee('Search results'); I.dontSeeElement('div[class*="cell"]'); @@ -153,7 +152,7 @@ function verifyOnHomePage(I: CodeceptJS.I) { async function openSearch(I: CodeceptJS.I) { if (await I.isMobile()) { - I.dontSee(searchBarLocator); + I.dontSeeElement(searchBarLocator); I.click(openSearchLocator); } diff --git a/test-e2e/tests/seo_test.ts b/test-e2e/tests/seo/index_test.ts similarity index 81% rename from test-e2e/tests/seo_test.ts rename to test-e2e/tests/seo/index_test.ts index 71fdb8f90..3028fa6ba 100644 --- a/test-e2e/tests/seo_test.ts +++ b/test-e2e/tests/seo/index_test.ts @@ -1,10 +1,10 @@ -import constants from "../utils/constants"; +import constants from '../../utils/constants'; const agent327PosterUrl = 'http://content.jwplatform.com/v2/media/uB8aRnu6/poster.jpg?width=720'; -const primitiveAnimalsDescription = 'If you\'re brand new to Blender or are getting stuck, check out the Blender 2.8 Fundamentals series.'; +const primitiveAnimalsDescription = "If you're brand new to Blender or are getting stuck, check out the Blender 2.8 Fundamentals series."; const primitiveAnimalsPosterUrl = 'http://content.jwplatform.com/v2/media/zKT3MFut/poster.jpg?width=720'; -Feature('seo'); +Feature('seo').retry(3); Scenario('It renders the correct meta tags for the home screen', ({ I }) => { I.amOnPage(constants.baseUrl); @@ -60,16 +60,19 @@ Scenario('It renders the correct meta tags for the movie screen', ({ I }) => { Scenario('It renders the correct structured metadata for the movie screen', ({ I }) => { I.amOnPage(removeQueryString(constants.agent327DetailUrl)); - I.seeTextEquals(JSON.stringify({ - "@context": "http://schema.org/", - "@type": "VideoObject", - "@id": removeQueryString(constants.agent327DetailUrl), - name: "Agent 327", - description: constants.agent327Description, - duration: "PT3M51S", - thumbnailUrl: makeHttps(agent327PosterUrl), - uploadDate: "2021-01-16T20:15:00.000Z" - }), { css: 'script[type="application/ld+json"]' }); + I.seeTextEquals( + JSON.stringify({ + '@context': 'http://schema.org/', + '@type': 'VideoObject', + '@id': removeQueryString(constants.agent327DetailUrl), + name: 'Agent 327', + description: constants.agent327Description, + duration: 'PT3M51S', + thumbnailUrl: makeHttps(agent327PosterUrl), + uploadDate: '2021-01-16T20:15:00.000Z', + }), + { css: 'script[type="application/ld+json"]' }, + ); }); Scenario('It renders the correct meta tags for the series screen', ({ I }) => { @@ -100,22 +103,25 @@ Scenario('It renders the correct meta tags for the series screen', ({ I }) => { Scenario('It renders the correct structured metadata for the series screen', ({ I }) => { I.amOnPage(constants.primitiveAnimalsDetailUrl); - I.seeTextEquals(JSON.stringify({ - "@context": "http://schema.org/", - "@type": "TVEpisode", - "@id": constants.primitiveAnimalsDetailUrl, - episodeNumber: "1", - seasonNumber: "1", - name: "Blocking", - uploadDate: "2021-03-10T10:00:00.000Z", - partOfSeries: { - "@type": "TVSeries", - "@id": removeQueryString(constants.primitiveAnimalsDetailUrl), - name: "Primitive Animals", - numberOfEpisodes: 4, - numberOfSeasons: 1 - } - }), { css: 'script[type="application/ld+json"]' }); + I.seeTextEquals( + JSON.stringify({ + '@context': 'http://schema.org/', + '@type': 'TVEpisode', + '@id': constants.primitiveAnimalsDetailUrl, + episodeNumber: '1', + seasonNumber: '1', + name: 'Blocking', + uploadDate: '2021-03-10T10:00:00.000Z', + partOfSeries: { + '@type': 'TVSeries', + '@id': removeQueryString(constants.primitiveAnimalsDetailUrl), + name: 'Primitive Animals', + numberOfEpisodes: 4, + numberOfSeasons: 1, + }, + }), + { css: 'script[type="application/ld+json"]' }, + ); }); function removeQueryString(href: string) { @@ -128,4 +134,4 @@ function makeHttps(href: string) { const url = new URL(href); url.protocol = 'https'; return url.toString(); -} \ No newline at end of file +} diff --git a/test-e2e/tests/series_test.ts b/test-e2e/tests/series/index_test.ts similarity index 79% rename from test-e2e/tests/series_test.ts rename to test-e2e/tests/series/index_test.ts index 1060f6fb6..7a6c789a7 100644 --- a/test-e2e/tests/series_test.ts +++ b/test-e2e/tests/series/index_test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; -Feature('series'); +Feature('series').retry(3); Scenario('I can see series', ({ I }) => { I.amOnPage('http://localhost:8080/s/L24UEeMK/fantasy-vehicle-creation?e=I3k8wgIs&c=test--no-cleeng'); @@ -10,18 +10,18 @@ Scenario('I can see series', ({ I }) => { I.see('5 episodes'); I.see('Advanced'); I.see('CC-BY'); - I.see('Let\'s get started with the Art of Blocking!'); + I.see("Let's get started with the Art of Blocking!"); I.see('Start watching'); I.see('Favorite'); I.see('Share'); I.seeTextEquals('Episodes', 'h3'); - I.see('Current episode', { css: 'div[aria-label="Play Blocking"]'}); + I.see('Current episode', { css: 'div[aria-label="Play Blocking"]' }); I.see('Concept Art'); - I.see('S1:E2', { css: 'div[aria-label="Play Concept Art"]'}) - I.see('S1:E3', { css: 'div[aria-label="Play Modeling Part 1"]'}) - I.see('S1:E4', { css: 'div[aria-label="Play Modeling Part 2"]'}) - I.see('S1:E5', { css: 'div[aria-label="Play Texturing and Lighting"]'}) -}) + I.see('S1:E2', { css: 'div[aria-label="Play Concept Art"]' }); + I.see('S1:E3', { css: 'div[aria-label="Play Modeling Part 1"]' }); + I.see('S1:E4', { css: 'div[aria-label="Play Modeling Part 2"]' }); + I.see('S1:E5', { css: 'div[aria-label="Play Texturing and Lighting"]' }); +}); Scenario('I can play other episodes from the series', async ({ I }) => { I.amOnPage('http://localhost:8080/s/L24UEeMK/fantasy-vehicle-creation?e=I3k8wgIs&c=test--no-cleeng'); @@ -33,7 +33,9 @@ Scenario('I can play other episodes from the series', async ({ I }) => { I.wait(2); assert.strictEqual((await I.grabPageScrollPosition()).y, 0); I.see('S1:E3'); - I.see('Finally we are creating the high-res model for our scene! In this chapter we will go over a few basic modeling techniques as well as the first part of the production timelapse.'); + I.see( + 'Finally we are creating the high-res model for our scene! In this chapter we will go over a few basic modeling techniques as well as the first part of the production timelapse.', + ); I.scrollTo('text="Texturing and Lighting"'); I.click('div[aria-label="Play Texturing and Lighting"]'); @@ -42,4 +44,4 @@ Scenario('I can play other episodes from the series', async ({ I }) => { I.wait(2); assert.strictEqual((await I.grabPageScrollPosition()).y, 0); I.see('Placing the lights and creating the environment then finishes up this workshop!'); -}); \ No newline at end of file +}); diff --git a/test-e2e/tests/video_detail_test.ts b/test-e2e/tests/video_detail/index_test.ts similarity index 86% rename from test-e2e/tests/video_detail_test.ts rename to test-e2e/tests/video_detail/index_test.ts index 51a2e1e9e..d53e20f54 100644 --- a/test-e2e/tests/video_detail_test.ts +++ b/test-e2e/tests/video_detail/index_test.ts @@ -1,8 +1,8 @@ -import * as assert from "assert"; +import * as assert from 'assert'; -import constants from '../utils/constants'; +import constants from '../../utils/constants'; -Feature('video_detail'); +Feature('video_detail').retry(3); Scenario('Video detail screen loads', ({ I }) => { I.amOnPage(constants.baseUrl); @@ -16,9 +16,8 @@ Scenario('Video detail screen loads', ({ I }) => { I.see('Sign up to start watching!'); I.see('Favorite'); I.see('Share'); - I.see('Current video', { css: 'div[aria-label="Play Agent 327"]'}); I.see('Elephants Dream'); - I.see('11 min', { css: 'div[aria-label="Play Elephants Dream"]'}) + I.see('11 min', { css: 'div[aria-label="Play Elephants Dream"]' }); }); Scenario('I can expand the description (@mobile-only)', ({ I }) => { @@ -30,7 +29,7 @@ Scenario('I can expand the description (@mobile-only)', ({ I }) => { // and sometimes codecept goes too fast and catches it before it's done animating I.wait(1); - I.seeCssPropertiesOnElements(`text="${constants.agent327Description}"`, {'max-height': height}); + I.seeCssPropertiesOnElements(`text="${constants.agent327Description}"`, { 'max-height': height }); } I.seeElement('div[aria-label="Expand"]'); @@ -48,7 +47,7 @@ Scenario('I can expand the description (@mobile-only)', ({ I }) => { I.seeElement('div[aria-label="Expand"]'); I.dontSeeElement('div[aria-label="Collapse"]'); checkHeight('60px'); -}) +}); Scenario('I can watch a video', async ({ I }) => await playBigBuckBunny(I)); @@ -65,8 +64,10 @@ Scenario('I can play other media from the related shelf', ({ I }) => { I.useConfig('test--no-cleeng'); openVideo(I, 'Agent 327'); openVideo(I, 'Elephants Dream'); - I.see('Elephants Dream (code-named Project Orange during production and originally titled Machina) is a 2006 Dutch computer animated science fiction fantasy experimental short film produced by Blender Foundation using, almost exclusively, free and open-source software. The film is English-language and includes subtitles in over 30 languages.') - openVideo(I,'Coffee Run'); + I.see( + 'Elephants Dream (code-named Project Orange during production and originally titled Machina) is a 2006 Dutch computer animated science fiction fantasy experimental short film produced by Blender Foundation using, almost exclusively, free and open-source software. The film is English-language and includes subtitles in over 30 languages.', + ); + openVideo(I, 'Coffee Run'); I.see('Coffee Run was directed by Hjalti Hjalmarsson and produced by the team at Blender Animation Studio.'); }); @@ -143,8 +144,8 @@ Scenario('I can share the media', async ({ I }) => { }); function openVideo(I, name) { - I.scrollTo({ css: `div[aria-label="Play ${name}"]`}); - I.click({ css: `div[aria-label="Play ${name}"]`}); + I.scrollTo({ css: `div[aria-label="Play ${name}"]` }); + I.click({ css: `div[aria-label="Play ${name}"]` }); } async function playBigBuckBunny(I) { @@ -159,4 +160,4 @@ async function playBigBuckBunny(I) { I.waitForElement('video', 5); await I.waitForPlayerPlaying('Big Buck Bunny'); -} \ No newline at end of file +} diff --git a/test-e2e/tests/watch_history/local_test.ts b/test-e2e/tests/watch_history/local_test.ts new file mode 100644 index 000000000..03ea67d88 --- /dev/null +++ b/test-e2e/tests/watch_history/local_test.ts @@ -0,0 +1,73 @@ +import { playVideo, checkProgress, checkElapsed } from '../../utils/watch_history'; +import constants from '../../utils/constants'; + +const videoLength = 231; + +Feature('watch_history - local').retry(3); + +Before(({ I }) => { + I.useConfig('test--no-cleeng'); +}); + +Scenario('I can get my watch progress stored (locally)', async ({ I }) => { + I.amOnPage(constants.agent327DetailUrl); + I.dontSee('Continue watching'); + + await playVideo(I, 100); + + I.see('Continue watching'); +}); + +Scenario('I can continue watching', async ({ I }) => { + I.amOnPage(constants.agent327DetailUrl); + await playVideo(I, 100); + I.click('Continue watching'); + await I.waitForPlayerPlaying('Agent 327'); + I.click('video'); + await checkElapsed(I, 1, 40); +}); + +Scenario('I can see my watch history on the Home screen', async ({ I }) => { + I.seeCurrentUrlEquals(constants.baseUrl); + I.dontSee('Continue watching'); + + await playVideo(I, 200); + I.amOnPage(constants.baseUrl); + + I.see('Continue watching'); + + await within('div[data-mediaid="continue_watching"]', async () => { + I.see('Agent 327'); + I.see('4 min'); + }); + + const xpath = '//*[@data-mediaid="continue_watching"]//*[@aria-label="Play Agent 327"]'; + await checkProgress(I, xpath, (200 / videoLength) * 100); + + I.click(xpath); + await I.waitForPlayerPlaying('Agent 327'); + I.click('video'); + + await checkElapsed(I, 3, 20); + I.seeInCurrentUrl('play=1'); +}); + +Scenario('Video removed from continue watching when finished', async ({ I }) => { + I.amOnPage(constants.agent327DetailUrl); + await playVideo(I, 100); + // Continue watching on video detail page + I.see('Continue watching'); + + // Continue watching on home page + I.amOnPage(constants.baseUrl); + I.see('Continue watching'); + + await playVideo(I, videoLength); + + I.see('Start watching'); + I.dontSee('Continue watching'); + + I.amOnPage(constants.baseUrl); + + I.dontSee('Continue watching'); +}); diff --git a/test-e2e/tests/watch_history/logged_in_test.ts b/test-e2e/tests/watch_history/logged_in_test.ts new file mode 100644 index 000000000..3baf44ebd --- /dev/null +++ b/test-e2e/tests/watch_history/logged_in_test.ts @@ -0,0 +1,67 @@ +import * as assert from 'assert'; + +import constants from '../../utils/constants'; +import { playVideo, checkProgress, checkElapsed } from '../../utils/watch_history'; + +const videoLength = 231; + +Feature('watch_history - logged in').retry(3); + +Before(({ I }) => { + I.useConfig('test--accounts'); +}); + +Scenario('I can get my watch history when logged in', async ({ I }) => { + I.login(); + await playVideo(I, 0); + I.see('Start watching'); + I.dontSee('Continue watching'); + + await playVideo(I, 80); + + I.see('Continue watching'); + I.dontSee('Start watching'); + await checkProgress(I, '//button[contains(., "Continue watching")]', (80 / videoLength) * 100, 5, '_progressRail_', '_progress_'); +}); + +Scenario('I can get my watch history stored to my account after login', async ({ I }) => { + I.amOnPage(constants.agent327DetailUrl); + I.dontSee('Continue watching'); + I.see('Sign up to start watching'); + + I.login(); + I.amOnPage(constants.agent327DetailUrl); + I.dontSee('Start watching'); + I.see('Continue watching'); + await checkProgress(I, '//button[contains(., "Continue watching")]', (80 / videoLength) * 100, 5, '_progressRail_', '_progress_'); + + I.click('Continue watching'); + await I.waitForPlayerPlaying('Agent 327'); + I.click('video'); + await checkElapsed(I, 1, 20); +}); + +Scenario('I can see my watch history on the Home screen when logged in', async ({ I }) => { + const xpath = '//*[@data-mediaid="continue_watching"]//*[@aria-label="Play Agent 327"]'; + + I.seeCurrentUrlEquals(constants.baseUrl); + I.dontSee('Continue watching'); + + I.login(); + I.see('Continue watching'); + + await within('div[data-mediaid="continue_watching"]', async () => { + I.see('Agent 327'); + I.see('4 min'); + }); + + await checkProgress(I, xpath, (80 / videoLength) * 100); + + // Automatic scroll leads to click problems for some reasons + I.scrollTo(xpath); + I.click(xpath); + await I.waitForPlayerPlaying('Agent 327'); + + await checkElapsed(I, 1, 20); + I.seeInCurrentUrl('play=1'); +}); diff --git a/test-e2e/tests/watch_history_test.ts b/test-e2e/tests/watch_history_test.ts deleted file mode 100644 index 675051500..000000000 --- a/test-e2e/tests/watch_history_test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import * as assert from 'assert'; - -import constants from '../utils/constants'; - -import LocatorOrString = CodeceptJS.LocatorOrString; - -const videoLength = 231; - -Feature('watch_history - local'); - -Before(({ I }) => { - I.useConfig('test--no-cleeng'); -}); - -Scenario('I can get my watch progress stored (locally)', async ({ I }) => { - I.amOnPage(constants.agent327DetailUrl); - I.dontSee('Continue watching'); - - await playVideo(I, 100); - - I.see('Continue watching'); -}); - -Scenario('I can continue watching', async ({ I }) => { - I.amOnPage(constants.agent327DetailUrl); - await playVideo(I, 100); - I.click('Continue watching'); - await I.waitForPlayerPlaying('Agent 327'); - I.click('video'); - await checkElapsed(I, 1, 40); -}); - -Scenario('I can see my watch history on the Home screen', async ({ I }) => { - I.seeCurrentUrlEquals(constants.baseUrl); - I.dontSee('Continue watching'); - - await playVideo(I, 200); - I.amOnPage(constants.baseUrl); - - I.see('Continue watching'); - - await within('div[data-mediaid="continue_watching"]', async () => { - I.see('Agent 327'); - I.see('4 min'); - }); - - const xpath = '//*[@data-mediaid="continue_watching"]//*[@aria-label="Play Agent 327"]'; - await checkProgress(I, xpath, (200 / videoLength) * 100); - - I.click(xpath); - await I.waitForPlayerPlaying('Agent 327'); - I.click('video'); - - await checkElapsed(I, 3, 20); - I.seeInCurrentUrl('play=1'); -}); - -Scenario('Video removed from continue watching when finished', async ({ I }) => { - I.amOnPage(constants.agent327DetailUrl); - await playVideo(I, 100); - // Continue watching on video detail page - I.see('Continue watching'); - - // Continue watching on home page - I.amOnPage(constants.baseUrl); - I.see('Continue watching'); - - await playVideo(I, videoLength); - - I.see('Start watching'); - I.dontSee('Continue watching'); - - I.amOnPage(constants.baseUrl); - - I.dontSee('Continue watching'); -}); - -Feature('watch_history - logged in'); - -Before(({ I }) => { - I.useConfig('test--accounts'); -}); - -Scenario('I can get my watch history when logged in', async ({ I }) => { - I.login(); - await playVideo(I, 0); - I.see('Start watching'); - I.dontSee('Continue watching'); - - await playVideo(I, 80); - - I.see('Continue watching'); - I.dontSee('Start watching'); - await checkProgress(I, '//button[contains(., "Continue watching")]', (80 / videoLength) * 100, 5, '_progressRail_', '_progress_'); -}); - -Scenario('I can get my watch history stored to my account after login', async ({ I }) => { - I.amOnPage(constants.agent327DetailUrl); - I.dontSee('Continue watching'); - I.see('Sign up to start watching'); - - I.login(); - I.amOnPage(constants.agent327DetailUrl); - I.dontSee('Start watching'); - I.see('Continue watching'); - await checkProgress(I, '//button[contains(., "Continue watching")]', (80 / videoLength) * 100, 5, '_progressRail_', '_progress_'); - - I.click('Continue watching'); - await I.waitForPlayerPlaying('Agent 327'); - I.click('video'); - await checkElapsed(I, 1, 20); -}); - -Scenario('I can see my watch history on the Home screen when logged in', async ({ I }) => { - const xpath = '//*[@data-mediaid="continue_watching"]//*[@aria-label="Play Agent 327"]'; - - I.seeCurrentUrlEquals(constants.baseUrl); - I.dontSee('Continue watching'); - - I.login(); - I.see('Continue watching'); - - await within('div[data-mediaid="continue_watching"]', async () => { - I.see('Agent 327'); - I.see('4 min'); - }); - - await checkProgress(I, xpath, (80 / videoLength) * 100); - - // Automatic scroll leads to click problems for some reasons - I.scrollTo(xpath); - I.click(xpath); - await I.waitForPlayerPlaying('Agent 327'); - - await checkElapsed(I, 1, 20); - I.seeInCurrentUrl('play=1'); -}); - -async function playVideo(I: CodeceptJS.I, seekTo: number) { - I.amOnPage(constants.agent327DetailUrl + '&play=1'); - await I.waitForPlayerPlaying('Agent 327'); - await I.executeScript((seekTo) => { - if (!window.jwplayer) { - throw "Can't find jwplayer ref"; - } - - window.jwplayer().seek(seekTo); - }, seekTo); - I.click('div[class="_cinema_1w0uk_1 _fill_1w0uk_1"]'); //re-enable controls overlay - I.click('div[aria-label="Back"]'); - I.waitForClickable(seekTo < videoLength && seekTo > 0 ? 'Continue watching' : 'Start watching', 5); -} - -async function checkProgress( - I: CodeceptJS.I, - context: LocatorOrString, - expectedPercent: number, - tolerance: number = 5, - containerClass: string = '_progressContainer', - barClass: string = '_progressBar', -) { - return within(context, async () => { - const containerWidth = await I.grabCssPropertyFrom(`div[class*=${containerClass}]`, 'width'); - const progressWidth = await I.grabCssPropertyFrom(`div[class*=${barClass}]`, 'width'); - - const percentage = Math.round((100 * pixelsToNumber(progressWidth)) / pixelsToNumber(containerWidth)); - - await I.say(`Checking that percentage ${percentage} is between ${expectedPercent - tolerance} and ${expectedPercent + tolerance}`); - - if (percentage < expectedPercent - tolerance) { - assert.fail(`Expected percentage ${percentage} to be greater than ${expectedPercent - tolerance}`); - } else if (percentage > expectedPercent + tolerance) { - assert.fail(`Expected percentage ${percentage} to be less than ${expectedPercent + tolerance}`); - } else { - assert.ok(percentage); - } - }); -} - -function pixelsToNumber(value: string) { - return Number(value.substring(0, value.indexOf('px'))); -} - -async function checkElapsed(I: CodeceptJS.I, expectedMinutes: number, expectedSeconds: number, bufferSeconds: number = 5) { - const elapsed = await I.grabTextFrom('[class*=jw-text-elapsed]'); - const [minutes, seconds] = elapsed.split(':').map((item) => Number.parseInt(item)); - assert.strictEqual(minutes, expectedMinutes); - - if (seconds < expectedSeconds || seconds > expectedSeconds + bufferSeconds) { - assert.fail(`Elapsed time of ${minutes}m ${seconds}s is not within ${bufferSeconds} seconds of ${expectedMinutes}m ${expectedSeconds}s`); - } else { - assert.ok(expectedSeconds); - } -} diff --git a/test-e2e/utils/constants.ts b/test-e2e/utils/constants.ts index 228b817aa..d1e057111 100644 --- a/test-e2e/utils/constants.ts +++ b/test-e2e/utils/constants.ts @@ -19,6 +19,7 @@ export default { agent327DetailUrl: `${baseUrl}m/uB8aRnu6/agent-327?r=${filmsPlaylistId}`, elephantsDreamDetailUrl: `${baseUrl}m/8pN9r7vd/elephants-dream?r=${filmsPlaylistId}`, primitiveAnimalsDetailUrl: `${baseUrl}s/xdAqW8ya/primitive-animals?e=zKT3MFut`, - agent327Description: 'Hendrik IJzerbroot – Agent 327 – is a secret agent working for the Netherlands secret service agency. In the twenty comic books that were published since 1968, Martin Lodewijk created a rich universe with international conspiracies, hilarious characters and a healthy dose of Dutch humour.', + agent327Description: + 'Hendrik IJzerbroot – Agent 327 – is a secret agent working for the Netherlands secret service agency. In the twenty comic books that were published since 1968, Martin Lodewijk created a rich universe with international conspiracies, hilarious characters and a healthy dose of Dutch humour.', bigBuckBunnyDetailUrl: `${baseUrl}m/awWEFyPu/big-buck-bunny?r=${filmsPlaylistId}`, -}; \ No newline at end of file +}; diff --git a/test-e2e/utils/login.ts b/test-e2e/utils/login.ts new file mode 100644 index 000000000..0f5985710 --- /dev/null +++ b/test-e2e/utils/login.ts @@ -0,0 +1,48 @@ +const formFeedback = 'div[class*=formFeedback]'; + +export function tryToSubmitForm(I: CodeceptJS.I) { + I.submitForm(false); + I.dontSeeElement(formFeedback); + I.dontSee('Incorrect email/password combination'); +} + +export function fillAndCheckField(I: CodeceptJS.I, field, value, error: string | boolean = false) { + if (value === '') { + // For some reason the Codecept/playwright clear and fillField with empty string do not fire the change events + // so use key presses to clear the field to avoid test-induced bugs + I.click(`input[name="${field}"]`); + I.pressKey(['CommandOrControl', 'a']); + I.pressKey('Backspace'); + } else { + I.fillField(field, value); + } + + checkField(I, field, error); +} + +export function checkField(I: CodeceptJS.I, field, error: string | boolean = false) { + const hoverColor = 'rgba(255, 255, 255, 0.7)'; + const activeColor = error ? 'rgb(255, 12, 62)' : 'rgb(255, 255, 255)'; + const restingColor = error ? 'rgb(255, 12, 62)' : 'rgba(255, 255, 255, 0.34)'; + + // If error === true, there's an error, but no associated message + if (error && error !== true) { + I.see(error, `[data-testid=login-${field}-input]`); + I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=helperText]`, { color: '#ff0c3e' }); + } else { + I.dontSeeElement(`[class*=helperText] [data-testid="${field}-input"]`); + } + + // There are 3 css states for the input fields, hover, active, and 'resting'. Check all 3. + // This isn't so much for testing functionality, as it is to avoid test bugs caused by codecept placing the mouse + // different places and accidentally triggering the wrong css color + // Hover: + I.click(`input[name="${field}"]`); + I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=container]`, { 'border-color': hoverColor }); + // Active (no hover): + I.moveCursorTo('button[type=submit]'); + I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=container]`, { 'border-color': activeColor }); + // Resting: + I.click('div[class*=banner]'); + I.seeCssPropertiesOnElements(`[data-testid="login-${field}-input"] [class*=container]`, { 'border-color': restingColor }); +} diff --git a/test-e2e/utils/password_utils.ts b/test-e2e/utils/password_utils.ts index d22af0379..d13f24cfc 100644 --- a/test-e2e/utils/password_utils.ts +++ b/test-e2e/utils/password_utils.ts @@ -1,4 +1,4 @@ -import assert from "assert"; +import assert from 'assert'; export interface LoginContext { email: string; @@ -9,10 +9,10 @@ async function testPasswordToggling(I: CodeceptJS.I, name = 'password') { await I.enableClipboard(); await I.writeClipboard(''); - I.fillField({name}, 'password123!'); + I.fillField({ name }, 'password123!'); await checkPasswordType(I, name, 'password'); -// Whem the input type is password, you should not be able to copy the password value + // When the input type is password, you should not be able to copy the password value await tryToCopyPassword(I, name, ''); I.click(`input[name="${name}"]+div div[aria-label="View password"]`); @@ -25,7 +25,7 @@ async function testPasswordToggling(I: CodeceptJS.I, name = 'password') { I.click(`input[name="${name}"]+div div[aria-label="Hide password"]`); await checkPasswordType(I, name, 'password'); -// Password should not be able to be copied again and whatever is in the clipboard should stay in the clipboard + // Password should not be able to be copied again and whatever is in the clipboard should stay in the clipboard await tryToCopyPassword(I, name, 'dummy'); } @@ -36,15 +36,23 @@ async function checkPasswordType(I: CodeceptJS.I, name, expectedType) { async function tryToCopyPassword(I: CodeceptJS.I, name, expectedResult) { // Use Ctrl + A, Ctrl + C to highlight and copy the password I.click(`input[name="${name}"]`); - I.pressKey(['Control', 'a']); - I.pressKey(['Control', 'c']); - I.wait(1); - assert.strictEqual(await I.readClipboard(), expectedResult); + await I.pressKey(['CommandOrControl', 'A']); + await I.pressKey(['CommandOrControl', 'C']); + // For some reason keyboard copy doesn't work when running via yarn + await I.executeScript(() => document.execCommand('copy')); + + const clipboard = await I.readClipboard(); + + assert.strictEqual(clipboard, expectedResult); } export default { testPasswordToggling, - createRandomEmail: function() { return `dummy-${Date.now()}-${Math.floor(Math.random()*10**6)}@jwplayer.com`; }, - createRandomPassword: function() { return `ABCDefgh${Math.floor(Math.random()*10**12)}!`; } -}; \ No newline at end of file + createRandomEmail: function () { + return `dummy-${Date.now()}-${Math.floor(Math.random() * 10 ** 6)}@jwplayer.com`; + }, + createRandomPassword: function () { + return `ABCDefgh${Math.floor(Math.random() * 10 ** 12)}!`; + }, +}; diff --git a/test-e2e/utils/payments.ts b/test-e2e/utils/payments.ts new file mode 100644 index 000000000..25e20fa2d --- /dev/null +++ b/test-e2e/utils/payments.ts @@ -0,0 +1,103 @@ +import constants from './constants'; + +const yearlyPrice = formatPrice(50); + +export function goToCheckout(I: CodeceptJS.I) { + I.amOnPage(constants.offersUrl); + + I.click('Continue'); + I.waitForLoaderDone(); +} + +export function formatPrice(price: number) { + // eslint-disable-next-line no-irregular-whitespace + return `€ ${price.toFixed(2).replace('.', ',')}`; +} + +export function addDays(date: Date, days: number) { + const newDate = new Date(date.valueOf()); + newDate.setDate(newDate.getDate() + days); + return newDate; +} + +export function addYear(date: Date) { + const newDate = new Date(date.valueOf()); + newDate.setFullYear(newDate.getFullYear() + 1); + return newDate; +} + +export function formatDate(date: Date) { + return date.toLocaleDateString('en-US'); +} + +export function finishAndCheckSubscription(I: CodeceptJS.I, billingDate: Date, today: Date) { + I.click('Continue'); + I.waitForLoaderDone(15); + I.see('Welcome to Blender'); + I.see('Thank you for subscribing to Blender. Please enjoy all our content.'); + + I.click('Start watching'); + + I.waitForLoaderDone(); + I.see('Annual subscription'); + I.see(yearlyPrice); + I.see('/year'); + I.see('Next billing date is on ' + formatDate(billingDate)); + I.see('Cancel subscription'); + + I.see('Annual subscription (recurring) to JW Player'); + + I.see(formatDate(today)); +} + +export function cancelPlan(I: CodeceptJS.I, expirationDate: Date) { + I.amOnPage(constants.paymentsUrl); + I.click('Cancel subscription'); + I.see('We are sorry to see you go.'); + I.see('You will be unsubscribed from your current plan by clicking the unsubscribe button below.'); + I.see('Unsubscribe'); + // Make sure the cancel button works + I.click('No, thanks'); + + I.dontSee('This plan will expire'); + + I.click('Cancel subscription'); + I.click('Unsubscribe'); + I.waitForLoaderDone(10); + I.see('Miss you already.'); + I.see('You have been successfully unsubscribed. Your current plan will expire on ' + formatDate(expirationDate)); + I.click('Return to profile'); + I.see('Renew subscription'); + I.see('This plan will expire on ' + formatDate(expirationDate)); +} + +export function renewPlan(I: CodeceptJS.I, billingDate: Date) { + I.amOnPage(constants.paymentsUrl); + I.click('Renew subscription'); + I.see('Renew your subscription'); + I.see('By clicking the button below you can renew your plan.'); + I.see('Annual subscription (recurring) to JW Player'); + I.see('Next billing date will be ' + formatDate(billingDate)); + I.see(yearlyPrice); + I.see('/year'); + + I.click('No, thanks'); + I.see('Renew subscription'); + + I.click('Renew subscription'); + + I.click('Renew subscription', '[class*=_dialog]'); + I.waitForLoaderDone(10); + I.see('Your subscription has been renewed'); + I.see(`You have been successfully resubscribed. Your fee will be ${yearlyPrice} starting from ${formatDate(billingDate)}`); + I.click('Back to profile'); + + I.see('Annual subscription'); + I.see('Next billing date is on'); + I.see('Cancel subscription'); +} + +export function overrideIP(I: CodeceptJS.I) { + // Set this as a cookie so it persists between page navigations (local storage would also work, but the permissions don't work) + I.setCookie({ name: 'overrideIP', value: '101.33.29.0', domain: 'localhost', path: '/' }); +} diff --git a/test-e2e/utils/steps_file.ts b/test-e2e/utils/steps_file.ts index 4ef43a854..754743298 100644 --- a/test-e2e/utils/steps_file.ts +++ b/test-e2e/utils/steps_file.ts @@ -6,233 +6,244 @@ import passwordUtils, { LoginContext } from './password_utils'; const configFileQueryKey = 'c'; const loaderElement = '[class*=_loadingOverlay]'; -module.exports = function () { - return actor({ - useConfig: function ( - this: CodeceptJS.I, - config: 'test--subscription' | 'test--accounts' | 'test--no-cleeng' | 'blender', - baseUrl: string = constants.baseUrl, - ) { - const url = new URL(baseUrl); - url.searchParams.delete(configFileQueryKey); - url.searchParams.append(configFileQueryKey, config); - - this.amOnPage(url.toString()); - }, - login: function ( - this: CodeceptJS.I, - { email, password }: { email: string; password: string } = { email: constants.username, password: constants.password }, - ) { - this.amOnPage(constants.loginUrl); - this.waitForElement('input[name=email]', 10); - this.fillField('email', email); - this.waitForElement('input[name=password]', 10); - this.fillField('password', password); - this.submitForm(15); - - this.dontSee('Incorrect email/password combination'); - this.dontSee(constants.loginFormSelector); - - return { - email, - password, - }; - }, - logout: async function (this: CodeceptJS.I) { - const isMobile = await this.isMobile(); - - if (isMobile) { - this.openMenuDrawer(); +const stepsObj = { + useConfig: function ( + this: CodeceptJS.I, + config: 'test--subscription' | 'test--accounts' | 'test--no-cleeng' | 'blender', + baseUrl: string = constants.baseUrl, + ) { + const url = new URL(baseUrl); + url.searchParams.delete(configFileQueryKey); + url.searchParams.append(configFileQueryKey, config); + + this.amOnPage(url.toString()); + }, + login: function (this: CodeceptJS.I, { email, password }: { email: string; password: string } = { email: constants.username, password: constants.password }) { + this.amOnPage(constants.loginUrl); + this.waitForElement('input[name=email]', 10); + this.fillField('email', email); + this.waitForElement('input[name=password]', 10); + this.fillField('password', password); + this.submitForm(15); + + this.dontSee('Incorrect email/password combination'); + this.dontSee(constants.loginFormSelector); + + return { + email, + password, + }; + }, + logout: async function (this: CodeceptJS.I) { + const isMobile = await this.isMobile(); + + if (isMobile) { + this.openMenuDrawer(); + } else { + this.openUserMenu(); + } + + this.click('div[aria-label="Log out"]'); + }, + // This function will register the user on the first call and return the context + // then assuming context is passed in the next time, will log that same user back in + // Use it for tests where you want a new user for the suite, but not for each test + registerOrLogin: function (this: CodeceptJS.I, context?: LoginContext, onRegister?: () => void) { + if (context) { + this.login({ email: context.email, password: context.password }); + } else { + context = { email: passwordUtils.createRandomEmail(), password: passwordUtils.createRandomPassword() }; + + this.amOnPage(`${constants.baseUrl}?u=create-account`); + this.waitForElement(constants.registrationFormSelector, 10); + + // Sometimes wrong value is saved at the back-end side. We want to be sure that it is correct + this.clearField('Email'); + this.fillField('Email', context.email); + this.wait(2); + + this.clearField('Password'); + this.fillField('Password', context.password); + this.wait(2); + + this.checkOption('Terms and Conditions'); + this.click('Continue'); + this.waitForElement('form[data-testid="personal_details-form"]', 20); + + if (onRegister) { + onRegister(); } else { - this.openUserMenu(); + this.clickCloseButton(); } - - this.click('div[aria-label="Log out"]'); - }, - // This function will register the user on the first call and return the context - // then assuming context is passed in the next time, will log that same user back in - // Use it for tests where you want a new user for the suite, but not for each test - registerOrLogin: function (this: CodeceptJS.I, context?: LoginContext, onRegister?: () => void) { - if (context) { - this.login({ email: context.email, password: context.password }); - } else { - context = { email: passwordUtils.createRandomEmail(), password: passwordUtils.createRandomPassword() }; - - this.amOnPage(`${constants.baseUrl}?u=create-account`); - this.waitForElement(constants.registrationFormSelector, 10); - this.fillField('Email', context.email); - this.fillField('Password', context.password); - - this.checkOption('Terms and Conditions'); - this.click('Continue'); - this.waitForElement('form[data-testid="personal_details-form"]', 20); - - if (onRegister) { - onRegister(); - } else { - this.clickCloseButton(); - } + } + + return context; + }, + submitForm: function (this: CodeceptJS.I, loaderTimeout: number | false = 5) { + this.click('button[type="submit"]'); + this.waitForLoaderDone(loaderTimeout); + }, + waitForLoaderDone: function (this: CodeceptJS.I, timeout: number | false = 5) { + // Specify false when the loader is NOT expected to be shown at all + if (timeout === false) { + this.dontSeeElement(loaderElement); + } else { + this.waitForInvisible(loaderElement, timeout); + } + }, + openMainMenu: async function (this: CodeceptJS.I) { + const isMobile = await this.isMobile(); + if (isMobile) { + this.openMenuDrawer(); + } else { + this.openUserMenu(); + } + + return isMobile; + }, + openMenuDrawer: function (this: CodeceptJS.I) { + this.click('div[aria-label="Open menu"]'); + }, + openUserMenu: function (this: CodeceptJS.I) { + this.click('div[aria-label="Open user menu"]'); + }, + clickCloseButton: function (this: CodeceptJS.I) { + this.click('div[aria-label="Close"]'); + }, + seeAll: function (this: CodeceptJS.I, allStrings: string[]) { + allStrings.forEach((s) => this.see(s)); + }, + dontSeeAny: function (this: CodeceptJS.I, allStrings: string[]) { + allStrings.forEach((s) => this.dontSee(s)); + }, + seeValueEquals: async function (this: CodeceptJS.I, value: string, locator: CodeceptJS.LocatorOrString) { + assert.equal(await this.grabValueFrom(locator), value); + }, + waitForAllInvisible: function (this: CodeceptJS.I, allStrings: string[], timeout: number | undefined = undefined) { + allStrings.forEach((s) => this.waitForInvisible(s, timeout)); + }, + swipeLeft: async function (this: CodeceptJS.I, args) { + args.direction = 'left'; + await this.swipe(args); + }, + swipeRight: async function (this: CodeceptJS.I, args) { + args.direction = 'right'; + await this.swipe(args); + }, + swipe: async function (this: CodeceptJS.I, args) { + await this.executeScript((args) => { + const xpath = args.xpath || `//*[text() = "${args.text}"]`; + + const points = + args.direction === 'left' + ? { x1: 100, y1: 1, x2: 50, y2: 1 } + : args.direction === 'right' + ? { + x1: 50, + y1: 1, + x2: 100, + y2: 1, + } + : args.points; + + const element = document.evaluate(xpath, document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue; + + if (!element) { + throw `Could not find element by xpath: "${xpath}"`; } - return context; - }, - submitForm: function (this: CodeceptJS.I, loaderTimeout: number | false = 5) { - this.click('button[type="submit"]'); - this.waitForLoaderDone(loaderTimeout); - }, - waitForLoaderDone: function (this: CodeceptJS.I, timeout: number | false = 5) { - // Specify false when the loader is NOT expected to be shown at all - if (timeout === false) { - this.dontSeeElement(loaderElement); - } else { - this.waitForInvisible(loaderElement, timeout); - } - }, - openMainMenu: async function (this: CodeceptJS.I, isMobile?: boolean) { - isMobile = await this.isMobile(isMobile); - if (isMobile) { - this.openMenuDrawer(); - } else { - this.openUserMenu(); + element.dispatchEvent( + new TouchEvent('touchstart', { + bubbles: true, + touches: [ + new Touch({ + identifier: Date.now(), + target: element, + clientX: points.x1, + clientY: points.y1, + }), + ], + }), + ); + + element.dispatchEvent( + new TouchEvent('touchend', { + bubbles: true, + changedTouches: [ + new Touch({ + identifier: Date.now() + 1, + target: element, + clientX: points.x2, + clientY: points.y2, + }), + ], + }), + ); + }, args); + }, + waitForPlayerPlaying: async function (title, tries = 10) { + this.seeElement('div[class*="jwplayer"]'); + this.see(title); + await this.waitForPlayerState('playing', ['buffering', 'idle', ''], tries); + }, + waitForPlayerState: async function (this: CodeceptJS.I, expectedState, allowedStates: string[] = [], tries = 5) { + // Since this check executes a script in the browser, it won't use the codecept retries, + // so we have to manually retry (this is because the video can take time to load and the state will be buffering) + for (let i = 0; i < tries; i++) { + // In theory this expression can be simplified, but without the typeof's codecept throws an error when the value is undefined. + const state = await this.executeScript(() => + typeof window.jwplayer === 'undefined' || typeof window.jwplayer().getState === 'undefined' ? '' : jwplayer().getState(), + ); + + await this.say(`Waiting for Player state. Expected: "${expectedState}", Current: "${state}"`); + + if (state === expectedState) { + return; } - return isMobile; - }, - openMenuDrawer: function (this: CodeceptJS.I) { - this.click('div[aria-label="Open menu"]'); - }, - openUserMenu: function (this: CodeceptJS.I) { - this.click('div[aria-label="Open user menu"]'); - }, - clickCloseButton: function (this: CodeceptJS.I) { - this.click('div[aria-label="Close"]'); - }, - seeAll: function (this: CodeceptJS.I, allStrings: string[]) { - allStrings.forEach((s) => this.see(s)); - }, - dontSeeAny: function (this: CodeceptJS.I, allStrings: string[]) { - allStrings.forEach((s) => this.dontSee(s)); - }, - seeValueEquals: async function (this: CodeceptJS.I, value: string, locator: CodeceptJS.LocatorOrString) { - assert.equal(await this.grabValueFrom(locator), value); - }, - waitForAllInvisible: function (this: CodeceptJS.I, allStrings: string[], timeout: number | undefined = undefined) { - allStrings.forEach((s) => this.waitForInvisible(s, timeout)); - }, - swipeLeft: async function (this: CodeceptJS.I, args) { - args.direction = 'left'; - await this.swipe(args); - }, - swipeRight: async function (this: CodeceptJS.I, args) { - args.direction = 'right'; - await this.swipe(args); - }, - swipe: async function (this: CodeceptJS.I, args) { - await this.executeScript((args) => { - const xpath = args.xpath || `//*[text() = "${args.text}"]`; - - const points = - args.direction === 'left' - ? { x1: 100, y1: 1, x2: 50, y2: 1 } - : args.direction === 'right' - ? { - x1: 50, - y1: 1, - x2: 100, - y2: 1, - } - : args.points; - - const element = document.evaluate(xpath, document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue; - - if (!element) { - throw `Could not find element by xpath: "${xpath}"`; - } - - element.dispatchEvent( - new TouchEvent('touchstart', { - bubbles: true, - touches: [ - new Touch({ - identifier: Date.now(), - target: element, - clientX: points.x1, - clientY: points.y1, - }), - ], - }), - ); - - element.dispatchEvent( - new TouchEvent('touchend', { - bubbles: true, - changedTouches: [ - new Touch({ - identifier: Date.now() + 1, - target: element, - clientX: points.x2, - clientY: points.y2, - }), - ], - }), - ); - }, args); - }, - waitForPlayerPlaying: async function (title, tries = 10) { - this.seeElement('div[class*="jwplayer"]'); - this.see(title); - await this.waitForPlayerState('playing', ['buffering', 'idle', ''], tries); - }, - waitForPlayerState: async function (this: CodeceptJS.I, expectedState, allowedStates: string[] = [], tries = 5) { - // Since this check executes a script in the browser, it won't use the codecept retries, - // so we have to manually retry (this is because the video can take time to load and the state will be buffering) - for (let i = 0; i < tries; i++) { - // In theory this expression can be simplified, but without the typeof's codecept throws an error when the value is undefined. - const state = await this.executeScript(() => - typeof window.jwplayer === 'undefined' || typeof window.jwplayer().getState === 'undefined' ? '' : jwplayer().getState(), - ); - - await this.say(`Waiting for Player state. Expected: "${expectedState}", Current: "${state}"`); - - if (state === expectedState) { - return; - } - - if (allowedStates.indexOf(state) >= 0) { - this.wait(1); - } else { - assert.fail(`Unexpected player state: ${state}`); - } + if (allowedStates.indexOf(state) >= 0) { + this.wait(1); + } else { + assert.fail(`Unexpected player state: ${state}`); } + } + + assert.fail(`Player did not reach "${expectedState}"`); + }, + checkPlayerClosed: async function (this: CodeceptJS.I) { + this.dontSeeElement('div[class*="jwplayer"]'); + this.dontSeeElement('video'); + // eslint-disable-next-line no-console + assert.equal(await this.executeScript(() => (typeof jwplayer === 'undefined' ? undefined : jwplayer().getState)), undefined); + }, + isMobile: async function (this: CodeceptJS.I): Promise { + let isMobile = false; + + await this.usePlaywrightTo('Get is Mobile', async ({ browserContext }) => { + isMobile = Boolean(browserContext._options.isMobile); + }); + + return isMobile; + }, + isDesktop: async function (this: CodeceptJS.I) { + return !(await this.isMobile()); + }, + enableClipboard: async function (this: CodeceptJS.I) { + await this.usePlaywrightTo('Setup the clipboard', async ({ browserContext }) => { + await browserContext.grantPermissions(['clipboard-read', 'clipboard-write']); + }); + }, + readClipboard: async function (this: CodeceptJS.I) { + return this.executeScript(async () => navigator.clipboard.readText()); + }, + writeClipboard: async function (this: CodeceptJS.I, text: string) { + await this.executeScript((text) => navigator.clipboard.writeText(text), text); + }, +}; +declare global { + type Steps = typeof stepsObj; +} - assert.fail(`Player did not reach "${expectedState}"`); - }, - checkPlayerClosed: async function (this: CodeceptJS.I) { - this.dontSeeElement('div[class*="jwplayer"]'); - this.dontSeeElement('video'); - // eslint-disable-next-line no-console - assert.equal(await this.executeScript(() => (typeof jwplayer === 'undefined' ? undefined : jwplayer().getState)), undefined); - }, - isMobile: async function (this: CodeceptJS.I) { - return ( - (await this.usePlaywrightTo('Get is Mobile', async ({ browserContext }) => { - return browserContext._options.isMobile; - })) || false - ); - }, - isDesktop: async function (this: CodeceptJS.I) { - return !(await this.isMobile()); - }, - enableClipboard: async function (this: CodeceptJS.I) { - await this.usePlaywrightTo('Setup the clipboard', async ({ browserContext }) => { - await browserContext.grantPermissions(['clipboard-read', 'clipboard-write']); - }); - }, - readClipboard: async function (this: CodeceptJS.I) { - return await this.executeScript(() => navigator.clipboard.readText()); - }, - writeClipboard: async function (this: CodeceptJS.I, text: string) { - await this.executeScript((text) => navigator.clipboard.writeText(text), text); - }, - }); +export = function () { + return actor(stepsObj); }; diff --git a/test-e2e/utils/watch_history.ts b/test-e2e/utils/watch_history.ts new file mode 100644 index 000000000..c610dfdc7 --- /dev/null +++ b/test-e2e/utils/watch_history.ts @@ -0,0 +1,64 @@ +import * as assert from 'assert'; + +import constants from './constants'; + +import LocatorOrString = CodeceptJS.LocatorOrString; + +const videoLength = 231; + +export async function playVideo(I: CodeceptJS.I, seekTo: number) { + I.amOnPage(constants.agent327DetailUrl + '&play=1'); + await I.waitForPlayerPlaying('Agent 327'); + await I.executeScript((seekTo) => { + if (!window.jwplayer) { + throw "Can't find jwplayer ref"; + } + + window.jwplayer().seek(seekTo); + }, seekTo); + I.click('div[class="_cinema_1w0uk_1 _fill_1w0uk_1"]'); //re-enable controls overlay + I.click('div[aria-label="Back"]'); + I.waitForClickable(seekTo < videoLength && seekTo > 0 ? 'Continue watching' : 'Start watching', 5); +} + +export async function checkProgress( + I: CodeceptJS.I, + context: LocatorOrString, + expectedPercent: number, + tolerance: number = 5, + containerClass: string = '_progressContainer', + barClass: string = '_progressBar', +) { + return within(context, async () => { + const containerWidth = await I.grabCssPropertyFrom(`div[class*=${containerClass}]`, 'width'); + const progressWidth = await I.grabCssPropertyFrom(`div[class*=${barClass}]`, 'width'); + + const percentage = Math.round((100 * pixelsToNumber(progressWidth)) / pixelsToNumber(containerWidth)); + + await I.say(`Checking that percentage ${percentage} is between ${expectedPercent - tolerance} and ${expectedPercent + tolerance}`); + + if (percentage < expectedPercent - tolerance) { + assert.fail(`Expected percentage ${percentage} to be greater than ${expectedPercent - tolerance}`); + } else if (percentage > expectedPercent + tolerance) { + assert.fail(`Expected percentage ${percentage} to be less than ${expectedPercent + tolerance}`); + } else { + assert.ok(percentage); + } + }); +} + +function pixelsToNumber(value: string) { + return Number(value.substring(0, value.indexOf('px'))); +} + +export async function checkElapsed(I: CodeceptJS.I, expectedMinutes: number, expectedSeconds: number, bufferSeconds: number = 5) { + const elapsed = await I.grabTextFrom('[class*=jw-text-elapsed]'); + const [minutes, seconds] = elapsed.split(':').map((item) => Number.parseInt(item)); + assert.strictEqual(minutes, expectedMinutes); + + if (seconds < expectedSeconds || seconds > expectedSeconds + bufferSeconds) { + assert.fail(`Elapsed time of ${minutes}m ${seconds}s is not within ${bufferSeconds} seconds of ${expectedMinutes}m ${expectedSeconds}s`); + } else { + assert.ok(expectedSeconds); + } +} diff --git a/yarn.lock b/yarn.lock index e6d943ab7..3b9dd5513 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2019,6 +2019,11 @@ ajv@^8.0.1, ajv@^8.6.0: require-from-string "^2.0.2" uri-js "^4.2.2" +allure-commandline@^2.17.2: + version "2.17.2" + resolved "https://registry.yarnpkg.com/allure-commandline/-/allure-commandline-2.17.2.tgz#48c1064973619644011092d31294834210c6c433" + integrity sha512-2a0M0nX1KtVrg4y0rWEFn/OQkv7AaQSMBOEtlKfQl3heZoTEo0IdB08Uk5vU390+qPsEv3EO5igjyCrS0gX+FQ== + allure-js-commons@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/allure-js-commons/-/allure-js-commons-1.3.2.tgz#e1cf0466e36695bb3ced1228f6570eac6c2e9eda" @@ -2565,7 +2570,7 @@ chai@^4.3.6: pathval "^1.1.1" type-detect "^4.0.5" -chalk@^2.0.0, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2799,7 +2804,7 @@ clsx@^1.0.4: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== -codeceptjs@^3.2.3: +codeceptjs@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/codeceptjs/-/codeceptjs-3.3.0.tgz#ad4be123b7ab9517fbaf6bf4b93d49bb25930aac" integrity sha512-2wNHeTM4fssNNxjmBJ3akPH7e54UwptVbW9b4h1y79dMZ2V8E2/eflyPZmMSqBQyu0e6bAaVC9ZJ5sQolc+GwQ== @@ -3049,6 +3054,17 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5556,6 +5572,11 @@ json-buffer@3.0.0: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" @@ -5787,6 +5808,16 @@ listr2@^3.2.2: through "^2.3.8" wrap-ansi "^7.0.0" +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + integrity sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw== + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + local-pkg@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.1.tgz#e7b0d7aa0b9c498a1110a5ac5b00ba66ef38cfff" @@ -6067,6 +6098,11 @@ memoize-one@^5.2.1: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== +memorystream@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" + integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== + meow@^8.0.0: version "8.1.2" resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" @@ -6345,6 +6381,11 @@ nested-error-stacks@~2.0.1: resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz#d2cc9fc5235ddb371fc44d506234339c8e4b0a4b" integrity sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A== +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -6373,7 +6414,7 @@ nopt@^5.0.0: dependencies: abbrev "1" -normalize-package-data@^2.5.0: +normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== @@ -6427,6 +6468,21 @@ now-and-later@^2.0.0: dependencies: once "^1.3.2" +npm-run-all@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba" + integrity sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ== + dependencies: + ansi-styles "^3.2.1" + chalk "^2.4.1" + cross-spawn "^6.0.5" + memorystream "^0.3.1" + minimatch "^3.0.4" + pidtree "^0.3.0" + read-pkg "^3.0.0" + shell-quote "^1.6.1" + string.prototype.padend "^3.0.0" + npm-run-path@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -6711,6 +6767,14 @@ parse-function@^5.6.4: "@babel/parser" "^7.8.3" arrify "^2.0.1" +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw== + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -6756,6 +6820,11 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= +path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== + path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" @@ -6778,6 +6847,13 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + dependencies: + pify "^3.0.0" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -6818,11 +6894,21 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pidtree@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a" + integrity sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA== + pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg== + pinkie-promise@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" @@ -7396,6 +7482,15 @@ read-pkg-up@^7.0.1: read-pkg "^5.2.0" type-fest "^0.8.1" +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + integrity sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA== + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + read-pkg@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" @@ -7864,7 +7959,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.6.0: +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -7905,6 +8000,13 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== + dependencies: + shebang-regex "^1.0.0" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7912,11 +8014,21 @@ shebang-command@^2.0.0: dependencies: shebang-regex "^3.0.0" +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== + shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shell-quote@^1.6.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" + integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== + shortcss@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/shortcss/-/shortcss-0.1.3.tgz#ee2a7904d80b7f5502c98408f4a2f313faadfb48" @@ -8155,6 +8267,15 @@ string.prototype.matchall@^4.0.6: regexp.prototype.flags "^1.3.1" side-channel "^1.0.4" +string.prototype.padend@^3.0.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz#997a6de12c92c7cb34dc8a201a6c53d9bd88a5f1" + integrity sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + string.prototype.trimend@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" @@ -9198,7 +9319,7 @@ which@2.0.2, which@^2.0.1: dependencies: isexe "^2.0.0" -which@^1.3.1: +which@^1.2.9, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==