diff --git a/.github/workflows/platform-docker-build-test-publish.yml b/.github/workflows/platform-docker-build-test-publish.yml index 5599493712c8..a5b08c6b784a 100644 --- a/.github/workflows/platform-docker-build-test-publish.yml +++ b/.github/workflows/platform-docker-build-test-publish.yml @@ -83,9 +83,9 @@ jobs: runs-on: [depot-ubuntu-latest-16, depot-ubuntu-latest-arm-16] args: - api-image: ${{ needs.docker-build-api.outputs.image }} - args: --meta-filter category=oss + args: --project=oss - api-image: ${{ needs.docker-build-private-cloud-api.outputs.image }} - args: --meta-filter category=oss,category=enterprise + args: --project=oss --project=enterprise # Publish to dockerhub @@ -213,3 +213,9 @@ jobs: { "appVersion": "${{ steps.version-trim.outputs.version }}" } + + steps: + - name: Install Playwright browsers + run: npm run playwright:install + - name: Run tests + run: npm run test diff --git a/.github/workflows/platform-pull-request.yml b/.github/workflows/platform-pull-request.yml index 72acc5c37b38..e376ee6b5a8f 100644 --- a/.github/workflows/platform-pull-request.yml +++ b/.github/workflows/platform-pull-request.yml @@ -137,7 +137,7 @@ jobs: runs-on: ${{ matrix.runs-on }} e2e-image: ${{ needs.docker-build-e2e.outputs.image }} api-image: ${{ needs.docker-build-api.outputs.image }} - args: --meta-filter category=oss + args: --project=oss secrets: GCR_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.GITHUB_TOKEN || '' }} SLACK_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.SLACK_TOKEN || '' }} @@ -154,7 +154,7 @@ jobs: runs-on: ${{ matrix.runs-on }} e2e-image: ${{ needs.docker-build-e2e.outputs.image }} api-image: ${{ needs.docker-build-private-cloud.outputs.image }} - args: --meta-filter category=enterprise + args: --project=enterprise secrets: GCR_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.GITHUB_TOKEN || '' }} SLACK_TOKEN: ${{ needs.permissions-check.outputs.can-write == 'true' && secrets.SLACK_TOKEN || '' }} diff --git a/frontend/.testcaferc.js b/frontend/.testcaferc.js deleted file mode 100644 index 43d0102f3722..000000000000 --- a/frontend/.testcaferc.js +++ /dev/null @@ -1,23 +0,0 @@ -const isDev = process.env.E2E_DEV; -module.exports = { - "browsers": "firefox:headless", - "port1": 8080, - "port2": 8081, - "hostname": "localhost", - quarantineMode: false, - skipJsErrors: true, - selectorTimeout: 20000, - assertionTimeout: 20000, - cache: true, - "videoPath": "reports/screen-captures", - "videoOptions": { - "singleFile": true, - "failedOnly": true, - "pathPattern": "./test-report-${FILE_INDEX}.mp4" - }, - "videoEncodingOptions": { - "r": 20, - "aspect": "4:3" - }, - // other settings -} diff --git a/frontend/Dockerfile.e2e b/frontend/Dockerfile.e2e index b0e536225318..b6faa110613c 100644 --- a/frontend/Dockerfile.e2e +++ b/frontend/Dockerfile.e2e @@ -14,6 +14,9 @@ COPY frontend . COPY .release-please-manifest.json ./.versions.json RUN npm run env +# Install Playwright browsers +RUN npm run playwright:install + ARG CI_COMMIT_SHA=dev RUN echo ${CI_COMMIT_SHA} > ./CI_COMMIT_SHA diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts deleted file mode 100644 index 93d8b386bd80..000000000000 --- a/frontend/e2e/helpers.cafe.ts +++ /dev/null @@ -1,586 +0,0 @@ -import { RequestLogger, Selector, t } from 'testcafe' -import { FlagsmithValue } from '../common/types/responses'; - -export const LONG_TIMEOUT = 40000 - -export const byId = (id: string) => `[data-test="${id}"]` - -export type MultiVariate = { value: string; weight: number } - -export type Rule = { - name: string - operator: string - value: string | number | boolean - ors?: Rule[] -} -export const setText = async (selector: string, text: string) => { - logUsingLastSection(`Set text ${selector} : ${text}`) - if (text) { - return t - .selectText(selector) - .pressKey('delete') - .selectText(selector) // Prevents issue where input tabs out of focus - .typeText(selector, `${text}`) - } else { - return t - .selectText(selector) // Prevents issue where input tabs out of focus - .pressKey('delete') - } -} - -export const waitForElementVisible = async (selector: string) => { - logUsingLastSection(`Waiting element visible ${selector}`) - return t - .expect(Selector(selector).visible) - .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) -} - -export const waitForElementNotClickable = async (selector: string) => { - logUsingLastSection(`Waiting element visible ${selector}`) - await t - .expect(Selector(selector).visible) - .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) - await t - .expect(Selector(selector).hasAttribute('disabled')).ok() -} - -export const waitForElementClickable = async (selector: string) => { - logUsingLastSection(`Waiting element visible ${selector}`) - await t - .expect(Selector(selector).visible) - .ok(`waitForElementVisible(${selector})`, { timeout: LONG_TIMEOUT }) - await t - .expect(Selector(selector).hasAttribute('disabled')).notOk() -} - -export const logResults = async (requests: LoggedRequest[], t) => { - if (!t.testRun?.errs?.length) { - log('Finished without errors') - return // do not log anything for passed tests - } - log('Start of Requests') - log( - undefined, - JSON.stringify( - requests.filter((v) => { - if ( - v.request?.url?.includes('get-subscription-metadata') || - v.request?.url?.includes('analytics/flags') - ) { - return false - } - if ( - v.response && - v.response?.statusCode >= 200 && - v.response?.statusCode < 300 - ) { - return false - } - return true - }), - null, - 2, - ), - ) - logUsingLastSection('Session JavaScript Errors') - logUsingLastSection(JSON.stringify(await t.getBrowserConsoleMessages())) - log('End of Requests') -} - -export const waitForElementNotExist = async (selector: string) => { - logUsingLastSection(`Waiting element not visible ${selector}`) - return t.expect(Selector(selector).exists).notOk('', { timeout: 10000 }) -} -export const gotoFeatures = async () => { - await click('#features-link') - await waitForElementVisible('#show-create-feature-btn') -} - -export const click = async (selector: string) => { - await waitForElementVisible(selector) - await t - .scrollIntoView(selector) - .expect(Selector(selector).hasAttribute('disabled')) - .notOk('ready for testing', { timeout: 5000 }) - .hover(selector) - .click(selector) -} - -export const clickByText = async (text:string, element = 'button') => { - logUsingLastSection(`Click by text ${text} ${element}`) - const selector = Selector(element).withText(text); - await t - .scrollIntoView(selector) - .expect(Selector(selector).hasAttribute('disabled')) - .notOk('ready for testing', { timeout: 5000 }) - .hover(selector) - .click(selector) -} - -export const gotoSegments = async () => { - await click('#segments-link') -} - -export const getLogger = () => - RequestLogger(/api\/v1/, { - logRequestBody: true, - logRequestHeaders: true, - logResponseBody: true, - logResponseHeaders: true, - stringifyRequestBody: true, - stringifyResponseBody: true, - }) - -export const createRole = async (roleName: string, index: number, users: number[]) => { - await click(byId('tab-item-roles')) - await click(byId('create-role')) - await setText(byId('role-name'), roleName) - await click(byId('save-role')) - await click(byId(`role-${index}`)) - await click(byId('members-tab')) - await click(byId('assigned-users')) - for (const userId of users) { - await click(byId(`assignees-list-item-${userId}`)) - } - await closeModal() -} - - -export const editRoleMembers = async (index:number)=>{ - await click(byId('tab-item-roles')) - await click(byId('create-role')) - await setText(byId('role-name'), roleName) - await click(byId('save-role')) -} - -export const gotoTraits = async () => { - await click('#features-link') - await click('#users-link') - await click(byId('user-item-0')) - await waitForElementVisible('#add-trait') -} - -export const createTrait = async ( - index: number, - id: string, - value: string | boolean | number, -) => { - await click('#add-trait') - await waitForElementVisible('#create-trait-modal') - await setText('[name="traitID"]', id) - await setText('[name="traitValue"]', `${value}`) - await click('#create-trait-btn') - await t.wait(2000) - await t.eval(() => location.reload()) - await waitForElementVisible(byId(`user-trait-value-${index}`)) - const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` - await assertTextContent(byId(`user-trait-value-${index}`), expectedValue) -} - -export const deleteTrait = async (index: number) => { - await click(byId(`delete-user-trait-${index}`)) - await click('#confirm-btn-yes') - await waitForElementNotExist(byId(`user-trait-${index}`)) -} - -const lastTestSection = {} -let lastTestName = undefined - -export const logUsingLastSection = (message?: string) => { - log(undefined, message) -} - -// eslint-disable-next-line no-console -export const log = (section: string | undefined, message?: string) => { - const testName = t.test.name - const sectionName = section ?? lastTestSection[testName] - - if (lastTestName !== testName || lastTestSection[testName] !== sectionName) { - const ellipsis = section === sectionName ? '' : '...' - console.log( - '\n', - '\x1b[32m', - `${testName ? `${ellipsis}[${testName} tests] ` : ''}${sectionName}`, - '\x1b[0m', - '\n', - ) - lastTestSection[testName] = sectionName - lastTestName = testName - } - if (message) { - console.log(message) - } -} - -export const viewFeature = async (index: number) => { - await click(byId(`feature-item-${index}`)) - await waitForElementVisible('#create-feature-modal') -} - -export const addSegmentOverrideConfig = async ( - index: number, - value: string | boolean | number, - selectionIndex = 0, -) => { - await click(byId('segment_overrides')) - await click(byId(`select-segment-option-${selectionIndex}`)) - - await waitForElementVisible(byId(`segment-override-value-${index}`)) - await setText(byId(`segment-override-value-${index}`), `${value}`) - await click(byId(`segment-override-toggle-${index}`)) -} - -export const addSegmentOverride = async ( - index: number, - value: string | boolean | number, - selectionIndex = 0, - mvs: MultiVariate[] = [], -) => { - await click(byId('segment_overrides')) - await click(byId(`select-segment-option-${selectionIndex}`)) - await waitForElementVisible(byId(`segment-override-value-${index}`)) - if (value) { - await click(`${byId(`segment-override-${index}`)} [role="switch"]`) - } - if (mvs) { - await Promise.all( - mvs.map(async (v, i) => { - await setText( - `.segment-overrides ${byId(`featureVariationWeight${v.value}`)}`, - `${v.weight}`, - ) - }), - ) - } -} - -export const saveFeature = async () => { - await click('#update-feature-btn') - await waitForElementVisible('.toast-message') - await waitForElementNotExist('.toast-message') - await closeModal() - await waitForElementNotExist('#create-feature-modal') -} - -export const saveFeatureSegments = async () => { - await click('#update-feature-segments-btn') - await waitForElementVisible('.toast-message') - await waitForElementNotExist('.toast-message') - await closeModal() - await waitForElementNotExist('#create-feature-modal') -} - -export const createEnvironment = async (name:string) => { - await setText('[name="envName"]', name) - await click('#create-env-btn') - await waitForElementVisible(byId(`switch-environment-${name.toLowerCase()}-active`)) -} - -export const goToUser = async (index: number) => { - await click('#features-link') - await click('#users-link') - await click(byId(`user-item-${index}`)) -} - -export const gotoFeature = async (index: number) => { - await click(byId(`feature-item-${index}`)) - await waitForElementVisible('#create-feature-modal') -} - -export const setSegmentOverrideIndex = async ( - index: number, - newIndex: number, -) => { - await click(byId('segment_overrides')) - await setText(byId(`sort-${index}`), `${newIndex}`) -} - -export const assertTextContent = (selector: string, v: string) => - t.expect(Selector(selector).textContent).eql(v) -export const assertTextContentContains = (selector: string, v: string) => - t.expect(Selector(selector).textContent).contains(v) -export const getText = (selector: string) => Selector(selector).innerText - -export const deleteSegment = async (index: number, name: string) => { - await click(byId(`remove-segment-btn-${index}`)) - await setText('[name="confirm-segment-name"]', name) - await click('#confirm-remove-segment-btn') - await waitForElementNotExist(`remove-segment-btn-${index}`) -} - -export const login = async (email: string, password: string) => { - await setText('[name="email"]', `${email}`) - await setText('[name="password"]', `${password}`) - await click('#login-btn') - await waitForElementVisible('#project-manage-widget') -} -export const logout = async () => { - await click('#account-settings-link') - await click('#logout-link') - await waitForElementVisible('#login-page') -} - -export const goToFeatureVersions = async (featureIndex:number) =>{ - await gotoFeature(featureIndex) - await click(byId('change-history')) -} - -export const compareVersion = async ( - featureIndex:number, - versionIndex:number, - compareOption: 'LIVE'|'PREVIOUS'|null, - oldEnabled:boolean, - newEnabled:boolean, - oldValue?:FlagsmithValue, - newValue?:FlagsmithValue -) =>{ - await goToFeatureVersions(featureIndex) - await click(byId(`history-item-${versionIndex}-compare`)) - if(compareOption==='LIVE') { - await click(byId(`history-item-${versionIndex}-compare-live`)) - } else if(compareOption==='PREVIOUS') { - await click(byId(`history-item-${versionIndex}-compare-previous`)) - } - - await assertTextContent(byId(`old-enabled`), `${oldEnabled}`) - await assertTextContent(byId(`new-enabled`), `${newEnabled}`) - if(oldValue) { - await assertTextContent(byId(`old-value`), `${oldValue}`) - } - if(newValue) { - await assertTextContent(byId(`old-value`), `${oldValue}`) - } - await closeModal() -} -export const assertNumberOfVersions = async (index:number, versions:number) =>{ - await goToFeatureVersions(index) - await waitForElementVisible(byId(`history-item-${versions-2}-compare`)) - await closeModal() -} - -export const createRemoteConfig = async ( - index: number, - name: string, - value: string | number | boolean, - description = 'description', - defaultOff?: boolean, - mvs: MultiVariate[] = [], -) => { - const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` - await gotoFeatures() - await click('#show-create-feature-btn') - await setText(byId('featureID'), name) - await setText(byId('featureValue'), `${value}`) - await setText(byId('featureDesc'), description) - if (!defaultOff) { - await click(byId('toggle-feature-button')) - } - await Promise.all( - mvs.map(async (v, i) => { - await click(byId('add-variation')) - - await setText(byId(`featureVariationValue${i}`), v.value) - await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) - }), - ) - await click(byId('create-feature-btn')) - await waitForElementVisible(byId(`feature-value-${index}`)) - await assertTextContent(byId(`feature-value-${index}`), expectedValue) - await closeModal() -} - -export const createOrganisationAndProject = async (organisationName:string,projectName:string) =>{ - log('Create Organisation') - await click(byId('home-link')) - await click(byId('create-organisation-btn')) - await setText('[name="orgName"]', organisationName) - await click('#create-org-btn') - await waitForElementVisible(byId('project-manage-widget')) - - log('Create Project') - await click('.btn-project-create') - await setText(byId('projectName'), projectName) - await click(byId('create-project-btn')) - await waitForElementVisible(byId('features-page')) -} -export const editRemoteConfig = async ( - index: number, - value: string | number | boolean, - toggleFeature: boolean = false, - mvs: MultiVariate[] = [], -) => { - const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` - await gotoFeatures() - - await click(byId(`feature-item-${index}`)) - await setText(byId('featureValue'), `${value}`) - if (toggleFeature) { - await click(byId('toggle-feature-button')) - } - await Promise.all( - mvs.map(async (v, i) => { - await setText(byId(`featureVariationWeight${v.value}`), `${v.weight}`) - }), - ) - await click(byId('update-feature-btn')) - if(value) { - await waitForElementVisible(byId(`feature-value-${index}`)) - await assertTextContent(byId(`feature-value-${index}`), expectedValue) - } - await closeModal() -} -export const closeModal = async () => { - await t.click('body', { - offsetX: 50, - offsetY: 50, - }) -} -export const createFeature = async ( - index: number, - name: string, - value?: string | boolean | number, - description = 'description', -) => { - await gotoFeatures() - await click('#show-create-feature-btn') - await setText(byId('featureID'), name) - await setText(byId('featureDesc'), description) - if (value) { - await click(byId('toggle-feature-button')) - } - await click(byId('create-feature-btn')) - await waitForElementVisible(byId(`feature-item-${index}`)) - await closeModal() -} - -export const deleteFeature = async (index: number, name: string) => { - await click(byId(`feature-action-${index}`)) - await waitForElementVisible(byId(`remove-feature-btn-${index}`)) - await click(byId(`remove-feature-btn-${index}`)) - await setText('[name="confirm-feature-name"]', name) - await click('#confirm-remove-feature-btn') - await waitForElementNotExist(`remove-feature-btn-${index}`) -} - -export const toggleFeature = async (index: number, toValue: boolean) => { - await click(byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)) - await click('#confirm-toggle-feature-btn') - await waitForElementVisible( - byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`), - ) -} - -export const setUserPermissions = async (index: number, toValue: boolean) => { - await click(byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)) - await click('#confirm-toggle-feature-btn') - await waitForElementVisible( - byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`), - ) -} - -export const setSegmentRule = async ( - ruleIndex: number, - orIndex: number, - name: string, - operator: string, - value: string | number | boolean, -) => { - await setText(byId(`rule-${ruleIndex}-property-${orIndex}`), name) - if (operator) { - await setText(byId(`rule-${ruleIndex}-operator-${orIndex}`), operator) - } - await setText(byId(`rule-${ruleIndex}-value-${orIndex}`), `${value}`) -} - -export const createSegment = async ( - index: number, - id: string, - rules?: Rule[], -) => { - await click(byId('show-create-segment-btn')) - await setText(byId('segmentID'), id) - for (let x = 0; x < rules.length; x++) { - const rule = rules[x] - if (x > 0) { - // eslint-disable-next-line no-await-in-loop - await click(byId('add-rule')) - } - // eslint-disable-next-line no-await-in-loop - await setSegmentRule(x, 0, rule.name, rule.operator, rule.value) - if (rule.ors) { - for (let orIndex = 0; orIndex < rule.ors.length; orIndex++) { - const or = rule.ors[orIndex] - // eslint-disable-next-line no-await-in-loop - await click(byId(`rule-${x}-or`)) - // eslint-disable-next-line no-await-in-loop - await setSegmentRule(x, orIndex + 1, or.name, or.operator, or.value) - } - } - } - - // Create - await click(byId('create-segment')) - await waitForElementVisible(byId(`segment-${index}-name`)) - await assertTextContent(byId(`segment-${index}-name`), id) - await closeModal() -} - -export const waitAndRefresh = async (waitFor = 3000) => { - logUsingLastSection(`Waiting for ${waitFor}ms, then refreshing.`) - await t.wait(waitFor) - await t.eval(() => location.reload()) -} - -export const refreshUntilElementVisible = async (selector: string, maxRetries=20) => { - const element = Selector(selector); - const isElementVisible = async () => await element.exists && await element.visible; - let retries = 0; - while (retries < maxRetries && !(await isElementVisible())) { - await t.eval(() => location.reload()); // Reload the page - await t.wait(3000); - retries++; - } - return t.scrollIntoView(element) -} - -const permissionsMap = { - 'CREATE_PROJECT': 'organisation', - 'MANAGE_USERS': 'organisation', - 'MANAGE_USER_GROUPS': 'organisation', - 'VIEW_PROJECT': 'project', - 'CREATE_ENVIRONMENT': 'project', - 'DELETE_FEATURE': 'project', - 'CREATE_FEATURE': 'project', - 'MANAGE_SEGMENTS': 'project', - 'VIEW_AUDIT_LOG': 'project', - 'VIEW_ENVIRONMENT': 'environment', - 'UPDATE_FEATURE_STATE': 'environment', - 'MANAGE_IDENTITIES': 'environment', - 'CREATE_CHANGE_REQUEST': 'environment', - 'APPROVE_CHANGE_REQUEST': 'environment', - 'VIEW_IDENTITIES': 'environment', - 'MANAGE_SEGMENT_OVERRIDES': 'environment', - 'MANAGE_TAGS': 'project', -} as const; - - -export const setUserPermission = async (email: string, permission: keyof typeof permissionsMap | 'ADMIN', entityName:string|null, entityLevel?: 'project'|'environment'|'organisation', parentName?: string) => { - await click(byId('users-and-permissions')) - await click(byId(`user-${email}`)) - const level = permissionsMap[permission] || entityLevel - await click(byId(`${level}-permissions-tab`)) - if(parentName) { - await clickByText(parentName, 'a') - } - if(entityName) { - await click(byId(`permissions-${entityName.toLowerCase()}`)) - } - if(permission==='ADMIN') { - await click(byId(`admin-switch-${level}`)) - } else { - await click(byId(`permission-switch-${permission}`)) - } - await closeModal() -} - -export default {} diff --git a/frontend/e2e/helpers/helpers.ts b/frontend/e2e/helpers/helpers.ts new file mode 100644 index 000000000000..c6dda9677b7c --- /dev/null +++ b/frontend/e2e/helpers/helpers.ts @@ -0,0 +1,542 @@ +import { Page, expect } from '@playwright/test'; +import { FlagsmithValue } from '../../common/types/responses'; + +export const LONG_TIMEOUT = 40000; + +export const byId = (id: string) => `[data-test="${id}"]`; + +export type MultiVariate = { value: string; weight: number }; + +export type Rule = { + name: string; + operator: string; + value: string | number | boolean; + ors?: Rule[]; +}; + +export const setText = async (page: Page, selector: string, text: string) => { + logUsingLastSection(`Set text ${selector} : ${text}`); + if (text) { + await page.fill(selector, text); + } else { + await page.fill(selector, ''); + } +}; + +export const waitForElementVisible = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element visible ${selector}`); + await expect(page.locator(selector)).toBeVisible({ timeout: LONG_TIMEOUT }); +}; + +export const waitForElementNotClickable = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element visible ${selector}`); + await expect(page.locator(selector)).toBeVisible({ timeout: LONG_TIMEOUT }); + await expect(page.locator(selector)).toHaveAttribute('disabled', ''); +}; + +export const waitForElementClickable = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element visible ${selector}`); + await expect(page.locator(selector)).toBeVisible({ timeout: LONG_TIMEOUT }); + await expect(page.locator(selector)).not.toHaveAttribute('disabled', ''); +}; + +export const waitForElementNotExist = async (page: Page, selector: string) => { + logUsingLastSection(`Waiting element not visible ${selector}`); + await expect(page.locator(selector)).not.toBeVisible({ timeout: 10000 }); +}; + +export const gotoFeatures = async (page: Page) => { + await click(page, '#features-link'); + await waitForElementVisible(page, '#show-create-feature-btn'); +}; + +export const click = async (page: Page, selector: string) => { + await waitForElementVisible(page, selector); + const element = page.locator(selector); + await element.scrollIntoViewIfNeeded(); + await expect(element).not.toHaveAttribute('disabled', ''); + await element.hover(); + await element.click(); +}; + +export const clickByText = async (page: Page, text: string, element = 'button') => { + logUsingLastSection(`Click by text ${text} ${element}`); + const selector = page.locator(element).filter({ hasText: text }); + await selector.scrollIntoViewIfNeeded(); + await expect(selector).not.toHaveAttribute('disabled', ''); + await selector.hover(); + await selector.click(); +}; + +export const gotoSegments = async (page: Page) => { + await click(page, '#segments-link'); +}; + +export const createRole = async (page: Page, roleName: string, index: number, users: number[]) => { + await click(page, byId('tab-item-roles')); + await click(page, byId('create-role')); + await setText(page, byId('role-name'), roleName); + await click(page, byId('save-role')); + await click(page, byId(`role-${index}`)); + await click(page, byId('members-tab')); + await click(page, byId('assigned-users')); + for (const userId of users) { + await click(page, byId(`assignees-list-item-${userId}`)); + } + await closeModal(page); +}; + +export const editRoleMembers = async (page: Page, index: number, roleName: string) => { + await click(page, byId('tab-item-roles')); + await click(page, byId('create-role')); + await setText(page, byId('role-name'), roleName); + await click(page, byId('save-role')); +}; + +export const gotoTraits = async (page: Page) => { + await click(page, '#features-link'); + await click(page, '#users-link'); + await click(page, byId('user-item-0')); + await waitForElementVisible(page, '#add-trait'); +}; + +export const createTrait = async ( + page: Page, + index: number, + id: string, + value: string | boolean | number, +) => { + await click(page, '#add-trait'); + await waitForElementVisible(page, '#create-trait-modal'); + await setText(page, '[name="traitID"]', id); + await setText(page, '[name="traitValue"]', `${value}`); + await click(page, '#create-trait-btn'); + await page.waitForTimeout(2000); + await page.reload(); + await waitForElementVisible(page, byId(`user-trait-value-${index}`)); + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await assertTextContent(page, byId(`user-trait-value-${index}`), expectedValue); +}; + +export const deleteTrait = async (page: Page, index: number) => { + await click(page, byId(`delete-user-trait-${index}`)); + await click(page, '#confirm-btn-yes'); + await waitForElementNotExist(page, byId(`user-trait-${index}`)); +}; + +const lastTestSection: Record = {}; +let lastTestName: string | undefined; + +export const logUsingLastSection = (message?: string) => { + log(undefined, message); +}; + +export const log = (section: string | undefined, message?: string) => { + const testName = process.env.TEST_NAME; + const sectionName = section ?? lastTestSection[testName || '']; + + if (lastTestName !== testName || lastTestSection[testName || ''] !== sectionName) { + const ellipsis = section === sectionName ? '' : '...'; + console.log( + '\n', + '\x1b[32m', + `${testName ? `${ellipsis}[${testName} tests] ` : ''}${sectionName}`, + '\x1b[0m', + '\n', + ); + if (testName) { + lastTestSection[testName] = sectionName; + } + lastTestName = testName; + } + if (message) { + console.log(message); + } +}; + +export const viewFeature = async (page: Page, index: number) => { + await click(page, byId(`feature-item-${index}`)); + await waitForElementVisible(page, '#create-feature-modal'); +}; + +export const addSegmentOverrideConfig = async ( + page: Page, + index: number, + value: string | boolean | number, + selectionIndex = 0, +) => { + await click(page, byId('segment_overrides')); + await click(page, byId(`select-segment-option-${selectionIndex}`)); + await waitForElementVisible(page, byId(`segment-override-value-${index}`)); + await setText(page, byId(`segment-override-value-${index}`), `${value}`); + await click(page, byId(`segment-override-toggle-${index}`)); +}; + +export const addSegmentOverride = async ( + page: Page, + index: number, + value: string | boolean | number, + selectionIndex = 0, + mvs: MultiVariate[] = [], +) => { + await click(page, byId('segment_overrides')); + await click(page, byId(`select-segment-option-${selectionIndex}`)); + await waitForElementVisible(page, byId(`segment-override-value-${index}`)); + if (value) { + await click(page, `${byId(`segment-override-${index}`)} [role="switch"]`); + } + if (mvs) { + await Promise.all( + mvs.map(async (v) => { + await setText( + page, + `.segment-overrides ${byId(`featureVariationWeight${v.value}`)}`, + `${v.weight}`, + ); + }), + ); + } +}; + +export const saveFeature = async (page: Page) => { + await click(page, '#update-feature-btn'); + await waitForElementVisible(page, '.toast-message'); + await waitForElementNotExist(page, '.toast-message'); + await closeModal(page); + await waitForElementNotExist(page, '#create-feature-modal'); +}; + +export const saveFeatureSegments = async (page: Page) => { + await click(page, '#update-feature-segments-btn'); + await waitForElementVisible(page, '.toast-message'); + await waitForElementNotExist(page, '.toast-message'); + await closeModal(page); + await waitForElementNotExist(page, '#create-feature-modal'); +}; + +export const createEnvironment = async (page: Page, name: string) => { + await setText(page, '[name="envName"]', name); + await click(page, '#create-env-btn'); + await waitForElementVisible(page, byId(`switch-environment-${name.toLowerCase()}-active`)); +}; + +export const goToUser = async (page: Page, index: number) => { + await click(page, '#features-link'); + await click(page, '#users-link'); + await click(page, byId(`user-item-${index}`)); +}; + +export const gotoFeature = async (page: Page, index: number) => { + await click(page, byId(`feature-item-${index}`)); + await waitForElementVisible(page, '#create-feature-modal'); +}; + +export const setSegmentOverrideIndex = async ( + page: Page, + index: number, + newIndex: number, +) => { + await click(page, byId('segment_overrides')); + await setText(page, byId(`sort-${index}`), `${newIndex}`); +}; + +export const assertTextContent = async (page: Page, selector: string, v: string) => + await expect(page.locator(selector)).toHaveText(v); + +export const assertTextContentContains = async (page: Page, selector: string, v: string) => + await expect(page.locator(selector)).toContainText(v); + +export const getText = async (page: Page, selector: string) => + await page.locator(selector).innerText(); + +export const deleteSegment = async (page: Page, index: number, name: string) => { + await click(page, byId(`remove-segment-btn-${index}`)); + await setText(page, '[name="confirm-segment-name"]', name); + await click(page, '#confirm-remove-segment-btn'); + await waitForElementNotExist(page, `remove-segment-btn-${index}`); +}; + +export const login = async (page: Page, email: string, password: string) => { + await page.goto(''); + // await page.addScriptTag({ path: '../../api/index.js' }); + await setText(page, '[name="email"]', `${email}`); + await setText(page, '[name="password"]', `${password}`); + await click(page, '#login-btn'); + await waitForElementVisible(page, '#project-manage-widget'); +}; + +export const logout = async (page: Page) => { + await click(page, '#account-settings-link'); + await click(page, '#logout-link'); + await waitForElementVisible(page, '#login-page'); +}; + +export const goToFeatureVersions = async (page: Page, featureIndex: number) => { + await gotoFeature(page, featureIndex); + await click(page, byId('change-history')); +}; + +export const compareVersion = async ( + page: Page, + featureIndex: number, + versionIndex: number, + compareOption: 'LIVE' | 'PREVIOUS' | null, + oldEnabled: boolean, + newEnabled: boolean, + oldValue?: FlagsmithValue, + newValue?: FlagsmithValue, +) => { + await goToFeatureVersions(page, featureIndex); + await click(page, byId(`history-item-${versionIndex}-compare`)); + if (compareOption === 'LIVE') { + await click(page, byId(`history-item-${versionIndex}-compare-live`)); + } else if (compareOption === 'PREVIOUS') { + await click(page, byId(`history-item-${versionIndex}-compare-previous`)); + } + + await assertTextContent(page, byId(`old-enabled`), `${oldEnabled}`); + await assertTextContent(page, byId(`new-enabled`), `${newEnabled}`); + if (oldValue) { + await assertTextContent(page, byId(`old-value`), `${oldValue}`); + } + if (newValue) { + await assertTextContent(page, byId(`old-value`), `${oldValue}`); + } + await closeModal(page); +}; + +export const assertNumberOfVersions = async (page: Page, index: number, versions: number) => { + await goToFeatureVersions(page, index); + await waitForElementVisible(page, byId(`history-item-${versions - 2}-compare`)); + await closeModal(page); +}; + +export const createRemoteConfig = async ( + page: Page, + index: number, + name: string, + value: string | number | boolean, + description = 'description', + defaultOff?: boolean, + mvs: MultiVariate[] = [], +) => { + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await gotoFeatures(page); + await click(page, '#show-create-feature-btn'); + await setText(page, byId('featureID'), name); + await setText(page, byId('featureValue'), `${value}`); + await setText(page, byId('featureDesc'), description); + if (!defaultOff) { + await click(page, byId('toggle-feature-button')); + } + await Promise.all( + mvs.map(async (v, i) => { + await click(page, byId('add-variation')); + await setText(page, byId(`featureVariationValue${i}`), v.value); + await setText(page, byId(`featureVariationWeight${v.value}`), `${v.weight}`); + }), + ); + await click(page, byId('create-feature-btn')); + await waitForElementVisible(page, byId(`feature-value-${index}`)); + await assertTextContent(page, byId(`feature-value-${index}`), expectedValue); + await closeModal(page); +}; + +export const createOrganisationAndProject = async (page: Page, organisationName: string, projectName: string) => { + log('Create Organisation'); + await click(page, byId('home-link')); + await click(page, byId('create-organisation-btn')); + await setText(page, '[name="orgName"]', organisationName); + await click(page, '#create-org-btn'); + await waitForElementVisible(page, byId('project-manage-widget')); + + log('Create Project'); + await click(page, '.btn-project-create'); + await setText(page, byId('projectName'), projectName); + await click(page, byId('create-project-btn')); + await waitForElementVisible(page, byId('features-page')); +}; + +export const editRemoteConfig = async ( + page: Page, + index: number, + value: string | number | boolean, + toggleFeature = false, + mvs: MultiVariate[] = [], +) => { + const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}`; + await gotoFeatures(page); + await click(page, byId(`feature-item-${index}`)); + await setText(page, byId('featureValue'), `${value}`); + if (toggleFeature) { + await click(page, byId('toggle-feature-button')); + } + await Promise.all( + mvs.map(async (v) => { + await setText(page, byId(`featureVariationWeight${v.value}`), `${v.weight}`); + }), + ); + await click(page, byId('update-feature-btn')); + if (value) { + await waitForElementVisible(page, byId(`feature-value-${index}`)); + await assertTextContent(page, byId(`feature-value-${index}`), expectedValue); + } + await closeModal(page); +}; + +export const closeModal = async (page: Page) => { + await page.click('body', { + position: { x: 50, y: 50 }, + }); +}; + +export const createFeature = async ( + page: Page, + index: number, + name: string, + value?: string | boolean | number, + description = 'description', +) => { + await gotoFeatures(page); + await click(page, '#show-create-feature-btn'); + await setText(page, byId('featureID'), name); + await setText(page, byId('featureDesc'), description); + if (value) { + await click(page, byId('toggle-feature-button')); + } + await click(page, byId('create-feature-btn')); + await waitForElementVisible(page, byId(`feature-item-${index}`)); + await closeModal(page); +}; + +export const deleteFeature = async (page: Page, index: number, name: string) => { + await click(page, byId(`feature-action-${index}`)); + await waitForElementVisible(page, byId(`remove-feature-btn-${index}`)); + await click(page, byId(`remove-feature-btn-${index}`)); + await setText(page, '[name="confirm-feature-name"]', name); + await click(page, '#confirm-remove-feature-btn'); + await waitForElementNotExist(page, `remove-feature-btn-${index}`); +}; + +export const toggleFeature = async (page: Page, index: number, toValue: boolean) => { + await click(page, byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)); + await click(page, '#confirm-toggle-feature-btn'); + await waitForElementVisible(page, byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`)); +}; + +export const setUserPermissions = async (page: Page, index: number, toValue: boolean) => { + await click(page, byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)); + await click(page, '#confirm-toggle-feature-btn'); + await waitForElementVisible(page, byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`)); +}; + +export const setSegmentRule = async ( + page: Page, + ruleIndex: number, + orIndex: number, + name: string, + operator: string, + value: string | number | boolean, +) => { + await setText(page, byId(`rule-${ruleIndex}-property-${orIndex}`), name); + if (operator) { + await setText(page, byId(`rule-${ruleIndex}-operator-${orIndex}`), operator); + } + await setText(page, byId(`rule-${ruleIndex}-value-${orIndex}`), `${value}`); +}; + +export const createSegment = async ( + page: Page, + index: number, + id: string, + rules?: Rule[], +) => { + await click(page, byId('show-create-segment-btn')); + await setText(page, byId('segmentID'), id); + if (rules) { + for (let x = 0; x < rules.length; x++) { + const rule = rules[x]; + if (x > 0) { + await click(page, byId('add-rule')); + } + await setSegmentRule(page, x, 0, rule.name, rule.operator, rule.value); + if (rule.ors) { + for (let orIndex = 0; orIndex < rule.ors.length; orIndex++) { + const or = rule.ors[orIndex]; + await click(page, byId(`rule-${x}-or`)); + await setSegmentRule(page, x, orIndex + 1, or.name, or.operator, or.value); + } + } + } + } + + await click(page, byId('create-segment')); + await waitForElementVisible(page, byId(`segment-${index}-name`)); + await assertTextContent(page, byId(`segment-${index}-name`), id); + await closeModal(page); +}; + +export const waitAndRefresh = async (page: Page, waitFor = 3000) => { + logUsingLastSection(`Waiting for ${waitFor}ms, then refreshing.`); + await page.waitForTimeout(waitFor); + await page.reload(); +}; + +export const refreshUntilElementVisible = async (page: Page, selector: string, maxRetries = 20) => { + const element = page.locator(selector); + let retries = 0; + while (retries < maxRetries && !(await element.isVisible())) { + await page.reload(); + await page.waitForTimeout(3000); + retries++; + } + await element.scrollIntoViewIfNeeded(); +}; + +const permissionsMap = { + 'CREATE_PROJECT': 'organisation', + 'MANAGE_USERS': 'organisation', + 'MANAGE_USER_GROUPS': 'organisation', + 'VIEW_PROJECT': 'project', + 'CREATE_ENVIRONMENT': 'project', + 'DELETE_FEATURE': 'project', + 'CREATE_FEATURE': 'project', + 'MANAGE_SEGMENTS': 'project', + 'VIEW_AUDIT_LOG': 'project', + 'VIEW_ENVIRONMENT': 'environment', + 'UPDATE_FEATURE_STATE': 'environment', + 'MANAGE_IDENTITIES': 'environment', + 'CREATE_CHANGE_REQUEST': 'environment', + 'APPROVE_CHANGE_REQUEST': 'environment', + 'VIEW_IDENTITIES': 'environment', + 'MANAGE_SEGMENT_OVERRIDES': 'environment', + 'MANAGE_TAGS': 'project', +} as const; + +export const setUserPermission = async ( + page: Page, + email: string, + permission: keyof typeof permissionsMap | 'ADMIN', + entityName: string | null, + entityLevel?: 'project' | 'environment' | 'organisation', + parentName?: string, +) => { + await click(page, byId('users-and-permissions')); + await click(page, byId(`user-${email}`)); + const level = permissionsMap[permission] || entityLevel; + await click(page, byId(`${level}-permissions-tab`)); + if (parentName) { + await clickByText(page, parentName, 'a'); + } + if (entityName) { + await click(page, byId(`permissions-${entityName.toLowerCase()}`)); + } + if (permission === 'ADMIN') { + await click(page, byId(`admin-switch-${level}`)); + } else { + await click(page, byId(`permission-switch-${permission}`)); + } + await closeModal(page); +}; + +export default {}; diff --git a/frontend/e2e/index.cafe.js b/frontend/e2e/index.cafe.js deleted file mode 100644 index 4a06df83aeff..000000000000 --- a/frontend/e2e/index.cafe.js +++ /dev/null @@ -1,76 +0,0 @@ -const createTestCafe = require('testcafe'); -const fs = require('fs'); -const path = require('path'); -const { fork } = require('child_process'); -const _options = require("../.testcaferc") -const upload = require('../bin/upload-file'); -const minimist = require('minimist'); -const options = { - ..._options, - browsers: process.env.E2E_DEV ? ['firefox'] : ['firefox:headless'], - debugOnFail: !!process.env.E2E_DEV -} -let testcafe; -let server; -const dir = path.join(__dirname, '../reports/screen-captures'); -if (fs.existsSync(dir)) { - fs.rmdirSync(dir, { recursive: true }); -} -const start = Date.now().valueOf(); -// Parse CLI arg --meta-filter -const args = minimist(process.argv.slice(2)); -const filterString = args['meta-filter']; // "type=smoke,priority=high" -const metaConditions = (filterString || '') - .split(',') - .map(pair => { - const [key, value] = pair.split('='); - return { key, value }; - }); -createTestCafe() - .then(async (tc) => { - testcafe = tc; - await new Promise((resolve) => { - process.env.PORT = 3000; - console.log(process.env.E2E_LOCAL) - if (process.env.E2E_LOCAL) { - resolve() - } else { - server = fork('./api/index'); - server.on('message', () => { - resolve(); - }); - } - }); - const runner = testcafe.createRunner() - const args = process.argv.splice(2).map(value => value.toLowerCase()); - console.log('Filter tests:', args) - const concurrentInstances = process.env.E2E_CONCURRENCY ?? 3 - console.log('E2E Concurrency:', concurrentInstances) - - return runner - .clientScripts('e2e/add-error-logs.js') - .src(['./e2e/init.cafe.js']) - .concurrency(parseInt(concurrentInstances)) - .filter((_, __, ___, testMeta, fixtureMeta) => - metaConditions.some(({ key, value }) => - testMeta[key] === value || fixtureMeta[key] === value - ) - ) - .run(options) - }) - .then(async (v) => { - // Upload files - console.log(`Test failures ${v} in ${Date.now().valueOf() - start}ms`); - if (fs.existsSync(dir) && !process.env.E2E_DEV) { - try { - const files = fs.readdirSync(dir); - await Promise.all(files.map(f => upload(path.join(dir, f)))); - } catch (e) { console.log('error uploading files', e); } - } else { - console.log('No files to upload'); - } - // Shut down server and testcafe - server.kill('SIGINT'); - testcafe.close(); - process.exit(v); - }); diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js deleted file mode 100644 index 4888a88b881c..000000000000 --- a/frontend/e2e/init.cafe.js +++ /dev/null @@ -1,116 +0,0 @@ -import fetch from 'node-fetch' -import { test, fixture } from 'testcafe' -import { waitForReact } from 'testcafe-react-selectors' - -import Project from '../common/project' -import { getLogger, log, logout, logResults } from './helpers.cafe' -import environmentTest from './tests/environment-test' -import inviteTest from './tests/invite-test' -import projectTest from './tests/project-test' -import { testSegment1, testSegment2, testSegment3 } from './tests/segment-test' -import initialiseTests from './tests/initialise-tests' -import flagTests from './tests/flag-tests' -import versioningTests from './tests/versioning-tests' -import organisationPermissionTest from './tests/organisation-permission-test' -import projectPermissionTest from './tests/project-permission-test' -import environmentPermissionTest from './tests/environment-permission-test' -import rolesTest from './tests/roles-test' - -require('dotenv').config() - -const url = `http://localhost:${process.env.PORT || 8080}/` -const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/` -const logger = getLogger() - -console.log( - '\n', - '\x1b[32m', - `E2E using API: ${e2eTestApi}. E2E URL: ${url}`, - '\x1b[0m', - '\n', -) - -fixture`E2E Tests`.requestHooks(logger).before(async () => { - const token = process.env.E2E_TEST_TOKEN - ? process.env.E2E_TEST_TOKEN - : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`] - - if (token) { - await fetch(e2eTestApi, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-E2E-Test-Auth-Token': token.trim(), - }, - body: JSON.stringify({}), - }).then((res) => { - if (res.ok) { - // eslint-disable-next-line no-console - console.log( - '\n', - '\x1b[32m', - 'e2e teardown successful', - '\x1b[0m', - '\n', - ) - } else { - // eslint-disable-next-line no-console - console.error( - '\n', - '\x1b[31m', - 'e2e teardown failed', - res.status, - '\x1b[0m', - '\n', - ) - } - console.log('Starting E2E tests') - }) - } else { - // eslint-disable-next-line no-console - console.error( - '\n', - '\x1b[31m', - 'e2e teardown failed - no available token', - '\x1b[0m', - '\n', - ) - } -}).page`${url}` - .beforeEach(async () => { - await waitForReact() - }) - .afterEach(async (t) => { - if (t.test.meta.autoLogout) { - log('Log out') - await logout() - } - await logResults(logger.requests, t) - }) - -test('Segment-part-1', testSegment1).meta({ autoLogout: true, category: 'oss' }) - -test('Segment-part-2', testSegment2).meta({ autoLogout: true, category: 'oss' }) - -test('Segment-part-3', testSegment3).meta({ autoLogout: true, category: 'oss' }) - -test('Flag', flagTests).meta({ autoLogout: true, category: 'oss' }) - -test('Signup', initialiseTests).meta({ autoLogout: true, category: 'oss' }) - -test('Invite', inviteTest).meta({ category: 'oss' }) - -test('Environment', environmentTest).meta({ autoLogout: true, category: 'oss' }) - -test('Project', projectTest).meta({ autoLogout: true, category: 'oss' }) - -test('Versioning', versioningTests).meta({ autoLogout: true, category: 'oss' }) - -test('Organisation-permission', organisationPermissionTest).meta({ autoLogout: true, category: 'enterprise' }) - -test('Project-permission', projectPermissionTest).meta({ autoLogout: true, category: 'enterprise' }) - -test('Environment-permission', environmentPermissionTest).meta({ autoLogout: true, category: 'enterprise' }) - -test('Roles', rolesTest).meta({ autoLogout: true, category: 'enterprise' }) diff --git a/frontend/e2e/tests/environment-permission-test.spec.ts b/frontend/e2e/tests/environment-permission-test.spec.ts new file mode 100644 index 000000000000..bc018e6ae19e --- /dev/null +++ b/frontend/e2e/tests/environment-permission-test.spec.ts @@ -0,0 +1,160 @@ +import { test, expect } from '@playwright/test'; +import { + byId, + click, + clickByText, + closeModal, + createEnvironment, + createFeature, + editRemoteConfig, + gotoTraits, + log, + login, + logout, + setUserPermission, + toggleFeature, + waitForElementClickable, + waitForElementNotClickable, + waitForElementNotExist, + waitForElementVisible, +} from '../helpers/helpers'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, + E2E_USER, +} from '../config'; + +test('@enterprise Environment permissions test', async ({ page }) => { + log('Login'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + + log('User can only view project'); + await click(page, '#project-select-0'); + await expect(page.locator('#project-select-1')).not.toBeVisible(); + await logout(page); + + log('User with permissions can Handle the Features'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await createFeature(page, 0, 'test_feature', false); + await toggleFeature(page, 0, true); + await logout(page); + + log('User without permissions cannot create traits'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await gotoTraits(page); + await expect(page.locator(byId('add-trait'))).toHaveAttribute('disabled', ''); + await logout(page); + + log('User without permissions cannot see audit logs'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementNotExist(page, byId('audit-log-link')); + await logout(page); + + log('Create new environment'); + await login(page, E2E_USER, PASSWORD); + await clickByText(page, 'My Test Project 6 Env Permission'); + await click(page, '#create-env-link'); + await createEnvironment(page, 'Production'); + await logout(page); + + log('User without permissions cannot see environment'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementVisible(page, byId('switch-environment-development')); + await waitForElementNotExist(page, byId('switch-environment-production')); + await logout(page); + + log('Grant view environment permission'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_ENVIRONMENT', 'Production', 'environment', 'My Test Project 6 Env Permission'); + await logout(page); + + log('User with permissions can see environment'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementVisible(page, byId('switch-environment-production')); + await waitForElementVisible(page, byId('switch-environment-production')); + await logout(page); + + log('User with permissions can update feature state'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await createFeature(page, 0, 'my_feature', "foo", 'A test feature'); + await editRemoteConfig(page, 0, 'bar'); + await logout(page); + + log('User without permission cannot create a segment override'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, byId('feature-item-0')); + await click(page, byId('segment_overrides')); + await waitForElementNotClickable(page, '#update-feature-segments-btn'); + await closeModal(page); + await logout(page); + + log('Grant MANAGE_IDENTITIES permission'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_SEGMENT_OVERRIDES', 'Development', 'environment', 'My Test Project 6 Env Permission'); + await logout(page); + + log('User with permission can create a segment override'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, byId('feature-item-0')); + await click(page, byId('segment_overrides')); + await waitForElementClickable(page, '#update-feature-segments-btn'); + await closeModal(page); + await logout(page); + + log('User without permissions cannot update feature state'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementClickable(page, byId('feature-switch-0-on')); + await click(page, byId('switch-environment-production')); + await waitForElementNotClickable(page, byId('feature-switch-0-on')); + await click(page, byId('feature-item-0')); + await waitForElementNotClickable(page, byId('update-feature-btn')); + await closeModal(page); + await logout(page); + + log('User with permissions can view identities'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementVisible(page, '#users-link'); + await logout(page); + + log('User without permissions cannot add user trait'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, '#users-link'); + await click(page, byId('user-item-0')); + await waitForElementNotClickable(page, byId('add-trait')); + await logout(page); + + log('Grant MANAGE_IDENTITIES permission'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission'); + await logout(page); + + log('User with permissions can add user trait'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, '#users-link'); + await click(page, byId('user-item-0')); + await waitForElementClickable(page, byId('add-trait')); + await logout(page); + + log('Remove VIEW_IDENTITIES permission'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission'); + await logout(page); + + log('User without permissions cannot view identities'); + await login(page, E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, '#users-link'); + await waitForElementVisible(page, byId('missing-view-identities')); +}); diff --git a/frontend/e2e/tests/environment-permission-test.ts b/frontend/e2e/tests/environment-permission-test.ts deleted file mode 100644 index ea072faebb72..000000000000 --- a/frontend/e2e/tests/environment-permission-test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - byId, - click, clickByText, closeModal, createEnvironment, - createFeature, editRemoteConfig, - gotoTraits, - log, - login, logout, setUserPermission, - toggleFeature, waitForElementClickable, waitForElementNotClickable, waitForElementNotExist, waitForElementVisible, -} from '../helpers.cafe'; -import { - PASSWORD, - E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, - E2E_USER, -} from '../config'; -import { Selector, t } from 'testcafe' - -export default async function () { - log('Login') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - log('User can only view project') - await click('#project-select-0') - await t - .expect(Selector('#project-select-1').exists) - .notOk('The element"#project-select-1" should not be present') - await logout() - - log('User with permissions can Handle the Features') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createFeature(0, 'test_feature', false) - await toggleFeature(0, true) - await logout() - - log('User without permissions cannot create traits') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await gotoTraits() - const createTraitBtn = Selector(byId('add-trait')) - await t.expect(createTraitBtn.hasAttribute('disabled')).ok() - await logout() - - log('User without permissions cannot see audit logs') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotExist(byId('audit-log-link')) - await logout() - - log('Create new environment') - await login(E2E_USER, PASSWORD) - await clickByText('My Test Project 6 Env Permission') - await click('#create-env-link') - await createEnvironment('Production') - await logout() - log('User without permissions cannot see environment') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible(byId('switch-environment-development')) - await waitForElementNotExist(byId('switch-environment-production')) - await logout() - - log('Grant view environment permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_ENVIRONMENT', 'Production', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User with permissions can see environment') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible(byId('switch-environment-production')) - await waitForElementVisible(byId('switch-environment-production')) - await logout() - - log('User with permissions can update feature state') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createFeature(0,'my_feature',"foo",'A test feature') - await editRemoteConfig(0, 'bar') - await logout() - log('User without permission cannot create a segment override') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-item-0')) - await click(byId('segment_overrides')) - await waitForElementNotClickable('#update-feature-segments-btn') - await closeModal() - await logout() - log('Grant MANAGE_IDENTITIES permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_SEGMENT_OVERRIDES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User with permission can create a segment override') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-item-0')) - await click(byId('segment_overrides')) - await waitForElementClickable('#update-feature-segments-btn') - await closeModal() - await logout() - - log('User without permissions cannot update feature state') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementClickable(byId('feature-switch-0-on')) - await click(byId('switch-environment-production')) - await waitForElementNotClickable(byId('feature-switch-0-on')) - await click(byId('feature-item-0')) - await waitForElementNotClickable(byId('update-feature-btn')) - await closeModal() - await logout() - - log('User with permissions can view identities') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible('#users-link') - await logout() - - log('User without permissions cannot add user trait') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click('#users-link') - await click(byId('user-item-0')) - await waitForElementNotClickable(byId('add-trait')) - await logout() - - log('Grant MANAGE_IDENTITIES permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'MANAGE_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User with permissions can add user trait') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click('#users-link') - await click(byId('user-item-0')) - await waitForElementClickable(byId('add-trait')) - await logout() - - - log('Remove VIEW_IDENTITIES permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, 'VIEW_IDENTITIES', 'Development', 'environment', 'My Test Project 6 Env Permission' ) - await logout() - log('User without permissions cannot view identities') - await login(E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click('#users-link') - await waitForElementVisible(byId('missing-view-identities')) -} diff --git a/frontend/e2e/tests/environment-test.spec.ts b/frontend/e2e/tests/environment-test.spec.ts new file mode 100644 index 000000000000..449fd488c9a7 --- /dev/null +++ b/frontend/e2e/tests/environment-test.spec.ts @@ -0,0 +1,33 @@ +import { test } from '@playwright/test'; +import { + byId, + click, + createEnvironment, + log, + login, + setText, + waitForElementVisible, +} from '../helpers/helpers'; +import { PASSWORD, E2E_USER } from '../config'; + +test('@oss Environment management test', async ({ page }) => { + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, '#project-select-0'); + + log('Create environment'); + await click(page, '#create-env-link'); + await createEnvironment(page, 'Staging'); + + log('Edit Environment'); + await click(page, '#env-settings-link'); + await setText(page, "[name='env-name']", 'Internal'); + await click(page, '#save-env-btn'); + await waitForElementVisible(page, byId('switch-environment-internal-active')); + + log('Delete environment'); + await click(page, '#delete-env-btn'); + await setText(page, "[name='confirm-env-name']", 'Internal'); + await click(page, '#confirm-delete-env-btn'); + await waitForElementVisible(page, byId('features-page')); +}); diff --git a/frontend/e2e/tests/environment-test.ts b/frontend/e2e/tests/environment-test.ts deleted file mode 100644 index 7749d32e0971..000000000000 --- a/frontend/e2e/tests/environment-test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - byId, - click, - createEnvironment, - log, - login, - setText, - waitForElementVisible, -} from '../helpers.cafe'; -import { PASSWORD, E2E_USER } from '../config' - -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-0') - log('Create environment') - await click('#create-env-link') - await createEnvironment('Staging') - log('Edit Environment') - await click('#env-settings-link') - await setText("[name='env-name']", 'Internal') - await click('#save-env-btn') - await waitForElementVisible(byId('switch-environment-internal-active')) - log('Delete environment') - await click('#delete-env-btn') - await setText("[name='confirm-env-name']", 'Internal') - await click('#confirm-delete-env-btn') - await waitForElementVisible(byId('features-page')) -} diff --git a/frontend/e2e/tests/flag-tests.spec.ts b/frontend/e2e/tests/flag-tests.spec.ts new file mode 100644 index 000000000000..3dcb5d45a9d6 --- /dev/null +++ b/frontend/e2e/tests/flag-tests.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; +import { + byId, + click, + closeModal, + createFeature, + createRemoteConfig, + deleteFeature, + editRemoteConfig, + getText, + log, + login, + toggleFeature, + waitForElementVisible, +} from '../helpers/helpers'; +import { E2E_USER, PASSWORD } from '../config'; + +test('@oss Feature flag management test', async ({ page }) => { + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, '#project-select-0'); + + log('Create Features'); + await click(page, '#features-link'); + + await createRemoteConfig(page, 0, 'header_size', 'big'); + await createRemoteConfig(page, 0, 'mv_flag', 'big', null, null, [ + { value: 'medium', weight: 100 }, + { value: 'small', weight: 0 }, + ]); + await createFeature(page, 1, 'header_enabled', false); + + log('Create Short Life Feature'); + await createFeature(page, 3, 'short_life_feature', false); + await page.evaluate(() => { + window.scrollBy(0, 15000); + }); + + log('Delete Short Life Feature'); + await deleteFeature(page, 3, 'short_life_feature'); + await page.evaluate(() => { + window.scrollBy(0, 30000); + }); + + log('Toggle Feature'); + await toggleFeature(page, 0, true); + + log('Try it'); + await page.waitForTimeout(2000); + await click(page, '#try-it-btn'); + await page.waitForTimeout(1500); + let text = await getText(page, '#try-it-results'); + let json; + try { + json = JSON.parse(text); + } catch (e) { + throw new Error('Try it results are not valid JSON'); + } + expect(json.header_size.value).toBe('big'); + expect(json.mv_flag.value).toBe('big'); + expect(json.header_enabled.enabled).toBe(true); + + log('Update feature'); + await editRemoteConfig(page, 1, 12); + + log('Try it again'); + await page.waitForTimeout(2000); + await click(page, '#try-it-btn'); + await page.waitForTimeout(1500); + text = await getText(page, '#try-it-results'); + try { + json = JSON.parse(text); + } catch (e) { + throw new Error('Try it results are not valid JSON'); + } + expect(json.header_size.value).toBe(12); + + log('Change feature value to boolean'); + await editRemoteConfig(page, 1, false); + + log('Try it again 2'); + await page.waitForTimeout(2000); + await click(page, '#try-it-btn'); + await page.waitForTimeout(1500); + text = await getText(page, '#try-it-results'); + try { + json = JSON.parse(text); + } catch (e) { + throw new Error('Try it results are not valid JSON'); + } + expect(json.header_size.value).toBe(false); + + log('Switch environment'); + await click(page, byId('switch-environment-production')); + + log('Feature should be off under different environment'); + await waitForElementVisible(page, byId('switch-environment-production-active')); + await waitForElementVisible(page, byId('feature-switch-0-off')); + + log('Clear down features'); + await deleteFeature(page, 1, 'header_size'); + await deleteFeature(page, 0, 'header_enabled'); +}); diff --git a/frontend/e2e/tests/flag-tests.ts b/frontend/e2e/tests/flag-tests.ts deleted file mode 100644 index 754106aab30e..000000000000 --- a/frontend/e2e/tests/flag-tests.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - byId, - click, - closeModal, - createFeature, - createRemoteConfig, - deleteFeature, editRemoteConfig, - getText, - log, - login, - toggleFeature, - waitForElementVisible, -} from '../helpers.cafe'; -import { t } from 'testcafe'; -import { E2E_USER, PASSWORD } from '../config'; - -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-0') - - log('Create Features') - await click('#features-link') - - await createRemoteConfig(0, 'header_size', 'big') - await createRemoteConfig(0, 'mv_flag', 'big', null, null, [ - { value: 'medium', weight: 100 }, - { value: 'small', weight: 0 }, - ]) - await createFeature(1, 'header_enabled', false) - - log('Create Short Life Feature') - await createFeature(3, 'short_life_feature', false) - await t.eval(() => { - window.scrollBy(0, 15000) - }) - - log('Delete Short Life Feature') - await deleteFeature(3, 'short_life_feature') - await t.eval(() => { - window.scrollBy(0, 30000) - }) - - log('Toggle Feature') - await toggleFeature(0, true) - - log('Try it') - await t.wait(2000) - await click('#try-it-btn') - await t.wait(1500) - let text = await getText('#try-it-results') - let json - try { - json = JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } - await t.expect(json.header_size.value).eql('big') - await t.expect(json.mv_flag.value).eql('big') - await t.expect(json.header_enabled.enabled).eql(true) - - log('Update feature') - await editRemoteConfig(1,12) - - log('Try it again') - await t.wait(2000) - await click('#try-it-btn') - await t.wait(1500) - text = await getText('#try-it-results') - try { - json = JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } - await t.expect(json.header_size.value).eql(12) - - log('Change feature value to boolean') - await editRemoteConfig(1,false) - - log('Try it again 2') - await t.wait(2000) - await click('#try-it-btn') - await t.wait(1500) - text = await getText('#try-it-results') - try { - json = JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } - await t.expect(json.header_size.value).eql(false) - - log('Switch environment') - await click(byId('switch-environment-production')) - - log('Feature should be off under different environment') - await waitForElementVisible(byId('switch-environment-production-active')) - await waitForElementVisible(byId('feature-switch-0-off')) - - log('Clear down features') - await deleteFeature(1, 'header_size') - await deleteFeature(0, 'header_enabled') -} diff --git a/frontend/e2e/tests/global.setup.ts b/frontend/e2e/tests/global.setup.ts new file mode 100644 index 000000000000..baec6c825e73 --- /dev/null +++ b/frontend/e2e/tests/global.setup.ts @@ -0,0 +1,44 @@ +import { test as setup } from '@playwright/test'; +import Project from '../../common/project'; + + + +require('dotenv').config() + + +async function runAPITeardown() { + const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/` + const token = process.env.E2E_TEST_TOKEN + ? process.env.E2E_TEST_TOKEN + : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`]; + + console.log(`E2E using API: ${e2eTestApi}`) + + if (token) { + try { + const response = await fetch(e2eTestApi, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-E2E-Test-Auth-Token': token.trim(), + }, + body: JSON.stringify({}), + }); + + if (response.ok) { + console.log('\n\x1b[32m%s\x1b[0m\n', 'e2e teardown successful'); + } else { + console.error('\n\x1b[31m%s\x1b[0m\n', `e2e teardown failed ${response.status}`); + } + } catch (error) { + console.error('\n\x1b[31m%s\x1b[0m\n', 'e2e teardown failed - request error'); + } + } else { + console.error('\n\x1b[31m%s\x1b[0m\n', 'e2e teardown failed - no available token'); + } +} + +setup('API teardown', async ({ }) => { + await runAPITeardown(); +}); diff --git a/frontend/e2e/tests/global.teardown.ts b/frontend/e2e/tests/global.teardown.ts new file mode 100644 index 000000000000..8ffad24444b0 --- /dev/null +++ b/frontend/e2e/tests/global.teardown.ts @@ -0,0 +1,22 @@ +import { test as teardown } from '@playwright/test'; + +teardown('kill bundled app', async ({ }) => { + + if (process.env.E2E_LOCAL) { + console.log('E2E_LOCAL is set, skipping server stop') + return + } + + const pid = process.env.PLAYWRIGHT_BUNDLED_SERVER_PID; + if (pid) { + try { + process.kill(Number(pid)); + console.log('Server stopped.'); + } catch (err) { + console.warn('Failed to stop server:', err); + } + } else { + console.log('No bundled app to stop'); + } + console.log('teardown complete'); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/initialise-tests.spec.ts b/frontend/e2e/tests/initialise-tests.spec.ts new file mode 100644 index 000000000000..c8a75bc638b8 --- /dev/null +++ b/frontend/e2e/tests/initialise-tests.spec.ts @@ -0,0 +1,36 @@ +import { test } from '@playwright/test'; +import { + byId, + click, + log, + setText, + waitForElementVisible, +} from '../helpers/helpers'; +import { E2E_SIGN_UP_USER, PASSWORD } from '../config'; + +test('@oss Initial setup test', async ({ page }) => { + log('Create Organisation'); + await click(page, byId('jsSignup')); + await setText(page, byId('firstName'), 'Bullet'); + await setText(page, byId('lastName'), 'Train'); + await setText(page, byId('email'), E2E_SIGN_UP_USER); + await setText(page, byId('password'), PASSWORD); + await click(page, byId('signup-btn')); + await setText(page, '[name="orgName"]', 'Flagsmith Ltd 0'); + await click(page, '#create-org-btn'); + await waitForElementVisible(page, byId('project-manage-widget')); + + log('Create Project'); + await click(page, byId('create-first-project-btn')); + await setText(page, byId('projectName'), 'My Test Project'); + await click(page, byId('create-project-btn')); + await waitForElementVisible(page, byId('features-page')); + + log('Hide disabled flags'); + await click(page, '#project-link'); + await click(page, '#project-settings-link'); + await click(page, byId('js-sdk-settings')); + await click(page, byId('js-hide-disabled-flags')); + await setText(page, byId('js-project-name'), 'My Test Project'); + await click(page, byId('js-confirm')); +}); diff --git a/frontend/e2e/tests/initialise-tests.ts b/frontend/e2e/tests/initialise-tests.ts deleted file mode 100644 index 348628bb6d74..000000000000 --- a/frontend/e2e/tests/initialise-tests.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - byId, - click, - log, - setText, - waitForElementVisible, -} from '../helpers.cafe' -import { E2E_SIGN_UP_USER, PASSWORD } from '../config' - -export default async function () { - log('Create Organisation') - await click(byId('jsSignup')) - await setText(byId('firstName'), 'Bullet') // visit the url - await setText(byId('lastName'), 'Train') // visit the url - await setText(byId('email'), E2E_SIGN_UP_USER) // visit the url - await setText(byId('password'), PASSWORD) // visit the url - await click(byId('signup-btn')) - await setText('[name="orgName"]', 'Flagsmith Ltd 0') - await click('#create-org-btn') - await waitForElementVisible(byId('project-manage-widget')) - - log('Create Project') - await click(byId('create-first-project-btn')) - await setText(byId('projectName'), 'My Test Project') - await click(byId('create-project-btn')) - await waitForElementVisible(byId('features-page')) - - log('Hide disabled flags') - await click('#project-link') - await click('#project-settings-link') - await click(byId('js-sdk-settings')) - await click(byId('js-hide-disabled-flags')) - await setText(byId('js-project-name'), 'My Test Project') - await click(byId('js-confirm')) -} diff --git a/frontend/e2e/tests/invite-test.spec.ts b/frontend/e2e/tests/invite-test.spec.ts new file mode 100644 index 000000000000..b410a9581f8d --- /dev/null +++ b/frontend/e2e/tests/invite-test.spec.ts @@ -0,0 +1,53 @@ +import { test } from '@playwright/test'; +import { + assertTextContent, + byId, + click, + log, + login, + setText, + waitForElementVisible, +} from '../helpers/helpers'; +import { E2E_CHANGE_MAIL, E2E_USER, PASSWORD } from '../config'; + +test('@oss Invite and user management test', async ({ page }) => { + const invitePrefix = `flagsmith${new Date().valueOf()}`; + const inviteEmail = `${invitePrefix}@restmail.net`; + + log('Login'); + await login(page, E2E_USER, PASSWORD); + + log('Get Invite url'); + await waitForElementVisible(page, byId('organisation-link')); + await click(page, byId('organisation-link')); + await waitForElementVisible(page, byId('org-settings-link')); + await click(page, byId('org-settings-link')); + await page.locator(byId('organisation-name')).inputValue(); + await click(page, byId('users-and-permissions')); + const inviteLink = await page.locator(byId('invite-link')).inputValue(); + + log('Accept invite'); + await page.goto(inviteLink); + await setText(page, '[name="email"]', inviteEmail); + await setText(page, byId('firstName'), 'Bullet'); // visit the url + await setText(page, byId('lastName'), 'Train'); + await setText(page, byId('email'), inviteEmail); + await setText(page, byId('password'), PASSWORD); + await waitForElementVisible(page, byId('signup-btn')); + await click(page, byId('signup-btn')); + + log('Change email'); + await click(page, byId('account-settings-link')); + await click(page, byId('change-email-button')); + await setText(page, "[name='EmailAddress']", E2E_CHANGE_MAIL); + await setText(page, "[name='newPassword']", PASSWORD); + await click(page, '#save-changes'); + await login(page, E2E_CHANGE_MAIL, PASSWORD); + + log('Delete invite user'); + await assertTextContent(page, '[id=account-settings-link]', 'Account'); + await click(page, byId('account-settings-link')); + await click(page, byId('delete-user-btn')); + await setText(page, "[name='currentPassword']", PASSWORD); + await click(page, byId('delete-account')); +}); diff --git a/frontend/e2e/tests/invite-test.ts b/frontend/e2e/tests/invite-test.ts deleted file mode 100644 index 12bd356d1859..000000000000 --- a/frontend/e2e/tests/invite-test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - assertTextContent, - byId, - click, - log, - login, - setText, - waitForElementVisible, -} from '../helpers.cafe' -import { Selector, t } from 'testcafe' -import { E2E_CHANGE_MAIL, E2E_USER, PASSWORD } from '../config' - -const invitePrefix = `flagsmith${new Date().valueOf()}` -const inviteEmail = `${invitePrefix}@restmail.net` -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - log('Get Invite url') - await waitForElementVisible(byId('organisation-link')) - await click(byId('organisation-link')) - await waitForElementVisible(byId('org-settings-link')) - await click(byId('org-settings-link')) - await Selector(byId('organisation-name')).value - await click(byId('users-and-permissions')) - const inviteLink = await Selector(byId('invite-link')).value - log('Accept invite') - await t.navigateTo(inviteLink) - await setText('[name="email"]', inviteEmail) - await setText(byId('firstName'), 'Bullet') // visit the url - await setText(byId('lastName'), 'Train') - await setText(byId('email'), inviteEmail) - await setText(byId('password'), PASSWORD) - await waitForElementVisible(byId('signup-btn')) - await click(byId('signup-btn')) - log('Change email') - await click(byId('account-settings-link')) - await click(byId('change-email-button')) - await setText("[name='EmailAddress']", E2E_CHANGE_MAIL) - await setText("[name='newPassword']", PASSWORD) - await click('#save-changes') - await login(E2E_CHANGE_MAIL, PASSWORD) - log('Delete invite user') - await assertTextContent('[id=account-settings-link]', 'Account') - await click(byId('account-settings-link')) - await click(byId('delete-user-btn')) - await setText("[name='currentPassword']", PASSWORD) - await click(byId('delete-account')) -} diff --git a/frontend/e2e/tests/organisation-permission-test.spec.ts b/frontend/e2e/tests/organisation-permission-test.spec.ts new file mode 100644 index 000000000000..04a1abce0d91 --- /dev/null +++ b/frontend/e2e/tests/organisation-permission-test.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { + byId, + click, + clickByText, + closeModal, + log, + login, + logout, + setText, + waitForElementClickable, + waitForElementNotClickable, + waitForElementVisible, +} from '../helpers/helpers'; +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, +} from '../config'; + +test('@enterprise Organization permissions test', async ({ page }) => { + log('Login'); + await login(page, E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, PASSWORD); + + log('User without permissions cannot see any Project'); + await expect(page.locator('#project-select-0')).not.toBeVisible(); + + log('User with permissions can Create a Project'); + await waitForElementClickable(page, byId('create-first-project-btn')); + + log('User can manage groups'); + await click(page, byId('users-and-permissions')); + await clickByText(page, 'Groups'); + await waitForElementClickable(page, "#btn-invite-groups"); + await logout(page); + + log('Login as project user'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + + log('User cannot manage users or groups'); + await click(page, byId('users-and-permissions')); + await clickByText(page, 'Groups'); + await waitForElementNotClickable(page, "#btn-invite-groups"); +}); diff --git a/frontend/e2e/tests/organisation-permission-test.ts b/frontend/e2e/tests/organisation-permission-test.ts deleted file mode 100644 index 24456a18637d..000000000000 --- a/frontend/e2e/tests/organisation-permission-test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - byId, - click, clickByText, - closeModal, - log, - login, logout, - setText, waitForElementClickable, waitForElementNotClickable, - waitForElementVisible, -} from '../helpers.cafe'; -import { - PASSWORD, - E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, - E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, -} from '../config'; -import { Selector, t } from 'testcafe' - -export default async function () { - log('Login') - await login(E2E_NON_ADMIN_USER_WITH_ORG_PERMISSIONS, PASSWORD) - log('User without permissions cannot see any Project') - await t - .expect(Selector('#project-select-0').exists) - .notOk('The element"#project-select-0" should not be present') - log('User with permissions can Create a Project') - await waitForElementClickable( byId('create-first-project-btn')) - - log('User can manage groups') - await click(byId('users-and-permissions')) - await clickByText('Groups') - await waitForElementClickable("#btn-invite-groups") - await logout() - log('Login as project user') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - log('User cannot manage users or groups') - await click(byId('users-and-permissions')) - await clickByText('Groups') - await waitForElementNotClickable("#btn-invite-groups") -} diff --git a/frontend/e2e/tests/project-permission-test.spec.ts b/frontend/e2e/tests/project-permission-test.spec.ts new file mode 100644 index 000000000000..e7c337525e67 --- /dev/null +++ b/frontend/e2e/tests/project-permission-test.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; +import { + byId, + click, + createEnvironment, + createFeature, + gotoSegments, + log, + login, + logout, + setUserPermission, + toggleFeature, + waitForElementNotClickable, + waitForElementNotExist, + waitForElementVisible, +} from '../helpers/helpers'; +import { E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, E2E_USER, PASSWORD } from '../config'; + +test('@enterprise Project permissions test', async ({ page }) => { + log('User with VIEW_PROJECT can only see their project'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await waitForElementNotExist(page, '#project-select-1'); + await logout(page); + + log('User with CREATE_ENVIRONMENT can create an environment'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await createEnvironment(page, 'Staging'); + await logout(page); + + log('User with VIEW_AUDIT_LOG can view the audit log'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, byId('audit-log-link')); + await logout(page); + + log('Remove VIEW_AUDIT_LOG permission'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'VIEW_AUDIT_LOG', 'My Test Project 5 Project Permission', 'project'); + await logout(page); + + log('User without VIEW_AUDIT_LOG cannot view the audit log'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementNotExist(page, 'audit-log-link'); + await logout(page); + + log('User with CREATE_FEATURE can Handle the Features'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await createFeature(page, 0, 'test_feature', false); + await toggleFeature(page, 0, true); + await logout(page); + + log('Remove CREATE_FEATURE permissions'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_FEATURE', 'My Test Project 5 Project Permission', 'project'); + await logout(page); + + log('User without CREATE_FEATURE cannot Handle the Features'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementNotClickable(page, '#show-create-feature-btn'); + await logout(page); + + log('User without ADMIN permissions cannot set other users project permissions'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await waitForElementNotExist(page, '#project-settings-link'); + await logout(page); + + log('Set user as project ADMIN'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project'); + await logout(page); + + log('User with ADMIN permissions can set project settings'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementVisible(page, '#project-settings-link'); + await logout(page); + + log('Remove user as project ADMIN'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project'); + await logout(page); + + log('User without create environment permissions cannot create a new environment'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_ENVIRONMENT', 'My Test Project 5 Project Permission', 'project'); + await logout(page); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await waitForElementNotExist(page, '#create-env-link'); + await logout(page); + + log('User without DELETE_FEATURE permissions cannot Delete any feature'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, byId('feature-action-0')); + await waitForElementVisible(page, byId('remove-feature-btn-0')); + await expect(page.locator(byId('remove-feature-btn-0'))).toHaveClass(/feature-action__item_disabled/); + await logout(page); + + log('Add DELETE_FEATURE permission to user'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'DELETE_FEATURE', 'My Test Project 5 Project Permission', 'project'); + await logout(page); + + log('User with permissions can Delete any feature'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await click(page, byId('feature-action-0')); + await waitForElementVisible(page, byId('remove-feature-btn-0')); + await expect(page.locator(byId('remove-feature-btn-0'))).not.toHaveClass(/feature-action__item_disabled/); + await logout(page); + + log('User without MANAGE_SEGMENTS permissions cannot Manage Segments'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await gotoSegments(page); + await expect(page.locator(byId('show-create-segment-btn'))).toHaveAttribute('disabled', ''); + await logout(page); + + log('Add MANAGE_SEGMENTS permission to user'); + await login(page, E2E_USER, PASSWORD); + await setUserPermission( + page, + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + 'MANAGE_SEGMENTS', + 'My Test Project 5 Project Permission', + 'project' + ); + await logout(page); + + log('User with MANAGE_SEGMENTS permissions can Manage Segments'); + await login(page, E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD); + await click(page, '#project-select-0'); + await gotoSegments(page); + await expect(page.locator(byId('show-create-segment-btn'))).not.toHaveAttribute('disabled', ''); +}); diff --git a/frontend/e2e/tests/project-permission-test.ts b/frontend/e2e/tests/project-permission-test.ts deleted file mode 100644 index 8f68d67df317..000000000000 --- a/frontend/e2e/tests/project-permission-test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { - byId, - click, - createEnvironment, - createFeature, gotoSegments, - log, - login, - logout, - setUserPermission, - toggleFeature, waitForElementNotClickable, waitForElementNotExist, waitForElementVisible, -} from '../helpers.cafe'; -import { E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, E2E_USER, PASSWORD } from '../config'; -import { Selector, t } from 'testcafe'; - -export default async function () { - - log('User with VIEW_PROJECT can only see their project') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await waitForElementNotExist('#project-select-1') - await logout() - - log('User with CREATE_ENVIRONMENT can create an environment') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createEnvironment('Staging') - await logout() - - log('User with VIEW_AUDIT_LOG can view the audit log') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('audit-log-link')) - await logout() - log('Remove VIEW_AUDIT_LOG permission') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'VIEW_AUDIT_LOG', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User without VIEW_AUDIT_LOG cannot view the audit log') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotExist('audit-log-link') - await logout() - - log('User with CREATE_FEATURE can Handle the Features') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await createFeature(0, 'test_feature', false) - await toggleFeature(0, true) - await logout() - log('Remove CREATE_FEATURE permissions') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_FEATURE', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User without CREATE_FEATURE cannot Handle the Features') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotClickable('#show-create-feature-btn') - await logout() - - log('User without ADMIN permissions cannot set other users project permissions') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await waitForElementNotExist('#project-settings-link') - await logout() - - log('Set user as project ADMIN') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User with ADMIN permissions can set project settings') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementVisible('#project-settings-link') - await logout() - log('Remove user as project ADMIN') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'ADMIN', 'My Test Project 5 Project Permission', 'project' ) - await logout() - - log('User without create environment permissions cannot create a new environment') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'CREATE_ENVIRONMENT', 'My Test Project 5 Project Permission', 'project' ) - await logout() - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await waitForElementNotExist('#create-env-link') - await logout() - - log('User without DELETE_FEATURE permissions cannot Delete any feature') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-action-0')) - await waitForElementVisible(byId('remove-feature-btn-0')) - await Selector(byId('remove-feature-btn-0')).hasClass( - 'feature-action__item_disabled', - ) - await logout() - log('Add DELETE_FEATURE permission to user') - await login(E2E_USER, PASSWORD) - await setUserPermission(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, 'DELETE_FEATURE', 'My Test Project 5 Project Permission', 'project' ) - await logout() - log('User with permissions can Delete any feature') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await click(byId('feature-action-0')) - await waitForElementVisible(byId('remove-feature-btn-0')) - await t.expect(Selector(byId('remove-feature-btn-0')).hasClass('feature-action__item_disabled')).notOk(); - await logout() - - log('User without MANAGE_SEGMENTS permissions cannot Manage Segments') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await gotoSegments() - const createSegmentBtn = Selector(byId('show-create-segment-btn')) - await t.expect(createSegmentBtn.hasAttribute('disabled')).ok() - await logout() - log('Add MANAGE_SEGMENTS permission to user') - await login(E2E_USER, PASSWORD) - await setUserPermission( - E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, - 'MANAGE_SEGMENTS', - 'My Test Project 5 Project Permission', - 'project' - ) - await logout() - log('User with MANAGE_SEGMENTS permissions can Manage Segments') - await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) - await click('#project-select-0') - await gotoSegments() - await t.expect(createSegmentBtn.hasAttribute('disabled')).notOk() -} diff --git a/frontend/e2e/tests/project-test.spec.ts b/frontend/e2e/tests/project-test.spec.ts new file mode 100644 index 000000000000..0a2952ff7720 --- /dev/null +++ b/frontend/e2e/tests/project-test.spec.ts @@ -0,0 +1,24 @@ +import { test } from '@playwright/test'; +import { + assertTextContent, + byId, + click, + log, + login, + setText, + waitForElementVisible, +} from '../helpers/helpers'; +import { E2E_USER, PASSWORD } from '../config'; + +test('@oss Project settings test', async ({ page }) => { + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, '#project-select-0'); + + log('Edit Project'); + await click(page, '#project-link'); + await click(page, '#project-settings-link'); + await setText(page, "[name='proj-name']", 'Test Project'); + await click(page, '#save-proj-btn'); + await assertTextContent(page, '#project-link', 'Test Project'); +}); diff --git a/frontend/e2e/tests/project-test.ts b/frontend/e2e/tests/project-test.ts deleted file mode 100644 index 48252d2c712b..000000000000 --- a/frontend/e2e/tests/project-test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - assertTextContent, - byId, - click, - log, - login, - setText, - waitForElementVisible, -} from '../helpers.cafe'; -import { E2E_USER, PASSWORD } from '../config' - -export default async function () { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-0') - log('Edit Project') - await click('#project-link') - await click('#project-settings-link') - await setText("[name='proj-name']", 'Test Project') - await click('#save-proj-btn') - await assertTextContent(`#project-link`, 'Test Project') - -} diff --git a/frontend/e2e/tests/roles-test.spec.ts b/frontend/e2e/tests/roles-test.spec.ts new file mode 100644 index 000000000000..6207120ac16f --- /dev/null +++ b/frontend/e2e/tests/roles-test.spec.ts @@ -0,0 +1,72 @@ +import { test } from '@playwright/test'; +import { + byId, + click, + createFeature, + log, + login, + setText, + waitForElementVisible, + closeModal, + logout, + gotoTraits, + deleteFeature, + createRole, +} from '../helpers/helpers'; + +import { + PASSWORD, + E2E_NON_ADMIN_USER_WITH_A_ROLE, + E2E_USER, +} from '../config'; + +test('@enterprise Roles and permissions test', async ({ page }) => { + const rolesProject = 'project-my-test-project-7-role'; + + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, byId(rolesProject)); + await createFeature(page, 0, 'test_feature', false); + + log('Go to Roles'); + await click(page, byId('organisation-link')); + await click(page, byId('users-and-permissions')); + await waitForElementVisible(page, byId('tab-item-roles')); + + log('Create Role'); + await createRole(page, 'test_role', 0, [4]); + + log('Add project permissions to the Role'); + await click(page, byId(`role-0`)); + await click(page, byId('permissions-tab')); + await click(page, byId('permissions-tab')); + await waitForElementVisible(page, byId('project-permissions-tab')); + await click(page, byId('project-permissions-tab')); + await click(page, byId('permissions-my test project 7 role')); + await click(page, byId('admin-switch-project')); + + log('Add environment permissions to the Role'); + await waitForElementVisible(page, byId('environment-permissions-tab')); + await click(page, byId('environment-permissions-tab')); + await click(page, byId('project-select')); + await waitForElementVisible(page, byId('project-select-option-6')); + await click(page, byId('project-select-option-6')); + await click(page, byId('permissions-development')); + await click(page, byId('admin-switch-environment')); + await closeModal(page); + + await logout(page); + + log('Login with the user with a new Role'); + await page.reload(); + await page.waitForTimeout(2000); + await login(page, E2E_NON_ADMIN_USER_WITH_A_ROLE, PASSWORD); + await click(page, byId(rolesProject)); + + log('User with permissions can Handle the Features'); + const flagName = 'test_feature'; + await deleteFeature(page, 0, flagName); + + log('User with permissions can See the Identities'); + await gotoTraits(page); +}); diff --git a/frontend/e2e/tests/roles-test.ts b/frontend/e2e/tests/roles-test.ts deleted file mode 100644 index b85aab1609fe..000000000000 --- a/frontend/e2e/tests/roles-test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - byId, - click, - createFeature, - log, - login, - setText, - waitForElementVisible, - closeModal, - logout, - gotoTraits, - deleteFeature, createRole, -} from '../helpers.cafe'; -import { - PASSWORD, - E2E_NON_ADMIN_USER_WITH_A_ROLE, - E2E_USER, -} from '../config' -import { t } from 'testcafe' - -export default async function () { - const rolesProject = 'project-my-test-project-7-role' - log('Login') - await login(E2E_USER, PASSWORD) - await click(byId(rolesProject)) - await createFeature(0, 'test_feature', false) - log('Go to Roles') - await click(byId('organisation-link')) - await click(byId('users-and-permissions')) - await waitForElementVisible(byId('tab-item-roles')) - log('Create Role') - await createRole('test_role', 0, [4]) - log('Add project permissions to the Role') - await click(byId(`role-0`)) - await click(byId('permissions-tab')) - await click(byId('permissions-tab')) - await waitForElementVisible(byId('project-permissions-tab')) - await click(byId('project-permissions-tab')) - await click(byId('permissions-my test project 7 role')) - await click(byId('admin-switch-project')) - log('Add environment permissions to the Role') - await waitForElementVisible(byId('environment-permissions-tab')) - await click(byId('environment-permissions-tab')) - await click(byId('project-select')) - await waitForElementVisible(byId('project-select-option-6')) - await click(byId('project-select-option-6')) - await click(byId('permissions-development')) - await click(byId('admin-switch-environment')) - await closeModal() - await logout(t) - log('Login with the user with a new Role') - await t.eval(() => location.reload()); - await t.wait(2000); - await login(E2E_NON_ADMIN_USER_WITH_A_ROLE, PASSWORD) - await click(byId(rolesProject)) - log('User with permissions can Handle the Features') - const flagName = 'test_feature' - await deleteFeature(0, flagName) - - log('User with permissions can See the Identities') - await gotoTraits() -} diff --git a/frontend/e2e/tests/segment-test.spec.ts b/frontend/e2e/tests/segment-test.spec.ts new file mode 100644 index 000000000000..b885e6f1fba0 --- /dev/null +++ b/frontend/e2e/tests/segment-test.spec.ts @@ -0,0 +1,246 @@ +import { test } from '@playwright/test'; +import { + addSegmentOverride, + addSegmentOverrideConfig, + assertTextContent, + byId, + click, + closeModal, + createFeature, + createRemoteConfig, + createSegment, + createTrait, + deleteFeature, + deleteTrait, + deleteSegment, + gotoFeature, + gotoFeatures, + gotoSegments, + gotoTraits, + goToUser, + log, + login, + saveFeatureSegments, + setSegmentOverrideIndex, + setText, + viewFeature, + waitAndRefresh, + waitForElementVisible, + createOrganisationAndProject, +} from '../helpers/helpers'; +import { E2E_USER, PASSWORD } from '../config'; + +test('@oss Segment test 1 - Age rules and overrides', async ({ page }) => { + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, '#project-select-1'); + + log('Create Feature'); + await createRemoteConfig(page, 0, 'mv_flag', 'big', null, null, [ + { value: 'medium', weight: 100 }, + { value: 'small', weight: 0 }, + ]); + + log('Segment age rules'); + await gotoSegments(page); + // (=== 18 || === 19) && (> 17 || < 19) && (!=20) && (<=18) && (>=18) + // Rule 1- Age === 18 || Age === 19 + + await createSegment(page, 0, '18_or_19', [ + // rule 2 =18 || =17 + { + name: 'age', + operator: 'EQUAL', + ors: [ + { + name: 'age', + operator: 'EQUAL', + value: 17, + }, + ], + value: 18, + }, + // rule 2 >17 or <10 + { + name: 'age', + operator: 'GREATER_THAN', + ors: [ + { + name: 'age', + operator: 'LESS_THAN', + value: 10, + }, + ], + value: 17, + }, + // rule 3 !=20 + { + name: 'age', + operator: 'NOT_EQUAL', + value: 20, + }, + // Rule 4 <= 18 + { + name: 'age', + operator: 'LESS_THAN_INCLUSIVE', + value: 18, + }, + // Rule 5 >= 18 + { + name: 'age', + operator: 'GREATER_THAN_INCLUSIVE', + value: 18, + }, + ]); + + log('Add segment trait for user'); + await gotoTraits(page); + await createTrait(page, 0, 'age', 18); + + await assertTextContent(page, byId('user-feature-value-0'), '"medium"'); + await gotoFeatures(page); + await gotoFeature(page, 0); + + await addSegmentOverride(page, 0, true, 0, [ + { value: 'medium', weight: 0 }, + { value: 'small', weight: 100 }, + ]); + await click(page, '#update-feature-segments-btn'); + await closeModal(page); + await waitAndRefresh(page); + + await gotoTraits(page); + await assertTextContent(page, byId('user-feature-value-0'), '"small"'); + + await assertTextContent(page, byId('segment-0-name'), '18_or_19'); + + await deleteTrait(page, 0); + + log('Set user MV override'); + await click(page, byId('user-feature-0')); + await click(page, byId('select-variation-medium')); + await click(page, byId('update-feature-btn')); + await waitAndRefresh(page); + await assertTextContent(page, byId('user-feature-value-0'), '"medium"'); + + log('Delete segment'); + await gotoSegments(page); + await deleteSegment(page, 0, '18_or_19'); + await gotoFeatures(page); + await deleteFeature(page, 0, 'mv_flag'); +}); + +test('@oss Segment test 2 - Multiple segments and prioritization', async ({ page }) => { + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, '#project-select-2'); + + log('Create segments'); + await gotoSegments(page); + await createSegment(page, 0, 'segment_1', [ + { + name: 'trait', + operator: 'EQUAL', + value: '1', + }, + ]); + await createSegment(page, 1, 'segment_2', [ + { + name: 'trait2', + operator: 'EQUAL', + value: '2', + }, + ]); + await createSegment(page, 2, 'segment_3', [ + { + name: 'trait3', + operator: 'EQUAL', + value: '3', + }, + ]); + + log('Create Features'); + await gotoFeatures(page); + await createFeature(page, 0, 'flag'); + await createRemoteConfig(page, 0, 'config', 0); + + log('Set segment overrides features'); + await viewFeature(page, 0); + await addSegmentOverrideConfig(page, 0, 1, 0); + await addSegmentOverrideConfig(page, 1, 2, 0); + await addSegmentOverrideConfig(page, 2, 3, 0); + await saveFeatureSegments(page); + await viewFeature(page, 1); + await addSegmentOverride(page, 0, true, 0); + await addSegmentOverride(page, 1, false, 0); + await addSegmentOverride(page, 2, true, 0); + await saveFeatureSegments(page); + + log('Set user in segment_1'); + await goToUser(page, 0); + await createTrait(page, 0, 'trait', 1); + await createTrait(page, 1, 'trait2', 2); + await createTrait(page, 2, 'trait3', 3); + await waitForElementVisible(page, byId('user-feature-switch-1-on')); + await assertTextContent(page, byId('user-feature-value-0'), '1'); + + log('Prioritise segment 2'); + await gotoFeatures(page); + await gotoFeature(page, 0); + await setSegmentOverrideIndex(page, 1, 0); + await saveFeatureSegments(page); + await gotoFeature(page, 1); + await setSegmentOverrideIndex(page, 1, 0); + await saveFeatureSegments(page); + await goToUser(page, 0); + await waitForElementVisible(page, byId('user-feature-switch-1-off')); + await assertTextContent(page, byId('user-feature-value-0'), '2'); + + log('Prioritise segment 3'); + await gotoFeatures(page); + await gotoFeature(page, 0); + await setSegmentOverrideIndex(page, 2, 0); + await saveFeatureSegments(page); + await gotoFeature(page, 1); + await setSegmentOverrideIndex(page, 2, 0); + await saveFeatureSegments(page); + await goToUser(page, 0); + await waitForElementVisible(page, byId('user-feature-switch-1-on')); + await assertTextContent(page, byId('user-feature-value-0'), '3'); + + log('Clear down features'); + await gotoFeatures(page); + await deleteFeature(page, 1, 'flag'); + await deleteFeature(page, 0, 'config'); +}); + +test('@oss Segment test 3 - User feature toggles', async ({ page }) => { + log('Login'); + await login(page, E2E_USER, PASSWORD); + await click(page, '#project-select-3'); + + log('Create features'); + await gotoFeatures(page); + await createFeature(page, 0, 'flag', true); + await createRemoteConfig(page, 0, 'config', 0, 'Description'); + + log('Toggle flag for user'); + await goToUser(page, 0); + await click(page, byId('user-feature-switch-1-on')); + await click(page, '#confirm-toggle-feature-btn'); + await waitAndRefresh(page); + await waitForElementVisible(page, byId('user-feature-switch-1-off')); + + log('Edit flag for user'); + await click(page, byId('user-feature-0')); + await setText(page, byId('featureValue'), 'small'); + await click(page, '#update-feature-btn'); + await waitAndRefresh(page); + await assertTextContent(page, byId('user-feature-value-0'), '"small"'); + + log('Toggle flag for user again'); + await click(page, byId('user-feature-switch-1-off')); + await click(page, '#confirm-toggle-feature-btn'); + await waitAndRefresh(page); + await waitForElementVisible(page, byId('user-feature-switch-1-on')); +}); diff --git a/frontend/e2e/tests/segment-test.ts b/frontend/e2e/tests/segment-test.ts deleted file mode 100644 index 2505dc86647f..000000000000 --- a/frontend/e2e/tests/segment-test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - addSegmentOverride, - addSegmentOverrideConfig, - assertTextContent, - byId, - click, - closeModal, - createFeature, - createRemoteConfig, - createSegment, - createTrait, - deleteFeature, - deleteTrait, - deleteSegment, - gotoFeature, - gotoFeatures, - gotoSegments, - gotoTraits, - goToUser, - log, - login, - saveFeatureSegments, - setSegmentOverrideIndex, - setText, - viewFeature, - waitAndRefresh, - waitForElementVisible, - createOrganisationAndProject, -} from '../helpers.cafe'; -import { E2E_USER, PASSWORD } from '../config' - -export const testSegment1 = async () => { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-1') - - log('Create Feature') - - await createRemoteConfig(0, 'mv_flag', 'big', null, null, [ - { value: 'medium', weight: 100 }, - { value: 'small', weight: 0 }, - ]) - - log('Segment age rules') - await gotoSegments() - // (=== 18 || === 19) && (> 17 || < 19) && (!=20) && (<=18) && (>=18) - // Rule 1- Age === 18 || Age === 19 - - await createSegment(0, '18_or_19', [ - // rule 2 =18 || =17 - { - name: 'age', - operator: 'EQUAL', - ors: [ - { - name: 'age', - operator: 'EQUAL', - value: 17, - }, - ], - value: 18, - }, - // rule 2 >17 or <10 - { - name: 'age', - operator: 'GREATER_THAN', - ors: [ - { - name: 'age', - operator: 'LESS_THAN', - value: 10, - }, - ], - value: 17, - }, - // rule 3 !=20 - { - name: 'age', - operator: 'NOT_EQUAL', - value: 20, - }, - // Rule 4 <= 18 - { - name: 'age', - operator: 'LESS_THAN_INCLUSIVE', - value: 18, - }, - // Rule 5 >= 18 - { - name: 'age', - operator: 'GREATER_THAN_INCLUSIVE', - value: 18, - }, - ]) - - log('Add segment trait for user') - await gotoTraits() - await createTrait(0, 'age', 18) - - await assertTextContent(byId('user-feature-value-0'), '"medium"') - await gotoFeatures() - await gotoFeature(0) - - await addSegmentOverride(0, true, 0, [ - { value: 'medium', weight: 0 }, - { value: 'small', weight: 100 }, - ]) - await click('#update-feature-segments-btn') - await closeModal() - await waitAndRefresh() - - await gotoTraits() - await assertTextContent(byId('user-feature-value-0'), '"small"') - - // log('Check user now belongs to segment'); - await assertTextContent(byId('segment-0-name'), '18_or_19') - - // log('Delete segment trait for user'); - await deleteTrait(0) - - log('Set user MV override') - await click(byId('user-feature-0')) - await click(byId('select-variation-medium')) - await click(byId('update-feature-btn')) - await waitAndRefresh() - await assertTextContent(byId('user-feature-value-0'), '"medium"') - - log('Delete segment') - await gotoSegments() - await deleteSegment(0, '18_or_19') - await gotoFeatures() - await deleteFeature(0, 'mv_flag') -} - -export const testSegment2 = async () => { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-2') - - log('Create segments') - await gotoSegments() - await createSegment(0, 'segment_1', [ - { - name: 'trait', - operator: 'EQUAL', - value: '1', - }, - ]) - await createSegment(1, 'segment_2', [ - { - name: 'trait2', - operator: 'EQUAL', - value: '2', - }, - ]) - await createSegment(2, 'segment_3', [ - { - name: 'trait3', - operator: 'EQUAL', - value: '3', - }, - ]) - - log('Create Features') - await gotoFeatures() - await createFeature(0, 'flag') - await createRemoteConfig(0, 'config', 0) - - log('Set segment overrides features') - await viewFeature(0) - await addSegmentOverrideConfig(0, 1, 0) - await addSegmentOverrideConfig(1, 2, 0) - await addSegmentOverrideConfig(2, 3, 0) - await saveFeatureSegments() - await viewFeature(1) - await addSegmentOverride(0, true, 0) - await addSegmentOverride(1, false, 0) - await addSegmentOverride(2, true, 0) - await saveFeatureSegments() - - log('Set user in segment_1') - await goToUser(0) - await createTrait(0, 'trait', 1) - await createTrait(1, 'trait2', 2) - await createTrait(2, 'trait3', 3) - // await assertTextContent(byId('segment-0-name'), 'segment_1'); todo: view user segments disabled in edge - await waitForElementVisible(byId('user-feature-switch-1-on')) - await assertTextContent(byId('user-feature-value-0'), '1') - - log('Prioritise segment 2') - await gotoFeatures() - await gotoFeature(0) - await setSegmentOverrideIndex(1, 0) - await saveFeatureSegments() - await gotoFeature(1) - await setSegmentOverrideIndex(1, 0) - await saveFeatureSegments() - await goToUser(0) - await waitForElementVisible(byId('user-feature-switch-1-off')) - await assertTextContent(byId('user-feature-value-0'), '2') - - log('Prioritise segment 3') - await gotoFeatures() - await gotoFeature(0) - await setSegmentOverrideIndex(2, 0) - await saveFeatureSegments() - await gotoFeature(1) - await setSegmentOverrideIndex(2, 0) - await saveFeatureSegments() - await goToUser(0) - await waitForElementVisible(byId('user-feature-switch-1-on')) - await assertTextContent(byId('user-feature-value-0'), '3') - - log('Clear down features') - await gotoFeatures() - await deleteFeature(1, 'flag') - await deleteFeature(0, 'config') -} - -export const testSegment3 = async () => { - log('Login') - await login(E2E_USER, PASSWORD) - await click('#project-select-3') - - log('Create features') - await gotoFeatures() - await createFeature(0, 'flag', true) - await createRemoteConfig(0, 'config', 0, 'Description') - - log('Toggle flag for user') - await goToUser(0) - await click(byId('user-feature-switch-1-on')) - await click('#confirm-toggle-feature-btn') - await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await waitForElementVisible(byId('user-feature-switch-1-off')) - - log('Edit flag for user') - await click(byId('user-feature-0')) - await setText(byId('featureValue'), 'small') - await click('#update-feature-btn') - await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await assertTextContent(byId('user-feature-value-0'), '"small"') - - log('Toggle flag for user again') - await click(byId('user-feature-switch-1-off')) - await click('#confirm-toggle-feature-btn') - await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows - await waitForElementVisible(byId('user-feature-switch-1-on')) -} diff --git a/frontend/e2e/tests/serve-bundle.setup.ts b/frontend/e2e/tests/serve-bundle.setup.ts new file mode 100644 index 000000000000..6e749b4c653e --- /dev/null +++ b/frontend/e2e/tests/serve-bundle.setup.ts @@ -0,0 +1,26 @@ +import { exec, spawn } from 'child_process'; + +import { test as setup } from '@playwright/test'; + +require('dotenv').config() + + +setup('serve bundled app', async ({ }) => { + if (process.env.E2E_LOCAL) { + console.log('E2E_LOCAL is set, skipping server start') + return + } + + const serverProcess = spawn('node', ['api/index.js'], { + shell: true, + stdio: 'inherit', + env: { + ...process.env, + PORT: '3000', + }, + }); + process.env.BUNDLED_SERVER_PID = String(serverProcess.pid); + // Wait for server to start + await new Promise((resolve) => setTimeout(resolve, 3000)); + console.log('Serving bundled app in port 3000') +}); \ No newline at end of file diff --git a/frontend/e2e/tests/versioning-tests.spec.ts b/frontend/e2e/tests/versioning-tests.spec.ts new file mode 100644 index 000000000000..0c9cc2a1a390 --- /dev/null +++ b/frontend/e2e/tests/versioning-tests.spec.ts @@ -0,0 +1,67 @@ +import { test } from '@playwright/test'; +import { + assertNumberOfVersions, + byId, + click, + compareVersion, + createFeature, + createOrganisationAndProject, + createRemoteConfig, + editRemoteConfig, + log, + login, + waitForElementVisible +} from '../helpers/helpers'; +import { E2E_USER, PASSWORD } from '../config'; +import fetch from 'node-fetch'; +import flagsmith from 'flagsmith/isomorphic'; +import Project from '../../common/project'; + +test('@oss Feature versioning test', async ({ page }) => { + await flagsmith.init({ fetch, environmentID: Project.flagsmith, api: Project.flagsmithClientAPI }); + const hasFeature = flagsmith.hasFeature('feature_versioning'); + + log('Login'); + await login(page, E2E_USER, PASSWORD); + + if (!hasFeature) { + log('Skipping version test, feature not enabled.'); + return; + } + + await createOrganisationAndProject(page, 'Flagsmith Versioning Org', 'Flagsmith Versioning Project'); + await waitForElementVisible(page, byId('features-page')); + await click(page, '#env-settings-link'); + await click(page, byId('enable-versioning')); + await click(page, '#confirm-btn-yes'); + await waitForElementVisible(page, byId('feature-versioning-enabled')); + + log('Create feature 1'); + await createRemoteConfig(page, 0, 'a', 'small'); + log('Edit feature 1'); + await editRemoteConfig(page, 0, 'medium'); + + log('Create feature 2'); + await createRemoteConfig(page, 1, 'b', 'small', null, null, [ + { value: 'medium', weight: 100 }, + { value: 'big', weight: 0 }, + ]); + log('Edit feature 2'); + await editRemoteConfig(page, 1, 'small', false, [ + { value: 'medium', weight: 0 }, + { value: 'big', weight: 100 }, + ]); + + log('Create feature 3'); + await createFeature(page, 2, 'c', false); + log('Edit feature 3'); + await editRemoteConfig(page, 2, '', true); + + log('Edit feature 3'); + await assertNumberOfVersions(page, 0, 2); + await assertNumberOfVersions(page, 1, 2); + await assertNumberOfVersions(page, 2, 2); + await compareVersion(page, 0, 0, null, true, true, 'small', 'medium'); + await compareVersion(page, 1, 0, null, true, true, 'small', 'small'); + await compareVersion(page, 2, 0, null, false, true, null, null); +}); diff --git a/frontend/e2e/tests/versioning-tests.ts b/frontend/e2e/tests/versioning-tests.ts deleted file mode 100644 index 20be8b42c21e..000000000000 --- a/frontend/e2e/tests/versioning-tests.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - assertNumberOfVersions, - byId, - click, - compareVersion, - createFeature, - createOrganisationAndProject, - createRemoteConfig, - editRemoteConfig, - log, - login, refreshUntilElementVisible, - waitForElementVisible -} from "../helpers.cafe"; -import { E2E_USER, PASSWORD } from '../config'; -import fetch from 'node-fetch'; -import flagsmith from 'flagsmith/isomorphic'; -import Project from '../../common/project'; - -export default async () => { - await flagsmith.init({fetch,environmentID:Project.flagsmith,api:Project.flagsmithClientAPI}) - const hasFeature = flagsmith.hasFeature("feature_versioning") - log('Login') - await login(E2E_USER, PASSWORD) - if(!hasFeature) { - log("Skipping version test, feature not enabled.") - return - } - - await createOrganisationAndProject('Flagsmith Versioning Org', 'Flagsmith Versioning Project') - await waitForElementVisible(byId('features-page')) - await click('#env-settings-link') - await click(byId('enable-versioning')) - await click('#confirm-btn-yes') - await waitForElementVisible(byId('feature-versioning-enabled')) - - log('Create feature 1') - await createRemoteConfig(0, 'a', 'small') - log('Edit feature 1') - await editRemoteConfig(0,'medium') - - log('Create feature 2') - await createRemoteConfig(1, 'b', 'small', null, null, [ - { value: 'medium', weight: 100 }, - { value: 'big', weight: 0 }, - ]) - log('Edit feature 2') - await editRemoteConfig(1,'small',false,[ - { value: 'medium', weight: 0 }, - { value: 'big', weight: 100 }, - ]) - - log('Create feature 3') - await createFeature(2, 'c', false) - log('Edit feature 3') - await editRemoteConfig(2,'',true) - - log('Edit feature 3') - await assertNumberOfVersions(0, 2) - await assertNumberOfVersions(1, 2) - await assertNumberOfVersions(2, 2) - await compareVersion(0,0,null,true,true, 'small','medium') - await compareVersion(1,0,null,true,true, 'small','small') - await compareVersion(2,0,null,false,true, null,null) -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4f19b27eec2e..8f21a78c66ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -129,6 +129,7 @@ "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^2.0.7", "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@playwright/test": "^1.52.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", @@ -4136,6 +4137,21 @@ "node": ">=12.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", @@ -15409,6 +15425,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e56513f36c77..15b9095d38c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,24 +5,26 @@ "main": "index.js", "scripts": { "kill": "kill -9 $(lsof -ti tcp:4444)", - "test:bundle": "cross-env E2E=1 npm run bundle", - "postinstall": "npm run env", - "build": "npm run bundle", "husky:install": "cd ../ && husky install", + "build": "npm run bundle", + "postinstall": "npm run env", + "bundle": "npx webpack --config ./webpack/webpack.config.prod.js", + "bundledjango": "npx webpack --config ./webpack/webpack.config.django.prod.js", + "test:bundle": "cross-env E2E=1 npm run bundle", "test:bundle:staging": "cross-env E2E=1 ENV=staging npm run bundle", - "test:dev": "cross-env NODE_ENV=production E2E=true E2E_DEV=true ts-node -T ./e2e/index.cafe", - "test:devlocal": "cross-env NODE_ENV=production E2E_LOCAL=true E2E_CONCURRENCY=1 E2E=true E2E_DEV=true ts-node -T ./e2e/index.cafe", + "test:ui": "playwright test --ui", + "test:dev": "cross-env NODE_ENV=production E2E=true E2E_DEV=true playwright test", + "test:devlocal": "cross-env NODE_ENV=production E2E_LOCAL=true E2E_CONCURRENCY=1 E2E=true E2E_DEV=true playwright test", "test:devBundle": "npm run test:bundle && npm run test:dev", - "test": "npm run test:bundle && cross-env NODE_ENV=production E2E=true ts-node -T ./e2e/index.cafe", - "test:staging": "npm run test:bundle:staging && cross-env NODE_ENV=production E2E=true ENV=staging ts-node -T ./e2e/index.cafe", + "test": "npm run test:bundle && cross-env NODE_ENV=production E2E=true playwright test", + "test:staging": "npm run test:bundle:staging && cross-env NODE_ENV=production E2E=true ENV=staging playwright test", "env": "node ./bin/env.js", "lint": "eslint .", "lint:fix": "npx eslint --fix .", "start": "cross-env NODE_ENV=production node ./api/index", "typecheck": "tsc", "dev": "cross-env npm run env && npx nodemon --watch ./api --ignore server/config/config.json --watch webpack --exec node ./api", - "bundle": "npx webpack --config ./webpack/webpack.config.prod.js", - "bundledjango": "npx webpack --config ./webpack/webpack.config.django.prod.js" + "playwright:install": "npx playwright install --with-deps firefox" }, "engines": { "node": "18.x", @@ -138,7 +140,6 @@ "style-loader": "1.3.0", "suppress-exit-code": "^1.0.0", "terser-webpack-plugin": "^5.3.6", - "testcafe-react-selectors": "^5.0.3", "toml": "^3.0.0", "ts-node": "^10.9.1", "webpack": "5.94.0", @@ -153,6 +154,7 @@ "devDependencies": { "@dword-design/eslint-plugin-import-alias": "^2.0.7", "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@playwright/test": "^1.52.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", @@ -178,7 +180,6 @@ "raw-loader": "0.5.1", "react-refresh": "^0.14.2", "ssgrtk": "^0.3.5", - "testcafe": "^3.2.0", "typescript": "4.6.4" }, "lint-staged": { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000000..74555a3fd91d --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,61 @@ +import { defineConfig, devices } from '@playwright/test' + +require('dotenv').config() + +const baseURL = `http://localhost:${process.env.PORT || 8080}/` + +export default defineConfig({ + expect: { + timeout: 20000, + }, + forbidOnly: !!process.env.CI, + projects: [ + { + name: 'api teardown', + testMatch: /global\.setup\.ts/, + }, + { + name: 'run bundled app', + testMatch: /serve-bundle\.setup\.ts/, + }, + { + name: 'teardown bundled app', + testMatch: /global-teardown\.ts/, + }, + { + name: 'tests', + testDir: './e2e/tests', + }, + { + dependencies: ['api teardown', 'run bundled app'], + grep: /@oss/, + name: 'oss', + teardown: 'teardown bundled app', + use: { ...devices['Desktop Firefox'] }, + }, + { + dependencies: ['api teardown', 'run bundled app'], + grep: /@enterprise/, + name: 'enterprise', + teardown: 'teardown bundled app', + use: { ...devices['Desktop Firefox'] }, + }, + ], + reporter: [['html'], ['list']], + retries: process.env.CI ? 2 : 0, + testDir: './e2e/tests', + timeout: 20000, + use: { + baseURL: process.env.E2E_LOCAL ? 'http://localhost:3000' : baseURL, + headless: process.env.E2E_DEV ? false : true, + screenshot: 'only-on-failure', + trace: 'on-first-retry', + video: process.env.E2E_DEV + ? 'off' + : { + mode: 'retain-on-failure', + size: { height: 720, width: 1280 }, + }, + }, + workers: Number(process.env.E2E_CONCURRENCY) || 1, +})