diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml new file mode 100644 index 000000000..4e86dec2c --- /dev/null +++ b/.github/workflows/cypress.yml @@ -0,0 +1,84 @@ +name: Cypress CI + +on: + push: + branches: + - 'main' + merge_group: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: false + +jobs: + cypress: + name: Cypress + runs-on: ubuntu-latest + timeout-minutes: 50 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + + - name: Yarn Install and Cache + uses: graasp/graasp-deploy/.github/actions/yarn-install-and-cache@v1 + with: + cypress: true + + # type check + - name: Type-check code + run: tsc --noEmit + + - name: Build App + run: NODE_OPTIONS=--max-old-space-size=8192 yarn build:test + shell: bash + env: + VITE_PORT: ${{ vars.VITE_PORT }} + VITE_VERSION: ${{ vars.VITE_VERSION }} + VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }} + VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }} + VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }} + VITE_GRAASP_PLAYER_HOST: ${{ vars.VITE_GRAASP_PLAYER_HOST }} + VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} + VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }} + VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} + VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} + + # use the Cypress GitHub Action to run Cypress tests within the chrome browser + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + install: false + # we launch the app in preview mode to avoid issues with hmr websockets from vite polluting the mocks + start: yarn preview:test + browser: chrome + quiet: true + config-file: cypress.config.ts + cache-key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + env: + VITE_PORT: ${{ vars.VITE_PORT }} + VITE_VERSION: ${{ vars.VITE_VERSION }} + VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }} + VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }} + VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }} + VITE_GRAASP_PLAYER_HOST: ${{ vars.VITE_GRAASP_PLAYER_HOST }} + VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} + VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }} + VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} + VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} + + # after the test run completes + # store any screenshots + # NOTE: screenshots will be generated only if E2E test failed + # thus we store screenshots only on failures + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: cypress/screenshots + + - name: coverage report + run: npx nyc report --reporter=text-summary diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..5d775e340 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,24 @@ +import setupCoverage from '@cypress/code-coverage/task'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + env: { + VITE_GRAASP_REDIRECTION_HOST: process.env.VITE_GRAASP_REDIRECTION_HOST, + VITE_GRAASP_DOMAIN: process.env.VITE_GRAASP_DOMAIN, + VITE_GRAASP_API_HOST: process.env.VITE_GRAASP_API_HOST, + VITE_SHOW_NOTIFICATIONS: false, + VITE_GRAASP_AUTH_HOST: process.env.VITE_GRAASP_AUTH_HOST, + VITE_GRAASP_PLAYER_HOST: process.env.VITE_GRAASP_PLAYER_HOST, + VITE_GRAASP_ANALYZER_HOST: process.env.VITE_GRAASP_ANALYZER_HOST, + VITE_GRAASP_LIBRARY_HOST: process.env.VITE_GRAASP_LIBRARY_HOST, + VITE_GRAASP_ACCOUNT_HOST: process.env.VITE_GRAASP_ACCOUNT_HOST, + }, + setupNodeEvents(on, config) { + // implement node event listeners here + setupCoverage(on, config); + return config; + }, + baseUrl: `http://localhost:${process.env.VITE_PORT || 3333}`, + }, +}); diff --git a/cypress/cypress.d.ts b/cypress/cypress.d.ts new file mode 100644 index 000000000..9400e626b --- /dev/null +++ b/cypress/cypress.d.ts @@ -0,0 +1,6 @@ +declare namespace Cypress { + interface Chainable { + // todo: improve types + setUpApi(args: any): Chainable; + } +} diff --git a/cypress/e2e/profile.cy.ts b/cypress/e2e/profile.cy.ts new file mode 100644 index 000000000..91e5a302d --- /dev/null +++ b/cypress/e2e/profile.cy.ts @@ -0,0 +1,6 @@ +describe('template spec', () => { + it('passes', () => { + cy.setUpApi(); + cy.visit('/'); + }); +}); diff --git a/cypress/fixtures/members.ts b/cypress/fixtures/members.ts new file mode 100644 index 000000000..25adce5c7 --- /dev/null +++ b/cypress/fixtures/members.ts @@ -0,0 +1,6 @@ +import { MemberFactory } from '@graasp/sdk'; + +// eslint-disable-next-line import/prefer-default-export +export const CURRENT_MEMBER = MemberFactory(); + +export const MEMBERS = []; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 000000000..11a3738cc --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,48 @@ +/// +import { CookieKeys } from '@graasp/sdk'; + +import { CURRENT_MEMBER, MEMBERS } from '../fixtures/members'; +import { + mockEditMember, + mockGetAvatarUrl, + mockGetCurrentMember, + mockGetMember, + mockPostAvatar, + mockSignInRedirection, + mockSignOut, + mockUpdatePassword, +} from './server'; + +Cypress.Commands.add( + 'setUpApi', + ({ + members = Object.values(MEMBERS), + currentMember = CURRENT_MEMBER, + getCurrentMemberError = false, + editMemberError = false, + getAvatarUrlError = false, + postAvatarError = false, + updatePasswordError = false, + } = {}) => { + const cachedMembers = JSON.parse(JSON.stringify(members)); + + // hide cookie banner by default + cy.setCookie(CookieKeys.AcceptCookies, 'true'); + + mockGetMember(cachedMembers); + + mockGetCurrentMember(currentMember, getCurrentMemberError); + + mockSignInRedirection(); + + mockSignOut(); + + mockEditMember(members, editMemberError); + + mockGetAvatarUrl(members, getAvatarUrlError); + + mockPostAvatar(postAvatarError); + + mockUpdatePassword(members, updatePasswordError); + }, +); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 000000000..b493c8a70 --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,21 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +// Import commands.js using ES2015 syntax: +import '@cypress/code-coverage/support'; + +import './commands'; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/cypress/support/server.ts b/cypress/support/server.ts new file mode 100644 index 000000000..b1c779267 --- /dev/null +++ b/cypress/support/server.ts @@ -0,0 +1,184 @@ +import { API_ROUTES } from '@graasp/query-client'; +import { HttpMethod, Member, buildSignInPath } from '@graasp/sdk'; + +import { StatusCodes } from 'http-status-codes'; + +import { CURRENT_MEMBER } from '../fixtures/members'; +import { ID_FORMAT, MemberForTest } from './utils'; + +const { + buildGetMember, + GET_CURRENT_MEMBER_ROUTE, + SIGN_OUT_ROUTE, + buildPatchMember, + buildUploadAvatarRoute, + buildUpdateMemberPasswordRoute, +} = API_ROUTES; + +export const SIGN_IN_PATH = buildSignInPath({ + host: Cypress.env('VITE_GRAASP_AUTH_HOST'), +}); +const API_HOST = Cypress.env('VITE_GRAASP_API_HOST'); +export const AVATAR_LINK = 'https://picsum.photos/200/200'; + +export const redirectionReply = { + headers: { 'content-type': 'application/json' }, + statusCode: StatusCodes.OK, +}; + +export const mockGetCurrentMember = ( + currentMember = CURRENT_MEMBER, + shouldThrowError = false, +): void => { + cy.intercept( + { + method: HttpMethod.Get, + url: `${API_HOST}/${GET_CURRENT_MEMBER_ROUTE}`, + }, + ({ reply }) => { + if (shouldThrowError) { + return reply({ + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + body: null, + }); + } + + // might reply empty user when signed out + return reply({ statusCode: StatusCodes.OK, body: currentMember }); + }, + ).as('getCurrentMember'); +}; + +export const mockGetMember = (members: Member[]): void => { + cy.intercept( + { + method: HttpMethod.Get, + url: new RegExp(`${API_HOST}/${buildGetMember(ID_FORMAT)}$`), + }, + ({ url, reply }) => { + const memberId = url.slice(API_HOST.length).split('/')[2]; + const member = members.find((m) => m.id === memberId); + + // member does not exist in db + if (!member) { + return reply({ + statusCode: StatusCodes.NOT_FOUND, + }); + } + + return reply({ + body: member, + statusCode: StatusCodes.OK, + }); + }, + ).as('getMember'); +}; + +export const mockEditMember = ( + _members: Member[], + shouldThrowError: boolean, +): void => { + cy.intercept( + { + method: HttpMethod.Patch, + url: new RegExp(`${API_HOST}/${buildPatchMember(ID_FORMAT)}`), + }, + ({ reply }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + return reply('edit member'); + }, + ).as('editMember'); +}; + +export const mockSignInRedirection = (): void => { + cy.intercept( + { + method: HttpMethod.Get, + url: SIGN_IN_PATH, + }, + ({ reply }) => { + reply(redirectionReply); + }, + ).as('signInRedirection'); +}; + +export const mockSignOut = (): void => { + cy.intercept( + { + method: HttpMethod.Get, + url: new RegExp(SIGN_OUT_ROUTE), + }, + ({ reply }) => { + reply(redirectionReply); + }, + ).as('signOut'); +}; + +export const mockGetAvatarUrl = ( + members: MemberForTest[], + shouldThrowError: boolean, +): void => { + cy.intercept( + { + method: HttpMethod.Get, + // TODO: include all sizes + url: new RegExp( + `${API_HOST}/members/${ID_FORMAT}/avatar/small\\?replyUrl\\=true`, + ), + }, + ({ reply, url }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + const [link] = url.split('?'); + const id = link.slice(API_HOST.length).split('/')[2]; + + const { thumbnails } = + members.find(({ id: thisId }) => id === thisId) ?? {}; + if (!thumbnails) { + return reply({ statusCode: StatusCodes.NOT_FOUND }); + } + // TODO: REPLY URL + return reply(AVATAR_LINK); + }, + ).as('downloadAvatarUrl'); +}; + +export const mockPostAvatar = (shouldThrowError: boolean): void => { + cy.intercept( + { + method: HttpMethod.Post, + url: new RegExp(`${buildUploadAvatarRoute()}`), + }, + ({ reply }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + return reply({ statusCode: StatusCodes.OK }); + }, + ).as('uploadAvatar'); +}; + +export const mockUpdatePassword = ( + _members: Member[], + shouldThrowError: boolean, +): void => { + cy.intercept( + { + method: HttpMethod.Patch, + url: new RegExp(`${API_HOST}/${buildUpdateMemberPasswordRoute()}`), + }, + ({ reply }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + return reply('update password'); + }, + ).as('updatePassword'); +}; diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts new file mode 100644 index 000000000..3e5430d3a --- /dev/null +++ b/cypress/support/utils.ts @@ -0,0 +1,6 @@ +import { CompleteMember } from '@graasp/sdk'; + +export const ID_FORMAT = '(?=.*[0-9])(?=.*[a-zA-Z])([a-z0-9-]+)'; + +// TODO: not ideal, to change? +export type MemberForTest = CompleteMember & { thumbnails?: string };