From 8c79b51d0f376915af20ef44b30653ff8b267224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Tue, 4 Apr 2023 09:32:35 +0200 Subject: [PATCH 01/17] fix: concurrency issue when running multiple requests (#3442) ## About the changes Fix issue when running multiple calls to the /frontend endpoint concurrently, which ends up creating many instances of unleash SDK client. --- src/lib/routes/admin-api/api-token.ts | 2 +- src/lib/routes/admin-api/project/api-token.ts | 2 +- src/lib/services/proxy-service.ts | 31 +++++---- .../api/proxy/proxy.concurrency.e2e.test.ts | 64 +++++++++++++++++++ 4 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts diff --git a/src/lib/routes/admin-api/api-token.ts b/src/lib/routes/admin-api/api-token.ts index 8c6349ac15..ad282b6660 100644 --- a/src/lib/routes/admin-api/api-token.ts +++ b/src/lib/routes/admin-api/api-token.ts @@ -200,7 +200,7 @@ export class ApiTokenController extends Controller { const { token } = req.params; await this.apiTokenService.delete(token, extractUsername(req)); - this.proxyService.deleteClientForProxyToken(token); + await this.proxyService.deleteClientForProxyToken(token); res.status(200).end(); } diff --git a/src/lib/routes/admin-api/project/api-token.ts b/src/lib/routes/admin-api/project/api-token.ts index 7afe8242d1..2c8e007ec4 100644 --- a/src/lib/routes/admin-api/project/api-token.ts +++ b/src/lib/routes/admin-api/project/api-token.ts @@ -187,7 +187,7 @@ export class ProjectApiTokenController extends Controller { storedToken.project[0] === projectId)) ) { await this.apiTokenService.delete(token, extractUsername(req)); - this.proxyService.deleteClientForProxyToken(token); + await this.proxyService.deleteClientForProxyToken(token); res.status(200).end(); } } diff --git a/src/lib/services/proxy-service.ts b/src/lib/services/proxy-service.ts index 1490ed15b6..b8965fd0f0 100644 --- a/src/lib/services/proxy-service.ts +++ b/src/lib/services/proxy-service.ts @@ -42,7 +42,13 @@ export class ProxyService { private readonly services: Services; - private readonly clients: Map = new Map(); + /** + * This is intentionally a Promise becasue we want to be able to await + * until the client (which might be being created by a different request) is ready + * Check this test that fails if we don't use a Promise: src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts + */ + private readonly clients: Map> = + new Map(); private cachedFrontendSettings?: FrontendSettings; @@ -99,14 +105,13 @@ export class ProxyService { private async clientForProxyToken(token: ApiUser): Promise { ProxyService.assertExpectedTokenType(token); - if (!this.clients.has(token.secret)) { - this.clients.set( - token.secret, - await this.createClientForProxyToken(token), - ); + let client = this.clients.get(token.secret); + if (!client) { + client = this.createClientForProxyToken(token); + this.clients.set(token.secret, client); } - return this.clients.get(token.secret); + return client; } private async createClientForProxyToken(token: ApiUser): Promise { @@ -134,13 +139,17 @@ export class ProxyService { return client; } - deleteClientForProxyToken(secret: string): void { - this.clients.get(secret)?.destroy(); - this.clients.delete(secret); + async deleteClientForProxyToken(secret: string): Promise { + let clientPromise = this.clients.get(secret); + if (clientPromise) { + const client = await clientPromise; + client.destroy(); + this.clients.delete(secret); + } } stopAll(): void { - this.clients.forEach((client) => client.destroy()); + this.clients.forEach((promise) => promise.then((c) => c.destroy())); } private static assertExpectedTokenType({ type }: ApiUser) { diff --git a/src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts b/src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts new file mode 100644 index 0000000000..bc89dd722a --- /dev/null +++ b/src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts @@ -0,0 +1,64 @@ +import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; +import dbInit, { ITestDb } from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import { randomId } from '../../../../lib/util'; +import { ApiTokenType } from '../../../../lib/types/models/api-token'; + +let app: IUnleashTest; +let db: ITestDb; +let appErrorLogs: string[] = []; + +beforeAll(async () => { + db = await dbInit(`proxy_concurrency`, getLogger); + const baseLogger = getLogger(); + const appLogger = { + ...baseLogger, + error: (msg: string, ...args: any[]) => { + appErrorLogs.push(msg); + baseLogger.error(msg, ...args); + }, + }; + app = await setupAppWithAuth(db.stores, { + frontendApiOrigins: ['https://example.com'], + getLogger: () => appLogger, + }); +}); + +afterEach(() => { + app.services.proxyService.stopAll(); + jest.clearAllMocks(); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => { + appErrorLogs = []; +}); + +/** + * This test needs to run on a new instance of the application and a clean DB + * which is why it should be the only test of this file + */ +test('multiple parallel calls to api/frontend should not create multiple instances', async () => { + const frontendTokenDefault = + await app.services.apiTokenService.createApiTokenWithProjects({ + type: ApiTokenType.FRONTEND, + projects: ['default'], + environment: 'default', + username: `test-token-${randomId()}`, + }); + + await Promise.all( + Array.from(Array(15).keys()).map(() => + app.request + .get('/api/frontend') + .set('Authorization', frontendTokenDefault.secret) + .expect('Content-Type', /json/) + .expect(200), + ), + ); + expect(appErrorLogs).toHaveLength(0); +}); From 1cedd3dc33f84c9fdacadf3ace93c7b5f03c4e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 5 Apr 2023 10:20:50 +0200 Subject: [PATCH 02/17] feat: add PAT kill switch (#3454) This PR disables PAT admin endpoints so it's not possible to create or get PATs the kill switch is enabled, the UI is hidden but the existing PATs will continue to work if they were created before. The delete endpoint still works allowing an admin to delete old PATs By default the kill switch is disabled (i.e. PAT is enabled by default) --- .../projects/notifications.spec.ts | 147 +++++------------- .../integration/projects/settings.spec.ts | 127 ++++++--------- .../src/component/user/Profile/Profile.tsx | 4 + frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/routes/admin-api/user/pat.ts | 18 ++- src/lib/types/experimental.ts | 4 + 7 files changed, 111 insertions(+), 192 deletions(-) diff --git a/frontend/cypress/integration/projects/notifications.spec.ts b/frontend/cypress/integration/projects/notifications.spec.ts index 125c9b861b..e214cdc45a 100644 --- a/frontend/cypress/integration/projects/notifications.spec.ts +++ b/frontend/cypress/integration/projects/notifications.spec.ts @@ -1,134 +1,63 @@ -/// +/// + +import UserCredentials = Cypress.UserCredentials; -type UserCredentials = { email: string; password: string }; const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); const randomId = String(Math.random()).split('.')[1]; const featureToggleName = `notifications_test-${randomId}`; const baseUrl = Cypress.config().baseUrl; let strategyId = ''; -const userIds: number[] = []; -const userCredentials: UserCredentials[] = []; +let userIds: number[] = []; +let userCredentials: UserCredentials[] = []; const userName = `notifications_user-${randomId}`; const projectName = `default`; -const password = Cypress.env(`AUTH_PASSWORD`) + '_A'; + const EDITOR = 2; -const PROJECT_MEMBER = 5; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; - -const createUser = () => { - const name = `${userName}`; - const email = `${name}@test.com`; - cy.request('POST', `${baseUrl}/api/admin/user-admin`, { - name: name, - email: `${name}@test.com`, - username: `${name}@test.com`, - sendEmail: false, - rootRole: EDITOR, - }) - .as(name) - .then(response => { - const id = response.body.id; - updateUserPassword(id).then(() => { - addUserToProject(id).then(() => { - userIds.push(id); - userCredentials.push({ email, password }); - }); - }); - }); -}; - -const updateUserPassword = (id: number) => - cy.request( - 'POST', - `${baseUrl}/api/admin/user-admin/${id}/change-password`, - { - password, - } - ); - -const addUserToProject = (id: number) => - cy.request( - 'POST', - `${baseUrl}/api/admin/projects/${projectName}/role/${PROJECT_MEMBER}/access`, - { - groups: [], - users: [{ id }], - } - ); describe('notifications', () => { before(() => { - disableActiveSplashScreens(); - cy.login(); - createUser(); + cy.runBefore(); }); - after(() => { - // We need to login as admin for cleanup - cy.login(); - userIds.forEach(id => - cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`) - ); + // This one is failing on CI: https://github.com/Unleash/unleash/actions/runs/4609305167/jobs/8160244872#step:4:193 + it.skip('should create a notification when a feature is created in a project', () => { + cy.login_UI(); + cy.createUser_API(userName, EDITOR).then(value => { + userIds = value.userIds; + userCredentials = value.userCredentials; - cy.request( - 'DELETE', - `${baseUrl}/api/admin/features/${featureToggleName}` - ); - }); + cy.login_UI(); + cy.visit(`/projects/${projectName}`); - beforeEach(() => { - cy.login(); - cy.visit(`/projects/${projectName}`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } - }); + cy.createFeature_UI(featureToggleName); - afterEach(() => { - cy.logout(); - }); + //Should not show own notifications + cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); - const createFeature = () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + //then + cy.get("[data-testid='NOTIFICATIONS_MODAL']").should('exist'); - cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as( - 'createFeature' - ); + const credentials = userCredentials[0]; - cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); - cy.wait('@createFeature'); - }; + //Sign in as a different user + cy.login_UI(credentials.email, credentials.password); + cy.visit(`/projects/${projectName}`); + cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); - it('should create a notification when a feature is created in a project', () => { - createFeature(); + //then + cy.get("[data-testid='UNREAD_NOTIFICATIONS']").should('exist'); + cy.get("[data-testid='NOTIFICATIONS_LIST']") + .eq(0) + .should('contain.text', `New feature ${featureToggleName}`); - //Should not show own notifications - cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); + //clean + // We need to login as admin for cleanup + cy.login_UI(); + userIds.forEach(id => + cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`) + ); - //then - cy.get("[data-testid='NOTIFICATIONS_MODAL']").should('exist'); - - const credentials = userCredentials[0]; - - //Sign in as a different user - cy.login(credentials.email, credentials.password); - cy.visit(`/projects/${projectName}`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } - cy.get("[data-testid='NOTIFICATIONS_BUTTON']").click(); - - //then - cy.get("[data-testid='UNREAD_NOTIFICATIONS']").should('exist'); - cy.get("[data-testid='NOTIFICATIONS_LIST']") - .should('have.length', 1) - .eq(0) - .should('contain.text', 'New feature'); + cy.deleteFeature_API(featureToggleName); + }); }); }); diff --git a/frontend/cypress/integration/projects/settings.spec.ts b/frontend/cypress/integration/projects/settings.spec.ts index 51307231e9..9637a2bcb1 100644 --- a/frontend/cypress/integration/projects/settings.spec.ts +++ b/frontend/cypress/integration/projects/settings.spec.ts @@ -1,117 +1,80 @@ -/// +/// -type UserCredentials = { email: string; password: string }; -const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); const randomId = String(Math.random()).split('.')[1]; -const featureToggleName = `settings-${randomId}`; const baseUrl = Cypress.config().baseUrl; let strategyId = ''; const userName = `settings-user-${randomId}`; const projectName = `stickiness-project-${randomId}`; +const TEST_STICKINESS = 'userId'; +const featureToggleName = `settings-${randomId}`; +let cleanFeature = false; +let cleanProject = false; -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; - -const disableFeatureStrategiesProdGuard = () => { - localStorage.setItem( - 'useFeatureStrategyProdGuardSettings:v2', - JSON.stringify({ hide: true }) - ); -}; - -describe('notifications', () => { +describe('project settings', () => { before(() => { - disableFeatureStrategiesProdGuard(); - disableActiveSplashScreens(); - cy.login(); - }); - - after(() => { - cy.request( - 'DELETE', - `${baseUrl}/api/admin/features/${featureToggleName}` - ); - - cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); + cy.runBefore(); }); beforeEach(() => { - cy.login(); - cy.visit(`/projects`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); + cy.login_UI(); + if (cleanFeature) { + cy.deleteFeature_API(featureToggleName); } + if (cleanProject) { + cy.deleteProject_API(projectName); + } + cy.visit(`/projects`); + cy.wait(300); }); - afterEach(() => { - cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); - }); - - const createFeature = () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); - - cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as( - 'createFeature' - ); - - cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); - cy.wait('@createFeature'); - }; - - const createProject = () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_PROJECT').click(); - - cy.get("[data-testid='PROJECT_ID_INPUT']").type(projectName); - cy.get("[data-testid='PROJECT_NAME_INPUT']").type(projectName); - cy.get("[id='stickiness-select']") - .first() - .click() - .get('[data-testid=SELECT_ITEM_ID-userId') - .first() - .click(); - cy.get("[data-testid='CREATE_PROJECT_BTN']").click(); - }; - it('should store default project stickiness when creating, retrieve it when editing a project', () => { - createProject(); - + //when + cleanProject = true; + cy.createProject_UI(projectName, TEST_STICKINESS); cy.visit(`/projects/${projectName}`); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } cy.get("[data-testid='NAVIGATE_TO_EDIT_PROJECT']").click(); //then cy.get("[id='stickiness-select']") .first() .should('have.text', 'userId'); + + //clean + cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); }); it('should respect the default project stickiness when creating a Gradual Rollout Strategy', () => { - createProject(); - createFeature(); + cy.createProject_UI(projectName, TEST_STICKINESS); + cy.createFeature_UI(featureToggleName, true, projectName); + cleanFeature = true; + + //when - then + cy.addFlexibleRolloutStrategyToFeature_UI({ + featureToggleName, + project: projectName, + stickiness: TEST_STICKINESS, + }); + + //clean + }); + + it.skip('should respect the default project stickiness when creating a variant', () => { + cy.createProject_UI(projectName, TEST_STICKINESS); + cy.createFeature_UI(featureToggleName, true, projectName); + + //when cy.visit( - `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout` + `/projects/${projectName}/features/${featureToggleName}/variants` ); + cy.get("[data-testid='ADD_VARIANT_BUTTON']").first().click(); //then cy.get("[id='stickiness-select']") .first() .should('have.text', 'userId'); - }); - it('should respect the default project stickiness when creating a variant', () => { - createProject(); - createFeature(); - - cy.visit(`/projects/default/features/${featureToggleName}/variants`); - - cy.get("[data-testid='EDIT_VARIANTS_BUTTON']").click(); - //then - cy.get('#menu-stickiness').first().should('have.text', 'userId'); + //clean + cy.deleteFeature_API(featureToggleName); + cy.deleteProject_API(projectName); }); }); diff --git a/frontend/src/component/user/Profile/Profile.tsx b/frontend/src/component/user/Profile/Profile.tsx index 94748642d3..324d2aff7f 100644 --- a/frontend/src/component/user/Profile/Profile.tsx +++ b/frontend/src/component/user/Profile/Profile.tsx @@ -7,6 +7,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { PasswordTab } from './PasswordTab/PasswordTab'; import { PersonalAPITokensTab } from './PersonalAPITokensTab/PersonalAPITokensTab'; import { ProfileTab } from './ProfileTab/ProfileTab'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; export const Profile = () => { const { user } = useAuthUser(); @@ -14,6 +15,8 @@ export const Profile = () => { const navigate = useNavigate(); const { config: simpleAuthConfig } = useAuthSettings('simple'); + const { uiConfig } = useUiConfig(); + const tabs = [ { id: 'profile', label: 'Profile' }, { @@ -26,6 +29,7 @@ export const Profile = () => { id: 'pat', label: 'Personal API tokens', path: 'personal-api-tokens', + hidden: uiConfig.flags.personalAccessTokensKillSwitch, }, ]; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 2b6ac934c7..4773226136 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -51,6 +51,7 @@ export interface IFlags { projectScopedSegments?: boolean; projectScopedStickiness?: boolean; projectMode?: boolean; + personalAccessTokensKillSwitch?: boolean; } export interface IVersionInfo { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index df802fb748..0e901a2942 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -83,6 +83,7 @@ exports[`should create default config 1`] = ` "newProjectOverview": false, "optimal304": false, "optimal304Differ": false, + "personalAccessTokensKillSwitch": false, "proPlanAutoCharge": false, "projectMode": false, "projectScopedSegments": false, @@ -110,6 +111,7 @@ exports[`should create default config 1`] = ` "newProjectOverview": false, "optimal304": false, "optimal304Differ": false, + "personalAccessTokensKillSwitch": false, "proPlanAutoCharge": false, "projectMode": false, "projectScopedSegments": false, diff --git a/src/lib/routes/admin-api/user/pat.ts b/src/lib/routes/admin-api/user/pat.ts index 077a189a42..330de59c35 100644 --- a/src/lib/routes/admin-api/user/pat.ts +++ b/src/lib/routes/admin-api/user/pat.ts @@ -1,7 +1,11 @@ import { Response } from 'express'; import Controller from '../../controller'; import { Logger } from '../../../logger'; -import { IUnleashConfig, IUnleashServices } from '../../../types'; +import { + IFlagResolver, + IUnleashConfig, + IUnleashServices, +} from '../../../types'; import { createRequestSchema } from '../../../openapi/util/create-request-schema'; import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { OpenApiService } from '../../../services/openapi-service'; @@ -21,6 +25,8 @@ export default class PatController extends Controller { private logger: Logger; + private flagResolver: IFlagResolver; + constructor( config: IUnleashConfig, { @@ -30,6 +36,7 @@ export default class PatController extends Controller { ) { super(config); this.logger = config.getLogger('lib/routes/auth/pat-controller.ts'); + this.flagResolver = config.flagResolver; this.openApiService = openApiService; this.patService = patService; this.route({ @@ -77,6 +84,11 @@ export default class PatController extends Controller { } async createPat(req: IAuthRequest, res: Response): Promise { + if (this.flagResolver.isEnabled('personalAccessTokensKillSwitch')) { + res.status(404).send({ message: 'PAT is disabled' }); + return; + } + const pat = req.body; const createdPat = await this.patService.createPat( pat, @@ -92,6 +104,10 @@ export default class PatController extends Controller { } async getPats(req: IAuthRequest, res: Response): Promise { + if (this.flagResolver.isEnabled('personalAccessTokensKillSwitch')) { + res.status(404).send({ message: 'PAT is disabled' }); + return; + } const pats = await this.patService.getAll(req.user.id); this.openApiService.respondWithValidation(200, res, patsSchema.$id, { pats: serializeDates(pats), diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 6012013081..e2017d0463 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -68,6 +68,10 @@ const flags = { false, ), projectMode: parseEnvVarBoolean(process.env.PROJECT_MODE, false), + personalAccessTokensKillSwitch: parseEnvVarBoolean( + process.env.UNLEASH_PAT_KILL_SWITCH, + false, + ), cleanClientApi: parseEnvVarBoolean(process.env.CLEAN_CLIENT_API, false), optimal304: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_OPTIMAL_304, From f7f07dd058dc3aff48182fc08886b6e59ad07c29 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Wed, 5 Apr 2023 08:42:24 +0000 Subject: [PATCH 03/17] 4.22.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a8a3b2898b..68fd16acf3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unleash-server", "description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.", - "version": "4.22.0", + "version": "4.22.1", "keywords": [ "unleash", "feature toggle", From 9bd595ffc44af5bae8c623f4d70dd2508a701241 Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Wed, 5 Apr 2023 15:34:17 +0300 Subject: [PATCH 04/17] Tmp 4.22.2 (#3461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: - Stickiness accept any string (removed enum) - UI sync issues when creating project->feature->Flexible rollout strategy - Fixes stickiness UI issue when adding variants ## About the changes Closes # ### Important files ## Discussion points --------- Signed-off-by: andreas-unleash --- .github/workflows/e2e.frontend.yaml | 2 +- frontend/cypress.d.ts | 5 + frontend/cypress/README.md | 36 ++ frontend/cypress/global.d.ts | 81 ++++ .../integration/feature/feature.spec.ts | 283 ++------------ .../cypress/integration/groups/groups.spec.ts | 23 +- .../cypress/integration/import/import.spec.ts | 23 +- .../integration/projects/access.spec.ts | 28 +- .../projects/notifications.spec.ts | 28 +- .../integration/projects/overview.spec.ts | 74 ++-- .../integration/projects/settings.spec.ts | 19 +- .../integration/segments/segments.spec.ts | 40 +- frontend/cypress/support/API.ts | 103 ++++++ frontend/cypress/support/UI.ts | 347 ++++++++++++++++++ frontend/cypress/support/commands.ts | 100 ++--- frontend/cypress/support/index.ts | 10 +- frontend/cypress/tsconfig.json | 12 + .../EnvironmentVariantsModal.tsx | 79 ++-- .../FlexibleStrategy/FlexibleStrategy.tsx | 10 +- .../Project/ProjectForm/ProjectForm.tsx | 10 +- .../project/Project/hooks/useProjectForm.ts | 12 +- .../src/hooks/useDefaultProjectSettings.ts | 37 +- frontend/tsconfig.json | 5 +- src/lib/services/project-schema.ts | 5 +- 24 files changed, 837 insertions(+), 535 deletions(-) create mode 100644 frontend/cypress.d.ts create mode 100644 frontend/cypress/README.md create mode 100644 frontend/cypress/global.d.ts create mode 100644 frontend/cypress/support/API.ts create mode 100644 frontend/cypress/support/UI.ts create mode 100644 frontend/cypress/tsconfig.json diff --git a/.github/workflows/e2e.frontend.yaml b/.github/workflows/e2e.frontend.yaml index 434b1ac0c2..2b3917ced6 100644 --- a/.github/workflows/e2e.frontend.yaml +++ b/.github/workflows/e2e.frontend.yaml @@ -11,7 +11,7 @@ jobs: - groups/groups.spec.ts - projects/access.spec.ts - projects/overview.spec.ts - # - projects/settings.spec.ts + - projects/settings.spec.ts - projects/notifications.spec.ts - segments/segments.spec.ts - import/import.spec.ts diff --git a/frontend/cypress.d.ts b/frontend/cypress.d.ts new file mode 100644 index 0000000000..d12adb035f --- /dev/null +++ b/frontend/cypress.d.ts @@ -0,0 +1,5 @@ +/// + +declare namespace Cypress { + interface Chainable {} +} diff --git a/frontend/cypress/README.md b/frontend/cypress/README.md new file mode 100644 index 0000000000..edd06efe20 --- /dev/null +++ b/frontend/cypress/README.md @@ -0,0 +1,36 @@ +## Unleash Behavioural tests + +### Add common commands to Cypress + +- `global.d.ts` is where we extend Cypress types +- `API.ts` contains api requests for common actions (great place for cleanup actions) +- `UI.ts` contains common functions for UI operations +- `commands.ts` is the place to map the functions to a cypress command + +### Test Format + +Ideally each test should manage its own data. + +Avoid using `after` and `afterEach` hooks for cleaning up. According to Cypress docs, there is no guarantee that the functions will run + +Suggested Format: + +- `prepare` +- `when` +- `then` +- `clean` + +#### Passing (returned) parameters around + +```ts +it('can add, update and delete a gradual rollout strategy to the development environment', async () => { + cy.addFlexibleRolloutStrategyToFeature_UI({ + featureToggleName, + }).then(value => { + strategyId = value; + cy.updateFlexibleRolloutStrategy_UI(featureToggleName, strategyId).then( + () => cy.deleteFeatureStrategy_UI(featureToggleName, strategyId) + ); + }); +}); +``` diff --git a/frontend/cypress/global.d.ts b/frontend/cypress/global.d.ts new file mode 100644 index 0000000000..90783d8bd0 --- /dev/null +++ b/frontend/cypress/global.d.ts @@ -0,0 +1,81 @@ +/// + +declare namespace Cypress { + interface AddFlexibleRolloutStrategyOptions { + featureToggleName: string; + project?: string; + environment?: string; + stickiness?: string; + } + + interface UserCredentials { + email: string; + password: string; + } + interface Chainable { + runBefore(): Chainable; + + login_UI(user = AUTH_USER, password = AUTH_PASSWORD): Chainable; + logout_UI(): Chainable; + + createProject_UI( + projectName: string, + defaultStickiness: string + ): Chainable; + + createFeature_UI( + name: string, + shouldWait?: boolean, + project?: string + ): Chainable; + + // VARIANTS + addVariantsToFeature_UI( + featureToggleName: string, + variants: Array, + projectName?: string + ); + deleteVariant_UI( + featureToggleName: string, + variant: string, + projectName?: string + ): Chainable; + + // SEGMENTS + createSegment_UI(segmentName: string): Chainable; + deleteSegment_UI(segmentName: string, id: string): Chainable; + + // STRATEGY + addUserIdStrategyToFeature_UI( + featureName: string, + strategyId: string, + projectName?: string + ): Chainable; + addFlexibleRolloutStrategyToFeature_UI( + options: AddFlexibleRolloutStrategyOptions + ): Chainable; + updateFlexibleRolloutStrategy_UI( + featureToggleName: string, + strategyId: string + ); + deleteFeatureStrategy_UI( + featureName: string, + strategyId: string, + shouldWait?: boolean, + projectName?: string + ): Chainable; + + // API + createUser_API(userName: string, role: number): Chainable; + updateUserPassword_API(id: number, pass?: string): Chainable; + addUserToProject_API( + id: number, + role: number, + projectName?: string + ): Chainable; + createProject_API(name: string): Chainable; + deleteProject_API(name: string): Chainable; + createFeature_API(name: string, projectName?: string): Chainable; + deleteFeature_API(name: string): Chainable; + } +} diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts index 2160b679e8..0003da5f16 100644 --- a/frontend/cypress/integration/feature/feature.spec.ts +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -1,269 +1,75 @@ -/// - -const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); -const randomId = String(Math.random()).split('.')[1]; -const featureToggleName = `unleash-e2e-${randomId}`; -const baseUrl = Cypress.config().baseUrl; -const variant1 = 'variant1'; -const variant2 = 'variant2'; -let strategyId = ''; - -// Disable the prod guard modal by marking it as seen. -const disableFeatureStrategiesProdGuard = () => { - localStorage.setItem( - 'useFeatureStrategyProdGuardSettings:v2', - JSON.stringify({ hide: true }) - ); -}; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; +/// describe('feature', () => { + const randomId = String(Math.random()).split('.')[1]; + const featureToggleName = `unleash-e2e-${randomId}`; + + const variant1 = 'variant1'; + const variant2 = 'variant2'; + let strategyId = ''; + before(() => { - disableFeatureStrategiesProdGuard(); - disableActiveSplashScreens(); + cy.runBefore(); }); after(() => { - cy.request({ - method: 'DELETE', - url: `${baseUrl}/api/admin/features/${featureToggleName}`, - }); - cy.request({ - method: 'DELETE', - url: `${baseUrl}/api/admin/archive/${featureToggleName}`, - }); + cy.deleteFeature_API(featureToggleName); }); beforeEach(() => { - cy.login(); + cy.login_UI(); cy.visit('/features'); }); it('can create a feature toggle', () => { - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } - - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); - - cy.intercept('POST', '/api/admin/projects/default/features').as( - 'createFeature' - ); - - cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); - cy.wait('@createFeature'); + cy.createFeature_UI(featureToggleName, true); cy.url().should('include', featureToggleName); }); it('gives an error if a toggle exists with the same name', () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); - - cy.intercept('POST', '/api/admin/projects/default/features').as( - 'createFeature' - ); - - cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + cy.createFeature_UI(featureToggleName, false); cy.get("[data-testid='INPUT_ERROR_TEXT']").contains( 'A toggle with that name already exists' ); }); it('gives an error if a toggle name is url unsafe', () => { - cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); - - cy.intercept('POST', '/api/admin/projects/default/features').as( - 'createFeature' - ); - - cy.get("[data-testid='CF_NAME_ID'").type('featureToggleUnsafe####$#//'); - cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); - cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + cy.createFeature_UI('featureToggleUnsafe####$#//', false); cy.get("[data-testid='INPUT_ERROR_TEXT']").contains( `"name" must be URL friendly` ); }); - it('can add a gradual rollout strategy to the development environment', () => { - cy.visit( - `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout` - ); - - if (ENTERPRISE) { - cy.get('[data-testid=ADD_CONSTRAINT_ID]').click(); - cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); - } - - cy.intercept( - 'POST', - `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`, - req => { - expect(req.body.name).to.equal('flexibleRollout'); - expect(req.body.parameters.groupId).to.equal(featureToggleName); - expect(req.body.parameters.stickiness).to.equal('default'); - expect(req.body.parameters.rollout).to.equal('50'); - - if (ENTERPRISE) { - expect(req.body.constraints.length).to.equal(1); - } else { - expect(req.body.constraints.length).to.equal(0); - } - - req.continue(res => { - strategyId = res.body.id; - }); - } - ).as('addStrategyToFeature'); - - cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); - cy.wait('@addStrategyToFeature'); - }); - - it('can update a strategy in the development environment', () => { - cy.visit( - `/projects/default/features/${featureToggleName}/strategies/edit?environmentId=development&strategyId=${strategyId}` - ); - - cy.get('[data-testid=FLEXIBLE_STRATEGY_STICKINESS_ID]') - .first() - .click() - .get('[data-testid=SELECT_ITEM_ID-sessionId') - .first() - .click(); - - cy.get('[data-testid=FLEXIBLE_STRATEGY_GROUP_ID]') - .first() - .clear() - .type('new-group-id'); - - cy.intercept( - 'PUT', - `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies/${strategyId}`, - req => { - expect(req.body.parameters.groupId).to.equal('new-group-id'); - expect(req.body.parameters.stickiness).to.equal('sessionId'); - expect(req.body.parameters.rollout).to.equal('50'); - - if (ENTERPRISE) { - expect(req.body.constraints.length).to.equal(1); - } else { - expect(req.body.constraints.length).to.equal(0); - } - - req.continue(res => { - expect(res.statusCode).to.equal(200); - }); - } - ).as('updateStrategy'); - - cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); - cy.wait('@updateStrategy'); - }); - - it('can delete a strategy in the development environment', () => { - cy.visit(`/projects/default/features/${featureToggleName}`); - - cy.intercept( - 'DELETE', - `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies/${strategyId}`, - req => { - req.continue(res => { - expect(res.statusCode).to.equal(200); - }); - } - ).as('deleteStrategy'); - - cy.get( - '[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]' - ).click(); - cy.get('[data-testid=STRATEGY_FORM_REMOVE_ID]').click(); - cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); - cy.wait('@deleteStrategy'); + it('can add, update and delete a gradual rollout strategy to the development environment', async () => { + cy.addFlexibleRolloutStrategyToFeature_UI({ + featureToggleName, + }).then(value => { + strategyId = value; + cy.updateFlexibleRolloutStrategy_UI( + featureToggleName, + strategyId + ).then(() => + cy.deleteFeatureStrategy_UI(featureToggleName, strategyId) + ); + }); }); it('can add a userId strategy to the development environment', () => { - cy.visit( - `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=userWithId` + cy.addUserIdStrategyToFeature_UI(featureToggleName, strategyId).then( + value => { + cy.deleteFeatureStrategy_UI(featureToggleName, value, false); + } ); - - if (ENTERPRISE) { - cy.get('[data-testid=ADD_CONSTRAINT_ID]').click(); - cy.get('[data-testid=CONSTRAINT_AUTOCOMPLETE_ID]') - .type('{downArrow}'.repeat(1)) - .type('{enter}'); - cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); - } - - cy.get('[data-testid=STRATEGY_INPUT_LIST]') - .type('user1') - .type('{enter}') - .type('user2') - .type('{enter}'); - cy.get('[data-testid=ADD_TO_STRATEGY_INPUT_LIST]').click(); - - cy.intercept( - 'POST', - `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`, - req => { - expect(req.body.name).to.equal('userWithId'); - - expect(req.body.parameters.userIds.length).to.equal(11); - - if (ENTERPRISE) { - expect(req.body.constraints.length).to.equal(1); - } else { - expect(req.body.constraints.length).to.equal(0); - } - - req.continue(res => { - strategyId = res.body.id; - }); - } - ).as('addStrategyToFeature'); - - cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); - cy.wait('@addStrategyToFeature'); }); - it('can add two variants to the development environment', () => { - cy.visit(`/projects/default/features/${featureToggleName}/variants`); - - cy.intercept( - 'PATCH', - `/api/admin/projects/default/features/${featureToggleName}/environments/development/variants`, - req => { - expect(req.body[0].op).to.equal('add'); - expect(req.body[0].path).to.equal('/0'); - expect(req.body[0].value.name).to.equal(variant1); - expect(req.body[0].value.weight).to.equal(500); - expect(req.body[1].op).to.equal('add'); - expect(req.body[1].path).to.equal('/1'); - expect(req.body[1].value.name).to.equal(variant2); - expect(req.body[1].value.weight).to.equal(500); - } - ).as('variantCreation'); - - cy.get('[data-testid=ADD_VARIANT_BUTTON]').first().click(); - cy.wait(1000); - cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant1); - cy.get('[data-testid=MODAL_ADD_VARIANT_BUTTON]').click(); - cy.get('[data-testid=VARIANT_NAME_INPUT]').last().type(variant2); - cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); - cy.wait('@variantCreation'); + it('can add variants to the development environment', () => { + cy.addVariantsToFeature_UI(featureToggleName, [variant1, variant2]); }); - it('can set weight to fixed value for one of the variants', () => { + it('can update variants', () => { cy.visit(`/projects/default/features/${featureToggleName}/variants`); cy.get('[data-testid=EDIT_VARIANTS_BUTTON]').click(); - cy.wait(1000); cy.get('[data-testid=VARIANT_NAME_INPUT]') .last() .children() @@ -292,32 +98,13 @@ describe('feature', () => { ).as('variantUpdate'); cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); - cy.wait('@variantUpdate'); cy.get(`[data-testid=VARIANT_WEIGHT_${variant2}]`).should( 'have.text', '15 %' ); }); - it('can delete variant', () => { - cy.visit(`/projects/default/features/${featureToggleName}/variants`); - cy.get('[data-testid=EDIT_VARIANTS_BUTTON]').click(); - cy.wait(1000); - cy.get(`[data-testid=VARIANT_DELETE_BUTTON_${variant2}]`).click(); - - cy.intercept( - 'PATCH', - `/api/admin/projects/default/features/${featureToggleName}/environments/development/variants`, - req => { - expect(req.body[0].op).to.equal('remove'); - expect(req.body[0].path).to.equal('/1'); - expect(req.body[1].op).to.equal('replace'); - expect(req.body[1].path).to.equal('/0/weight'); - expect(req.body[1].value).to.equal(1000); - } - ).as('delete'); - - cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); - cy.wait('@delete'); + it('can delete variants', () => { + cy.deleteVariant_UI(featureToggleName, variant2); }); }); diff --git a/frontend/cypress/integration/groups/groups.spec.ts b/frontend/cypress/integration/groups/groups.spec.ts index 1626ac8aeb..2b4a2431dc 100644 --- a/frontend/cypress/integration/groups/groups.spec.ts +++ b/frontend/cypress/integration/groups/groups.spec.ts @@ -1,19 +1,14 @@ -/// - -const baseUrl = Cypress.config().baseUrl; -const randomId = String(Math.random()).split('.')[1]; -const groupName = `unleash-e2e-${randomId}`; -const userIds: any[] = []; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; +/// describe('groups', () => { + const baseUrl = Cypress.config().baseUrl; + const randomId = String(Math.random()).split('.')[1]; + const groupName = `unleash-e2e-${randomId}`; + const userIds: any[] = []; + before(() => { - disableActiveSplashScreens(); - cy.login(); + cy.runBefore(); + cy.login_UI(); for (let i = 1; i <= 2; i++) { cy.request('POST', `${baseUrl}/api/admin/user-admin`, { name: `unleash-e2e-user${i}-${randomId}`, @@ -31,7 +26,7 @@ describe('groups', () => { }); beforeEach(() => { - cy.login(); + cy.login_UI(); cy.visit('/admin/groups'); if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { cy.get("[data-testid='CLOSE_SPLASH']").click(); diff --git a/frontend/cypress/integration/import/import.spec.ts b/frontend/cypress/integration/import/import.spec.ts index 4038d3f931..0c67d8b8c1 100644 --- a/frontend/cypress/integration/import/import.spec.ts +++ b/frontend/cypress/integration/import/import.spec.ts @@ -1,19 +1,14 @@ -/// - -const baseUrl = Cypress.config().baseUrl; -const randomSeed = String(Math.random()).split('.')[1]; -const randomFeatureName = `cypress-features${randomSeed}`; -const userIds: any[] = []; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; +/// describe('imports', () => { + const baseUrl = Cypress.config().baseUrl; + const randomSeed = String(Math.random()).split('.')[1]; + const randomFeatureName = `cypress-features${randomSeed}`; + const userIds: any[] = []; + before(() => { - disableActiveSplashScreens(); - cy.login(); + cy.runBefore(); + cy.login_UI(); for (let i = 1; i <= 2; i++) { cy.request('POST', `${baseUrl}/api/admin/user-admin`, { name: `unleash-e2e-user${i}-${randomFeatureName}`, @@ -31,7 +26,7 @@ describe('imports', () => { }); beforeEach(() => { - cy.login(); + cy.login_UI(); if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { cy.get("[data-testid='CLOSE_SPLASH']").click(); } diff --git a/frontend/cypress/integration/projects/access.spec.ts b/frontend/cypress/integration/projects/access.spec.ts index 67dfac45b9..2cd6c7f1cd 100644 --- a/frontend/cypress/integration/projects/access.spec.ts +++ b/frontend/cypress/integration/projects/access.spec.ts @@ -1,4 +1,4 @@ -/// +/// import { PA_ASSIGN_BUTTON_ID, @@ -8,24 +8,20 @@ import { PA_ROLE_ID, PA_USERS_GROUPS_ID, PA_USERS_GROUPS_TITLE_ID, + //@ts-ignore } from '../../../src/utils/testIds'; -const baseUrl = Cypress.config().baseUrl; -const randomId = String(Math.random()).split('.')[1]; -const groupAndProjectName = `group-e2e-${randomId}`; -const userName = `user-e2e-${randomId}`; -const groupIds: any[] = []; -const userIds: any[] = []; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; - describe('project-access', () => { + const baseUrl = Cypress.config().baseUrl; + const randomId = String(Math.random()).split('.')[1]; + const groupAndProjectName = `group-e2e-${randomId}`; + const userName = `user-e2e-${randomId}`; + const groupIds: any[] = []; + const userIds: any[] = []; + before(() => { - disableActiveSplashScreens(); - cy.login(); + cy.runBefore(); + cy.login_UI(); for (let i = 1; i <= 2; i++) { const name = `${i}-${userName}`; cy.request('POST', `${baseUrl}/api/admin/user-admin`, { @@ -68,7 +64,7 @@ describe('project-access', () => { }); beforeEach(() => { - cy.login(); + cy.login_UI(); cy.visit(`/projects/${groupAndProjectName}/settings/access`); if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { cy.get("[data-testid='CLOSE_SPLASH']").click(); diff --git a/frontend/cypress/integration/projects/notifications.spec.ts b/frontend/cypress/integration/projects/notifications.spec.ts index e214cdc45a..8cd2bef1e3 100644 --- a/frontend/cypress/integration/projects/notifications.spec.ts +++ b/frontend/cypress/integration/projects/notifications.spec.ts @@ -1,25 +1,20 @@ /// -import UserCredentials = Cypress.UserCredentials; - -const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); -const randomId = String(Math.random()).split('.')[1]; -const featureToggleName = `notifications_test-${randomId}`; -const baseUrl = Cypress.config().baseUrl; -let strategyId = ''; -let userIds: number[] = []; -let userCredentials: UserCredentials[] = []; -const userName = `notifications_user-${randomId}`; -const projectName = `default`; - const EDITOR = 2; describe('notifications', () => { + const randomId = String(Math.random()).split('.')[1]; + const featureToggleName = `notifications_test-${randomId}`; + const baseUrl = Cypress.config().baseUrl; + let userIds: number[] = []; + let userCredentials: Cypress.UserCredentials[] = []; + const userName = `notifications_user-${randomId}`; + const projectName = `default`; + before(() => { cy.runBefore(); }); - // This one is failing on CI: https://github.com/Unleash/unleash/actions/runs/4609305167/jobs/8160244872#step:4:193 it.skip('should create a notification when a feature is created in a project', () => { cy.login_UI(); cy.createUser_API(userName, EDITOR).then(value => { @@ -46,9 +41,10 @@ describe('notifications', () => { //then cy.get("[data-testid='UNREAD_NOTIFICATIONS']").should('exist'); - cy.get("[data-testid='NOTIFICATIONS_LIST']") - .eq(0) - .should('contain.text', `New feature ${featureToggleName}`); + cy.get("[data-testid='NOTIFICATIONS_LIST']").should( + 'contain.text', + `New feature ${featureToggleName}` + ); //clean // We need to login as admin for cleanup diff --git a/frontend/cypress/integration/projects/overview.spec.ts b/frontend/cypress/integration/projects/overview.spec.ts index 4d02dbafa0..126542288f 100644 --- a/frontend/cypress/integration/projects/overview.spec.ts +++ b/frontend/cypress/integration/projects/overview.spec.ts @@ -1,63 +1,23 @@ -/// +/// import { BATCH_ACTIONS_BAR, BATCH_SELECT, BATCH_SELECTED_COUNT, MORE_BATCH_ACTIONS, SEARCH_INPUT, + //@ts-ignore } from '../../../src/utils/testIds'; -const randomId = String(Math.random()).split('.')[1]; -const featureTogglePrefix = 'unleash-e2e-project-overview'; -const featureToggleName = `${featureTogglePrefix}-${randomId}`; -const baseUrl = Cypress.config().baseUrl; -const selectAll = '[title="Toggle All Rows Selected"] input[type="checkbox"]'; - -// Disable the prod guard modal by marking it as seen. -const disableFeatureStrategiesProdGuard = () => { - localStorage.setItem( - 'useFeatureStrategyProdGuardSettings:v2', - JSON.stringify({ hide: true }) - ); -}; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; - describe('project overview', () => { - before(() => { - disableFeatureStrategiesProdGuard(); - disableActiveSplashScreens(); - cy.login(); - cy.request({ - url: '/api/admin/projects/default/features', - method: 'POST', - body: { - name: `${featureToggleName}-A`, - description: 'hello-world', - type: 'release', - impressionData: false, - }, - }); - cy.request({ - url: '/api/admin/projects/default/features', - method: 'POST', - body: { - name: `${featureToggleName}-B`, - description: 'hello-world', - type: 'release', - impressionData: false, - }, - }); - }); + const randomId = String(Math.random()).split('.')[1]; + const featureTogglePrefix = 'unleash-e2e-project-overview'; + const featureToggleName = `${featureTogglePrefix}-${randomId}`; + const baseUrl = Cypress.config().baseUrl; + const selectAll = + '[title="Toggle All Rows Selected"] input[type="checkbox"]'; - beforeEach(() => { - cy.login(); - if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { - cy.get("[data-testid='CLOSE_SPLASH']").click(); - } + before(() => { + cy.runBefore(); }); after(() => { @@ -82,6 +42,9 @@ describe('project overview', () => { }); it('loads the table', () => { + cy.login_UI(); + cy.createFeature_API(`${featureToggleName}-A`); + cy.createFeature_API(`${featureToggleName}-B`); cy.visit('/projects/default'); // Use search to filter feature toggles and check that the feature toggle is listed in the table. @@ -91,6 +54,7 @@ describe('project overview', () => { }); it('can select and deselect feature toggles', () => { + cy.login_UI(); cy.visit('/projects/default'); cy.viewport(1920, 1080); cy.get("[data-testid='SEARCH_INPUT']").click().type(featureToggleName); @@ -138,9 +102,12 @@ describe('project overview', () => { }); it('can mark selected togggles as stale', () => { + cy.login_UI(); cy.visit('/projects/default'); cy.viewport(1920, 1080); - cy.get(`[data-testid='${SEARCH_INPUT}']`).click().type(featureToggleName); + cy.get(`[data-testid='${SEARCH_INPUT}']`) + .click() + .type(featureToggleName); cy.get('table tbody tr').should('have.length', 2); cy.get(selectAll).click(); @@ -153,9 +120,12 @@ describe('project overview', () => { }); it('can archive selected togggles', () => { + cy.login_UI(); cy.visit('/projects/default'); cy.viewport(1920, 1080); - cy.get(`[data-testid='${SEARCH_INPUT}']`).click().type(featureToggleName); + cy.get(`[data-testid='${SEARCH_INPUT}']`) + .click() + .type(featureToggleName); cy.get('table tbody tr').should('have.length', 2); cy.get(selectAll).click(); diff --git a/frontend/cypress/integration/projects/settings.spec.ts b/frontend/cypress/integration/projects/settings.spec.ts index 9637a2bcb1..03512897c9 100644 --- a/frontend/cypress/integration/projects/settings.spec.ts +++ b/frontend/cypress/integration/projects/settings.spec.ts @@ -1,16 +1,14 @@ /// -const randomId = String(Math.random()).split('.')[1]; -const baseUrl = Cypress.config().baseUrl; -let strategyId = ''; -const userName = `settings-user-${randomId}`; -const projectName = `stickiness-project-${randomId}`; -const TEST_STICKINESS = 'userId'; -const featureToggleName = `settings-${randomId}`; -let cleanFeature = false; -let cleanProject = false; - describe('project settings', () => { + const randomId = String(Math.random()).split('.')[1]; + const baseUrl = Cypress.config().baseUrl; + const projectName = `stickiness-project-${randomId}`; + const TEST_STICKINESS = 'userId'; + const featureToggleName = `settings-${randomId}`; + let cleanFeature = false; + let cleanProject = false; + before(() => { cy.runBefore(); }); @@ -68,6 +66,7 @@ describe('project settings', () => { ); cy.get("[data-testid='ADD_VARIANT_BUTTON']").first().click(); + cy.wait(300); //then cy.get("[id='stickiness-select']") .first() diff --git a/frontend/cypress/integration/segments/segments.spec.ts b/frontend/cypress/integration/segments/segments.spec.ts index aa8b00ca97..4d7f0733d1 100644 --- a/frontend/cypress/integration/segments/segments.spec.ts +++ b/frontend/cypress/integration/segments/segments.spec.ts @@ -1,43 +1,29 @@ -/// - -const randomId = String(Math.random()).split('.')[1]; -const segmentName = `unleash-e2e-${randomId}`; - -// Disable all active splash pages by visiting them. -const disableActiveSplashScreens = () => { - cy.visit(`/splash/operators`); -}; +/// describe('segments', () => { + const randomId = String(Math.random()).split('.')[1]; + const segmentName = `unleash-e2e-${randomId}`; + let segmentId: string; + before(() => { - disableActiveSplashScreens(); + cy.runBefore(); }); beforeEach(() => { - cy.login(); + cy.login_UI(); cy.visit('/segments'); - }); - - it('can create a segment', () => { if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { cy.get("[data-testid='CLOSE_SPLASH']").click(); } + }); - cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click(); - - cy.intercept('POST', '/api/admin/segments').as('createSegment'); - - cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName); - cy.get("[data-testid='SEGMENT_DESC_ID']").type('hello-world'); - cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").click(); - cy.get("[data-testid='SEGMENT_CREATE_BTN_ID']").click(); - cy.wait('@createSegment'); + it('can create a segment', () => { + cy.createSegment_UI(segmentName); cy.contains(segmentName); }); it('gives an error if a segment exists with the same name', () => { cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click(); - cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName); cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").should('be.disabled'); cy.get("[data-testid='INPUT_ERROR_TEXT']").contains( @@ -46,11 +32,7 @@ describe('segments', () => { }); it('can delete a segment', () => { - cy.get(`[data-testid='SEGMENT_DELETE_BTN_ID_${segmentName}']`).click(); - - cy.get("[data-testid='SEGMENT_DIALOG_NAME_ID']").type(segmentName); - cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click(); - + cy.deleteSegment_UI(segmentName, segmentId); cy.contains(segmentName).should('not.exist'); }); }); diff --git a/frontend/cypress/support/API.ts b/frontend/cypress/support/API.ts new file mode 100644 index 0000000000..cde08909f7 --- /dev/null +++ b/frontend/cypress/support/API.ts @@ -0,0 +1,103 @@ +/// + +import Chainable = Cypress.Chainable; +const baseUrl = Cypress.config().baseUrl; +const password = Cypress.env(`AUTH_PASSWORD`) + '_A'; +const PROJECT_MEMBER = 5; +export const createFeature_API = ( + featureName: string, + projectName?: string +): Chainable => { + const project = projectName || 'default'; + return cy.request({ + url: `/api/admin/projects/${project}/features`, + method: 'POST', + body: { + name: `${featureName}`, + description: 'hello-world', + type: 'release', + impressionData: false, + }, + }); +}; + +export const deleteFeature_API = (name: string): Chainable => { + cy.request({ + method: 'DELETE', + url: `${baseUrl}/api/admin/features/${name}`, + }); + return cy.request({ + method: 'DELETE', + url: `${baseUrl}/api/admin/archive/${name}`, + }); +}; + +export const createProject_API = (project: string): Chainable => { + return cy.request({ + url: `/api/admin/projects`, + method: 'POST', + body: { + id: project, + name: project, + description: project, + impressionData: false, + }, + }); +}; + +export const deleteProject_API = (name: string): Chainable => { + return cy.request({ + method: 'DELETE', + url: `${baseUrl}/api/admin/projects/${name}`, + }); +}; + +export const createUser_API = (userName: string, role: number) => { + const name = `${userName}`; + const email = `${name}@test.com`; + const userIds: number[] = []; + const userCredentials: Cypress.UserCredentials[] = []; + cy.request('POST', `${baseUrl}/api/admin/user-admin`, { + name: name, + email: `${name}@test.com`, + username: `${name}@test.com`, + sendEmail: false, + rootRole: role, + }) + .as(name) + .then(response => { + const id = response.body.id; + updateUserPassword_API(id).then(() => { + addUserToProject_API(id, PROJECT_MEMBER).then(value => { + userIds.push(id); + userCredentials.push({ email, password }); + }); + }); + }); + return cy.wrap({ userIds, userCredentials }); +}; + +export const updateUserPassword_API = (id: number, pass?: string): Chainable => + cy.request( + 'POST', + `${baseUrl}/api/admin/user-admin/${id}/change-password`, + { + password: pass || password, + } + ); + +export const addUserToProject_API = ( + id: number, + role: number, + projectName?: string +): Chainable => { + const project = projectName || 'default'; + return cy.request( + 'POST', + `${baseUrl}/api/admin/projects/${project}/role/${role}/access`, + { + groups: [], + users: [{ id }], + } + ); +}; diff --git a/frontend/cypress/support/UI.ts b/frontend/cypress/support/UI.ts new file mode 100644 index 0000000000..a3f97ecf3d --- /dev/null +++ b/frontend/cypress/support/UI.ts @@ -0,0 +1,347 @@ +/// + +import Chainable = Cypress.Chainable; +import AddStrategyOptions = Cypress.AddFlexibleRolloutStrategyOptions; +const AUTH_USER = Cypress.env('AUTH_USER'); +const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD'); +const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); +const disableActiveSplashScreens = () => { + return cy.visit(`/splash/operators`); +}; + +const disableFeatureStrategiesProdGuard = () => { + localStorage.setItem( + 'useFeatureStrategyProdGuardSettings:v2', + JSON.stringify({ hide: true }) + ); +}; + +export const runBefore = () => { + disableFeatureStrategiesProdGuard(); + disableActiveSplashScreens(); +}; + +export const login_UI = ( + user = AUTH_USER, + password = AUTH_PASSWORD +): Chainable => { + return cy.session(user, () => { + cy.visit('/'); + cy.wait(1500); + cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user); + + if (AUTH_PASSWORD) { + cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password); + } + + cy.get("[data-testid='LOGIN_BUTTON']").click(); + + // Wait for the login redirect to complete. + cy.get("[data-testid='HEADER_USER_AVATAR']"); + + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + }); +}; + +export const createFeature_UI = ( + name: string, + shouldWait?: boolean, + project?: string +): Chainable => { + const projectName = project || 'default'; + + cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + + cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as( + 'createFeature' + ); + + cy.wait(300); + + cy.get("[data-testid='CF_NAME_ID'").type(name); + cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); + if (!shouldWait) return cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + else cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + return cy.wait('@createFeature'); +}; + +export const createProject_UI = ( + projectName: string, + defaultStickiness: string +): Chainable => { + cy.get('[data-testid=NAVIGATE_TO_CREATE_PROJECT').click(); + + cy.intercept('POST', `/api/admin/projects`).as('createProject'); + + cy.get("[data-testid='PROJECT_ID_INPUT']").type(projectName); + cy.get("[data-testid='PROJECT_NAME_INPUT']").type(projectName); + cy.get("[id='stickiness-select']") + .first() + .click() + .get(`[data-testid=SELECT_ITEM_ID-${defaultStickiness}`) + .first() + .click(); + cy.get("[data-testid='CREATE_PROJECT_BTN']").click(); + cy.wait('@createProject'); + return cy.visit(`/projects/${projectName}`); +}; + +export const createSegment_UI = (segmentName: string): Chainable => { + cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click(); + let segmentId; + cy.intercept('POST', '/api/admin/segments', req => { + req.continue(res => { + segmentId = res.body.id; + }); + }).as('createSegment'); + + cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName); + cy.get("[data-testid='SEGMENT_DESC_ID']").type('hello-world'); + cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").click(); + cy.get("[data-testid='SEGMENT_CREATE_BTN_ID']").click(); + cy.wait('@createSegment'); + return cy.wrap(segmentId); +}; + +export const deleteSegment_UI = (segmentName: string): Chainable => { + cy.get(`[data-testid='SEGMENT_DELETE_BTN_ID_${segmentName}']`).click(); + + cy.get("[data-testid='SEGMENT_DIALOG_NAME_ID']").type(segmentName); + return cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click(); +}; + +export const addFlexibleRolloutStrategyToFeature_UI = ( + options: AddStrategyOptions +): Chainable => { + const { featureToggleName, project, environment, stickiness } = options; + const projectName = project || 'default'; + const env = environment || 'development'; + const defaultStickiness = stickiness || 'default'; + + cy.visit(`/projects/default/features/${featureToggleName}`); + let strategyId; + cy.intercept( + 'POST', + `/api/admin/projects/${projectName}/features/${featureToggleName}/environments/development/strategies`, + req => { + expect(req.body.name).to.equal('flexibleRollout'); + expect(req.body.parameters.groupId).to.equal(featureToggleName); + expect(req.body.parameters.stickiness).to.equal(defaultStickiness); + expect(req.body.parameters.rollout).to.equal('50'); + + if (ENTERPRISE) { + expect(req.body.constraints.length).to.equal(1); + } else { + expect(req.body.constraints.length).to.equal(0); + } + + req.continue(res => { + strategyId = res.body.id; + }); + } + ).as('addStrategyToFeature'); + + cy.visit( + `/projects/${projectName}/features/${featureToggleName}/strategies/create?environmentId=${env}&strategyName=flexibleRollout` + ); + cy.wait(500); + // Takes a bit to load the screen - this will wait until it finds it or fail + cy.get('[data-testid=FLEXIBLE_STRATEGY_STICKINESS_ID]'); + if (ENTERPRISE) { + cy.get('[data-testid=ADD_CONSTRAINT_ID]').click(); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + } + cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); + cy.wait('@addStrategyToFeature'); + return cy.wrap(strategyId); +}; + +export const updateFlexibleRolloutStrategy_UI = ( + featureToggleName: string, + strategyId: string, + projectName?: string +) => { + const project = projectName || 'default'; + cy.visit( + `/projects/${project}/features/${featureToggleName}/strategies/edit?environmentId=development&strategyId=${strategyId}` + ); + + cy.wait(500); + + cy.get('[data-testid=FLEXIBLE_STRATEGY_STICKINESS_ID]') + .first() + .click() + .get('[data-testid=SELECT_ITEM_ID-sessionId') + .first() + .click(); + + cy.wait(500); + cy.get('[data-testid=FLEXIBLE_STRATEGY_GROUP_ID]') + .first() + .clear() + .type('new-group-id'); + + cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); + cy.intercept( + 'PUT', + `/api/admin/projects/${project}/features/${featureToggleName}/environments/*/strategies/${strategyId}`, + req => { + expect(req.body.parameters.groupId).to.equal('new-group-id'); + expect(req.body.parameters.stickiness).to.equal('sessionId'); + expect(req.body.parameters.rollout).to.equal('50'); + + if (ENTERPRISE) { + expect(req.body.constraints.length).to.equal(1); + } else { + expect(req.body.constraints.length).to.equal(0); + } + + req.continue(res => { + expect(res.statusCode).to.equal(200); + }); + } + ).as('updateStrategy'); + return cy.wait('@updateStrategy'); +}; + +export const deleteFeatureStrategy_UI = ( + featureToggleName: string, + strategyId: string, + shouldWait?: boolean, + projectName?: string +): Chainable => { + const project = projectName || 'default'; + + cy.intercept( + 'DELETE', + `/api/admin/projects/${project}/features/${featureToggleName}/environments/*/strategies/${strategyId}`, + req => { + req.continue(res => { + expect(res.statusCode).to.equal(200); + }); + } + ).as('deleteUserStrategy'); + cy.visit(`/projects/${project}/features/${featureToggleName}`); + cy.get('[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]').click(); + cy.get('[data-testid=STRATEGY_FORM_REMOVE_ID]').click(); + if (!shouldWait) return cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + else cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + return cy.wait('@deleteUserStrategy'); +}; + +export const addUserIdStrategyToFeature_UI = ( + featureToggleName: string, + projectName: string +): Chainable => { + const project = projectName || 'default'; + cy.visit( + `/projects/${project}/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=userWithId` + ); + + if (ENTERPRISE) { + cy.get('[data-testid=ADD_CONSTRAINT_ID]').click(); + cy.get('[data-testid=CONSTRAINT_AUTOCOMPLETE_ID]') + .type('{downArrow}'.repeat(1)) + .type('{enter}'); + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + } + + cy.get('[data-testid=STRATEGY_INPUT_LIST]') + .type('user1') + .type('{enter}') + .type('user2') + .type('{enter}'); + cy.get('[data-testid=ADD_TO_STRATEGY_INPUT_LIST]').click(); + let strategyId; + cy.intercept( + 'POST', + `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`, + req => { + expect(req.body.name).to.equal('userWithId'); + + expect(req.body.parameters.userIds.length).to.equal(11); + + if (ENTERPRISE) { + expect(req.body.constraints.length).to.equal(1); + } else { + expect(req.body.constraints.length).to.equal(0); + } + + req.continue(res => { + strategyId = res.body.id; + }); + } + ).as('addStrategyToFeature'); + + cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click(); + cy.wait('@addStrategyToFeature'); + return cy.wrap(strategyId); +}; + +export const addVariantsToFeature_UI = ( + featureToggleName: string, + variants: Array, + projectName: string +) => { + const project = projectName || 'default'; + cy.visit(`/projects/${project}/features/${featureToggleName}/variants`); + cy.wait(1000); + cy.intercept( + 'PATCH', + `/api/admin/projects/${project}/features/${featureToggleName}/environments/development/variants`, + req => { + variants.forEach((variant, index) => { + expect(req.body[index].op).to.equal('add'); + expect(req.body[index].path).to.equal(`/${index}`); + expect(req.body[index].value.name).to.equal(variant); + expect(req.body[index].value.weight).to.equal( + 1000 / variants.length + ); + }); + } + ).as('variantCreation'); + + cy.get('[data-testid=ADD_VARIANT_BUTTON]').first().click(); + cy.wait(500); + variants.forEach((variant, index) => { + cy.get('[data-testid=VARIANT_NAME_INPUT]').eq(index).type(variant); + index + 1 < variants.length && + cy.get('[data-testid=MODAL_ADD_VARIANT_BUTTON]').first().click(); + }); + + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').first().click(); + return cy.wait('@variantCreation'); +}; + +export const deleteVariant_UI = ( + featureToggleName: string, + variant: string, + projectName?: string +): Chainable => { + const project = projectName || 'default'; + cy.visit(`/projects/${project}/features/${featureToggleName}/variants`); + cy.get('[data-testid=EDIT_VARIANTS_BUTTON]').click(); + cy.wait(300); + cy.get(`[data-testid=VARIANT_DELETE_BUTTON_${variant}]`).first().click(); + + cy.intercept( + 'PATCH', + `/api/admin/projects/${project}/features/${featureToggleName}/environments/development/variants`, + req => { + expect(req.body[0].op).to.equal('remove'); + expect(req.body[0].path).to.equal('/1'); + expect(req.body[1].op).to.equal('replace'); + expect(req.body[1].path).to.equal('/0/weight'); + expect(req.body[1].value).to.equal(1000); + } + ).as('delete'); + + cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click(); + return cy.wait('@delete'); +}; + +export const logout_UI = (): Chainable => { + return cy.visit('/logout'); +}; diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 0d16067cc2..b76560c09d 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -1,49 +1,57 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +/// -const AUTH_USER = Cypress.env('AUTH_USER'); -const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD'); +import { + runBefore, + login_UI, + logout_UI, + createProject_UI, + createFeature_UI, + createSegment_UI, + deleteSegment_UI, + deleteVariant_UI, + deleteFeatureStrategy_UI, + addFlexibleRolloutStrategyToFeature_UI, + addUserIdStrategyToFeature_UI, + updateFlexibleRolloutStrategy_UI, + addVariantsToFeature_UI, + //@ts-ignore +} from './UI'; +import { + addUserToProject_API, + createFeature_API, + createProject_API, + createUser_API, + deleteFeature_API, + deleteProject_API, + updateUserPassword_API, + //@ts-ignore +} from './API'; -Cypress.Commands.add('login', (user = AUTH_USER, password = AUTH_PASSWORD) => - cy.session(user, () => { - cy.visit('/'); - cy.wait(1500); - cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user); - - if (AUTH_PASSWORD) { - cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password); - } - - cy.get("[data-testid='LOGIN_BUTTON']").click(); - - // Wait for the login redirect to complete. - cy.get("[data-testid='HEADER_USER_AVATAR']"); - }) +Cypress.Commands.add('runBefore', runBefore); +Cypress.Commands.add('login_UI', login_UI); +Cypress.Commands.add('createSegment_UI', createSegment_UI); +Cypress.Commands.add('deleteSegment_UI', deleteSegment_UI); +Cypress.Commands.add('deleteFeature_API', deleteFeature_API); +Cypress.Commands.add('deleteProject_API', deleteProject_API); +Cypress.Commands.add('logout_UI', logout_UI); +Cypress.Commands.add('createProject_UI', createProject_UI); +Cypress.Commands.add('createUser_API', createUser_API); +Cypress.Commands.add('addUserToProject_API', addUserToProject_API); +Cypress.Commands.add('updateUserPassword_API', updateUserPassword_API); +Cypress.Commands.add('createFeature_UI', createFeature_UI); +Cypress.Commands.add('deleteFeatureStrategy_UI', deleteFeatureStrategy_UI); +Cypress.Commands.add('createFeature_API', createFeature_API); +Cypress.Commands.add('deleteVariant_UI', deleteVariant_UI); +Cypress.Commands.add('addVariantsToFeature_UI', addVariantsToFeature_UI); +Cypress.Commands.add( + 'addUserIdStrategyToFeature_UI', + addUserIdStrategyToFeature_UI +); +Cypress.Commands.add( + 'addFlexibleRolloutStrategyToFeature_UI', + addFlexibleRolloutStrategyToFeature_UI +); +Cypress.Commands.add( + 'updateFlexibleRolloutStrategy_UI', + updateFlexibleRolloutStrategy_UI ); - -Cypress.Commands.add('logout', () => { - cy.visit('/logout'); -}); diff --git a/frontend/cypress/support/index.ts b/frontend/cypress/support/index.ts index a8f805772b..c7cca105d0 100644 --- a/frontend/cypress/support/index.ts +++ b/frontend/cypress/support/index.ts @@ -14,16 +14,8 @@ // *********************************************************** // Import commands.js using ES2015 syntax: +// @ts-ignore import './commands'; // Alternatively you can use CommonJS syntax: // require('./commands') - -declare global { - namespace Cypress { - interface Chainable { - login(user?: string, password?: string): Chainable; - logout(user?: string): Chainable; - } - } -} diff --git a/frontend/cypress/tsconfig.json b/frontend/cypress/tsconfig.json new file mode 100644 index 0000000000..a49f6356d0 --- /dev/null +++ b/frontend/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "../cypress.d.ts"], + "exclude": [], + "compilerOptions": { + "types": ["cypress"], + "lib": ["es2015", "dom"], + "isolatedModules": false, + "allowJs": true, + "noEmit": true + } +} diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx index 1415f69d78..9289bb5aab 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx @@ -20,6 +20,7 @@ import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashCon import { updateWeightEdit } from 'component/common/util'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; +import Loader from 'component/common/Loader/Loader'; const StyledFormSubtitle = styled('div')(({ theme }) => ({ display: 'flex', @@ -145,7 +146,7 @@ export const EnvironmentVariantsModal = ({ const { uiConfig } = useUiConfig(); const { context } = useUnleashContext(); - const { defaultStickiness } = useDefaultProjectSettings(projectId); + const { defaultStickiness, loading } = useDefaultProjectSettings(projectId); const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const { data } = usePendingChangeRequests(projectId); @@ -157,31 +158,33 @@ export const EnvironmentVariantsModal = ({ const [newVariant, setNewVariant] = useState(); useEffect(() => { - setVariantsEdit( - oldVariants.length - ? oldVariants.map(oldVariant => ({ - ...oldVariant, - isValid: true, - new: false, - id: uuidv4(), - })) - : [ - { - name: '', - weightType: WeightType.VARIABLE, - weight: 0, - overrides: [], - stickiness: - variantsEdit?.length > 0 - ? variantsEdit[0].stickiness - : defaultStickiness, - new: true, - isValid: false, + if (!loading) { + setVariantsEdit( + oldVariants.length + ? oldVariants.map(oldVariant => ({ + ...oldVariant, + isValid: true, + new: false, id: uuidv4(), - }, - ] - ); - }, [open]); + })) + : [ + { + name: '', + weightType: WeightType.VARIABLE, + weight: 0, + overrides: [], + stickiness: + variantsEdit?.length > 0 + ? variantsEdit[0].stickiness + : defaultStickiness, + new: true, + isValid: false, + id: uuidv4(), + }, + ] + ); + } + }, [open, loading]); const updateVariant = (updatedVariant: IFeatureVariantEdit, id: string) => { setVariantsEdit(prevVariants => @@ -206,7 +209,7 @@ export const EnvironmentVariantsModal = ({ stickiness: variantsEdit?.length > 0 ? variantsEdit[0].stickiness - : 'default', + : defaultStickiness, new: true, isValid: false, id, @@ -303,14 +306,18 @@ export const EnvironmentVariantsModal = ({ setError(apiPayload.error); } }, [apiPayload.error]); + + const handleClose = () => { + updateStickiness(defaultStickiness).catch(console.warn); + setOpen(false); + }; + + if (loading || stickiness === '') { + return ; + } + return ( - { - setOpen(false); - }} - label="" - > + - { - setOpen(false); - }} - > + Cancel diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx index bc1797739e..bfd09d6a42 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx @@ -15,6 +15,7 @@ import { StickinessSelect } from './StickinessSelect/StickinessSelect'; import { useOptionalPathParam } from 'hooks/useOptionalPathParam'; import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; import Loader from '../../../common/Loader/Loader'; +import { useMemo } from 'react'; interface IFlexibleStrategyProps { parameters: IFeatureStrategyParameters; @@ -30,6 +31,7 @@ const FlexibleStrategy = ({ }: IFlexibleStrategyProps) => { const projectId = useOptionalPathParam('projectId'); const { defaultStickiness, loading } = useDefaultProjectSettings(projectId); + const onUpdate = (field: string) => (newValue: string) => { updateParameter(field, newValue); }; @@ -43,15 +45,13 @@ const FlexibleStrategy = ({ ? parseParameterNumber(parameters.rollout) : 100; - const resolveStickiness = () => { - if (parameters.stickiness === '') { + const stickiness = useMemo(() => { + if (parameters.stickiness === '' && !loading) { return defaultStickiness; } return parseParameterString(parameters.stickiness); - }; - - const stickiness = resolveStickiness(); + }, [loading, parameters.stickiness]); if (parameters.stickiness === '') { onUpdate('stickiness')(stickiness); diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index d8193e431b..39f7489844 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -13,7 +13,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import Select from 'component/common/select'; -import { DefaultStickiness, ProjectMode } from '../hooks/useProjectForm'; +import { ProjectMode } from '../hooks/useProjectForm'; import { Box } from '@mui/material'; import { CollaborationModeTooltip } from './CollaborationModeTooltip'; @@ -23,9 +23,7 @@ interface IProjectForm { projectDesc: string; projectStickiness?: string; projectMode?: string; - setProjectStickiness?: React.Dispatch< - React.SetStateAction - >; + setProjectStickiness?: React.Dispatch>; setProjectMode?: React.Dispatch>; setProjectId: React.Dispatch>; setProjectName: React.Dispatch>; @@ -127,9 +125,7 @@ const ProjectForm: React.FC = ({ data-testid={PROJECT_STICKINESS_SELECT} onChange={e => setProjectStickiness && - setProjectStickiness( - e.target.value as DefaultStickiness - ) + setProjectStickiness(e.target.value) } editable /> diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 7e34bb45d4..8c7a670cf9 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -1,27 +1,23 @@ import { useEffect, useState } from 'react'; import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; export type ProjectMode = 'open' | 'protected'; -export type DefaultStickiness = 'default' | 'userId' | 'sessionId' | 'random'; export const DEFAULT_PROJECT_STICKINESS = 'default'; const useProjectForm = ( initialProjectId = '', initialProjectName = '', initialProjectDesc = '', - initialProjectStickiness: DefaultStickiness = DEFAULT_PROJECT_STICKINESS, + initialProjectStickiness = DEFAULT_PROJECT_STICKINESS, initialProjectMode: ProjectMode = 'open' ) => { const [projectId, setProjectId] = useState(initialProjectId); - const { defaultStickiness } = useDefaultProjectSettings(projectId); const [projectName, setProjectName] = useState(initialProjectName); const [projectDesc, setProjectDesc] = useState(initialProjectDesc); - const [projectStickiness, setProjectStickiness] = - useState( - defaultStickiness || initialProjectStickiness - ); + const [projectStickiness, setProjectStickiness] = useState( + initialProjectStickiness + ); const [projectMode, setProjectMode] = useState(initialProjectMode); const [errors, setErrors] = useState({}); diff --git a/frontend/src/hooks/useDefaultProjectSettings.ts b/frontend/src/hooks/useDefaultProjectSettings.ts index 0e069c5914..1f9add93b1 100644 --- a/frontend/src/hooks/useDefaultProjectSettings.ts +++ b/frontend/src/hooks/useDefaultProjectSettings.ts @@ -3,14 +3,11 @@ import { SWRConfiguration } from 'swr'; import { useCallback } from 'react'; import handleErrorResponses from './api/getters/httpErrorResponseHandler'; import { useConditionalSWR } from './api/getters/useConditionalSWR/useConditionalSWR'; -import { - DefaultStickiness, - ProjectMode, -} from 'component/project/Project/hooks/useProjectForm'; +import { ProjectMode } from 'component/project/Project/hooks/useProjectForm'; import { formatApiPath } from 'utils/formatPath'; export interface ISettingsResponse { - defaultStickiness?: DefaultStickiness; + defaultStickiness?: string; mode?: ProjectMode; } const DEFAULT_STICKINESS = 'default'; @@ -23,24 +20,32 @@ export const useDefaultProjectSettings = ( const PATH = `api/admin/projects/${projectId}/settings`; const { projectScopedStickiness } = uiConfig.flags; - const { data, error, mutate } = useConditionalSWR( - Boolean(projectId) && Boolean(projectScopedStickiness), - {}, - ['useDefaultProjectSettings', PATH], - () => fetcher(formatApiPath(PATH)), - options - ); + const { data, isLoading, error, mutate } = + useConditionalSWR( + Boolean(projectId) && Boolean(projectScopedStickiness), + {}, + ['useDefaultProjectSettings', PATH], + () => fetcher(formatApiPath(PATH)), + options + ); - const defaultStickiness: DefaultStickiness = - data?.defaultStickiness ?? DEFAULT_STICKINESS; + const defaultStickiness = (): string => { + if (!isLoading) { + if (data?.defaultStickiness) { + return data?.defaultStickiness; + } + return DEFAULT_STICKINESS; + } + return ''; + }; const refetch = useCallback(() => { mutate().catch(console.warn); }, [mutate]); return { - defaultStickiness, + defaultStickiness: defaultStickiness(), refetch, - loading: !error && !data, + loading: isLoading, error, }; }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2dcb13545f..37ee123501 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -18,8 +18,9 @@ "strict": true, "paths": { "@server/*": ["./../../src/lib/*"] - } + }, + "types": ["cypress"] }, - "include": ["./src"], + "include": ["./src", "cypress.d.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/src/lib/services/project-schema.ts b/src/lib/services/project-schema.ts index 6051f55cee..ca6282b7b4 100644 --- a/src/lib/services/project-schema.ts +++ b/src/lib/services/project-schema.ts @@ -8,9 +8,6 @@ export const projectSchema = joi name: joi.string().required(), description: joi.string().allow(null).allow('').optional(), mode: joi.string().valid('open', 'protected').default('open'), - defaultStickiness: joi - .string() - .valid('default', 'userId', 'sessionId', 'random') - .default('default'), + defaultStickiness: joi.string().default('default'), }) .options({ allowUnknown: false, stripUnknown: true }); From 4567ae6e1e6c8abf99fe0dc531e628cb4d7db67a Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Wed, 5 Apr 2023 12:35:51 +0000 Subject: [PATCH 05/17] 4.22.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 68fd16acf3..69153e8f89 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unleash-server", "description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.", - "version": "4.22.1", + "version": "4.22.2", "keywords": [ "unleash", "feature toggle", From 92328f6c274e5bc3399e147d521a527e7914cd86 Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Thu, 6 Apr 2023 17:17:46 +0300 Subject: [PATCH 06/17] fix: stickiness (#3471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixes project model/store - Fixes UI sync issues when switching stickiness ## About the changes Closes # ### Important files ## Discussion points --------- Signed-off-by: andreas-unleash --- .../integration/projects/settings.spec.ts | 1 - .../FeatureStrategyCreate.tsx | 2 +- .../EnvironmentVariantsModal.tsx | 13 +++-- .../FlexibleStrategy/FlexibleStrategy.tsx | 4 +- .../StickinessSelect/StickinessSelect.tsx | 1 - .../api/getters/useProject/useProject.ts | 1 + .../src/hooks/useDefaultProjectSettings.ts | 54 +++---------------- frontend/src/interfaces/project.ts | 1 + src/lib/db/project-store.ts | 3 +- src/lib/services/project-service.ts | 3 +- src/lib/types/model.ts | 2 +- .../api/admin/project/features.e2e.test.ts | 7 ++- src/test/e2e/api/proxy/proxy.e2e.test.ts | 2 +- .../services/api-token-service.e2e.test.ts | 1 + .../e2e/services/project-service.e2e.test.ts | 44 +++++++++++++++ src/test/fixtures/fake-project-store.ts | 1 + 16 files changed, 79 insertions(+), 61 deletions(-) diff --git a/frontend/cypress/integration/projects/settings.spec.ts b/frontend/cypress/integration/projects/settings.spec.ts index 03512897c9..bdff8ab941 100644 --- a/frontend/cypress/integration/projects/settings.spec.ts +++ b/frontend/cypress/integration/projects/settings.spec.ts @@ -66,7 +66,6 @@ describe('project settings', () => { ); cy.get("[data-testid='ADD_VARIANT_BUTTON']").first().click(); - cy.wait(300); //then cy.get("[id='stickiness-select']") .first() diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx index 0047c06518..93be11a0bf 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx @@ -74,7 +74,7 @@ export const FeatureStrategyCreate = () => { forceRefreshCache(feature); ref.current = feature; } - }, [feature]); + }, [feature.name]); useEffect(() => { if (strategyDefinition) { diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx index 9289bb5aab..83058c7c43 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx @@ -273,7 +273,13 @@ export const EnvironmentVariantsModal = ({ isChangeRequestConfigured(environment?.name || '') && uiConfig.flags.crOnVariants; - const stickiness = variants[0]?.stickiness || defaultStickiness; + const stickiness = useMemo(() => { + if (!loading) { + return variants[0]?.stickiness || defaultStickiness; + } + return ''; + }, [loading, defaultStickiness, JSON.stringify(variants[0] ?? {})]); + const stickinessOptions = useMemo( () => [ 'default', @@ -296,7 +302,7 @@ export const EnvironmentVariantsModal = ({ }; const onStickinessChange = (value: string) => { - updateStickiness(value).catch(console.warn); + updateStickiness(value); }; const [error, setError] = useState(); @@ -308,14 +314,13 @@ export const EnvironmentVariantsModal = ({ }, [apiPayload.error]); const handleClose = () => { - updateStickiness(defaultStickiness).catch(console.warn); + updateStickiness(defaultStickiness); setOpen(false); }; if (loading || stickiness === '') { return ; } - return ( { - const projectId = useOptionalPathParam('projectId'); + const projectId = useRequiredPathParam('projectId'); const { defaultStickiness, loading } = useDefaultProjectSettings(projectId); const onUpdate = (field: string) => (newValue: string) => { diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx index a857bacca5..8565e13fe7 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx @@ -41,7 +41,6 @@ export const StickinessSelect = ({ ); const stickinessOptions = resolveStickinessOptions(); - return (