diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index ac12011429..76098bd31c 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -252,9 +252,55 @@ jobs: wait-on: "http://localhost:8880" working-directory: ./frontend + e2e_multi_windows_test: + name: Run E2E multi windows tests + needs: [version, build] + runs-on: ubuntu-22.04 + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + DB_PUBLIC_PORT: 5432 + ENV_PROFILE: ${{ needs.version.outputs.ENV_PROFILE }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MONITORFISH_VERSION: ${{ needs.version.outputs.VERSION }} + VITE_CYPRESS_PORT: 8880 + VERSION: ${{ needs.version.outputs.VERSION }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image + uses: ishworkh/docker-image-artifact-download@v1 + with: + image: monitorfish-app:${{ env.VERSION }} + + - name: Docker login + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${GITHUB_ACTOR} --password-stdin + + - name: Run docker images + run: make docker-compose-puppeteer-up + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + cache: npm + cache-dependency-path: ./frontend/package-lock.json + node-version: 20 + + - name: Install Node.js dependencies + run: npm ci + working-directory: ./frontend + + - name: Install Firefox + run: npx puppeteer browsers install firefox + + - name: Run multi-windows tests + run: npm run test:multi-windows:run + working-directory: ./frontend + push_to_registry: name: Push to registry - needs: [version, e2e_test] + needs: [version, e2e_test, e2e_multi_windows_test] runs-on: ubuntu-22.04 if: needs.version.outputs.IS_RELEASE && !contains(github.ref, 'dependabot') env: diff --git a/Makefile b/Makefile index dbf84d8583..104271ed5f 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ stop-stubbed-apis: erase-db: docker compose down -v docker compose -f ./frontend/cypress/docker-compose.yml down -v + docker compose -f ./frontend/puppeteer/docker-compose.dev.yml down -v check-clean-archi: cd backend/tools && ./check-clean-architecture.sh test: test-back @@ -35,6 +36,12 @@ lint-back: -e "Exceeded max line length" \ -e "Package name must not contain underscore" \ -e "Wildcard import" +run-back-for-puppeteer: run-stubbed-apis + docker compose up -d --quiet-pull --wait db + docker compose -f ./frontend/puppeteer/docker-compose.dev.yml up -d + cd backend && MONITORENV_URL=http://localhost:8882 ./gradlew bootRun --args='--spring.profiles.active=local --spring.config.additional-location=$(INFRA_FOLDER)' +run-front-for-puppeteer: + cd ./frontend && npm run dev-puppeteer # CI commands - app docker-build: @@ -59,6 +66,11 @@ docker-compose-up: @printf 'Waiting for backend app to be ready' @until curl --output /dev/null --silent --fail "http://localhost:8880/bff/v1/healthcheck"; do printf '.' && sleep 1; done +docker-compose-puppeteer-up: + docker compose -f ./frontend/puppeteer/docker-compose.yml up -d + @printf 'Waiting for backend app to be ready' + @until curl --output /dev/null --silent --fail "http://localhost:8880/bff/v1/healthcheck"; do printf '.' && sleep 1; done + # CI commands - data pipeline docker-build-pipeline: docker build -f "infra/docker/Dockerfile.DataPipeline" . -t monitorfish-pipeline:$(VERSION) diff --git a/adrs/0002-mission-form-sync-e2e-tests.md b/adrs/0002-mission-form-sync-e2e-tests.md new file mode 100644 index 0000000000..4fc476adb3 --- /dev/null +++ b/adrs/0002-mission-form-sync-e2e-tests.md @@ -0,0 +1,36 @@ +# Test end-to-end (e2e) multi-fenêtres pour la synchronisation des missions + +Date: 17/01/2024 + +## Statut + +Résolu, à approfondir. + +## Contexte + +Les tests de synchronisation du formulaire des missions sont : +- Fastidieux à effectuer (il faut ouvrir plusieurs fenêtre et switcher entre celles-ci) et difficile à repliquer +- Impossible à exécuter de manière automatique dans `Cypress` + +En se basant sur [cet article](https://liveblocks.io/blog/e2e-tests-with-puppeteer-and-jest-for-multiplayer-apps), `Puppeteer` a été choisi comme pour tester +sur plusieurs fenêtres. + +### `Puppeteer` + +L'outil est simple à mettre en place et se focalise seulement sur la manipulation d'un navigateur depuis `NodeJS`. +Nous utilisons `jest` pour écrire les assertions. + +Il est possible de tester en mode `headless` (pour la CI) ou `headfull` (pour développer). + +Les tests ont été concluants et permettent d'avoir un premier jeu de tests de la synchronisation des missions, voir `puppeteer/e2e/*.spec.ts`. + +## Décision + +`Puppeteer` est ajouté dans la CI de tests e2e. + +## Conséquences + +Une deuxième librairie de test e2E a été ajoutée, attention à bien utiliser `Puppeteer` seulement pour les tests de plusieurs fenêtres. + +à creuser : +- L'utilisation de `Puppeteer` pour les tests de `SideWindow` (ouvertes avec `document.open()`) diff --git a/frontend/.env.example b/frontend/.env.example index 600e94ce7b..a54a7e9f4b 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,5 @@ FRONTEND_GEOSERVER_LOCAL_URL= FRONTEND_GEOSERVER_REMOTE_URL= -FRONTEND_IS_DEV_ENV= FRONTEND_MAPBOX_KEY= FRONTEND_MONITORENV_URL= FRONTEND_OIDC_AUTHORITY= diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 42da0ffb18..b28e020489 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -134,7 +134,7 @@ module.exports = { // Jest { - files: ['__mocks__/**/*.[j|t]s', '**/*.test.ts', '**/*.test.tsx'], + files: ['__mocks__/**/*.[j|t]s', '**/*.test.ts', '**/*.test.tsx', 'puppeteer/**/*.ts'], plugins: ['jest'], env: { jest: true @@ -144,7 +144,13 @@ module.exports = { 'jest/no-focused-tests': 'error', 'jest/no-identical-title': 'error', 'jest/prefer-to-have-length': 'error', - 'jest/valid-expect': 'error' + 'jest/valid-expect': 'error', + 'no-await-in-loop': 'off', + 'no-console': 'off', + 'no-restricted-syntax': 'off', + 'no-underscore-dangle': 'off', + 'import/no-default-export': 'off', + 'import/no-extraneous-dependencies': 'off', } }, diff --git a/frontend/config/multi-windows/jest.config.js b/frontend/config/multi-windows/jest.config.js new file mode 100644 index 0000000000..5a82910bf2 --- /dev/null +++ b/frontend/config/multi-windows/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + rootDir: '../..', + globalSetup: "/puppeteer/setup.ts", + testEnvironment: '/puppeteer/puppeteer_environment.ts', + // because it's detected by the default value of testRegex + // https://jestjs.io/docs/configuration#testregex-string--arraystring + globalTeardown: "/puppeteer/teardown.ts", + testMatch: ['/puppeteer/e2e/*.spec.ts'], + preset: "ts-jest", + transform: { + ".ts": [ + "ts-jest", + { + isolatedModules: true, + useESM: true, + } + ] + } +} diff --git a/frontend/cypress/docker-compose.yml b/frontend/cypress/docker-compose.yml index 9d9d48421c..553d6735ab 100644 --- a/frontend/cypress/docker-compose.yml +++ b/frontend/cypress/docker-compose.yml @@ -36,7 +36,6 @@ services: - MONITORFISH_API_PROTECTED_API_KEY=APIKEY - FRONTEND_GEOSERVER_LOCAL_URL=http://0.0.0.0:8081 - FRONTEND_GEOSERVER_REMOTE_URL=http://0.0.0.0:8081 - - FRONTEND_IS_DEV_ENV=true - FRONTEND_MAPBOX_KEY=pk.eyJ1IjoibW9uaXRvcmZpc2giLCJhIjoiY2tsdHJ6dHhhMGZ0eDJ2bjhtZmJlOHJmZiJ9.bdi1cO-cUcZKXdkEkqAoZQ - FRONTEND_MONITORENV_URL=http://0.0.0.0:8081 - FRONTEND_OIDC_AUTHORITY=https://authentification.recette.din.developpement-durable.gouv.fr/authSAML/oidc/monitorfish diff --git a/frontend/cypress/e2e/side_window/mission_form/action_list.spec.ts b/frontend/cypress/e2e/side_window/mission_form/action_list.spec.ts index a225ddc02b..c651f7b504 100644 --- a/frontend/cypress/e2e/side_window/mission_form/action_list.spec.ts +++ b/frontend/cypress/e2e/side_window/mission_form/action_list.spec.ts @@ -6,6 +6,32 @@ import { SeaFrontGroup } from '../../../../src/domain/entities/seaFront/constant import { editSideWindowMissionListMissionWithId } from '../mission_list/utils' context('Side Window > Mission Form > Action List', () => { + it('Should focus to the last action selected', () => { + openSideWindowNewMission() + + cy.clickButton('Ajouter') + + cy.clickButton('Ajouter un contrôle en mer') + cy.get('*[data-cy="action-list-item"]').contains('Contrôle en mer') + cy.get('*[data-cy="action-list-item"]').should('have.css', 'outline', 'rgb(86, 151, 210) solid 2px') + + cy.wait(250) + + cy.clickButton('Ajouter') + cy.clickButton('Ajouter une note libre') + + cy.wait(250) + cy.get('*[data-cy="action-list-item"]').eq(0).should('have.css', 'outline', 'rgb(86, 151, 210) solid 2px') + cy.get('*[data-cy="action-list-item"]').eq(0).should('not.contain', 'Contrôle en mer') + + cy.get('*[data-cy="action-list-item"]').eq(1).should('not.have.css', 'outline', 'rgb(86, 151, 210) solid 2px') + cy.get('*[data-cy="action-list-item"]').eq(1).contains('Contrôle en mer') + cy.wait(250) + + cy.fill('Observations, commentaires...', 'Une observation.') + cy.get('*[data-cy="action-list-item"]').eq(0).should('not.contain', 'Une observation.') + }) + it('Should send the expected data to the API when duplicating a mission action', () => { editSideWindowMissionListMissionWithId(4, SeaFrontGroup.MEMN) diff --git a/frontend/cypress/e2e/side_window/mission_form/land_control.spec.ts b/frontend/cypress/e2e/side_window/mission_form/land_control.spec.ts index 70cd04a160..87fcd7f0ec 100644 --- a/frontend/cypress/e2e/side_window/mission_form/land_control.spec.ts +++ b/frontend/cypress/e2e/side_window/mission_form/land_control.spec.ts @@ -304,10 +304,20 @@ context('Side Window > Mission Form > Land Control', () => { it('Should fill the mission zone from the last land control added', () => { const now = getUtcDateInMultipleFormats() - cy.intercept('POST', '/api/v1/mission', { + cy.intercept('POST', '/api/v1/missions', { + body: { + id: 1 + }, statusCode: 201 }).as('createMission') + cy.intercept('POST', '/api/v1/missions/1', { + body: { + id: 1 + }, + statusCode: 201 + }).as('updateMission') + cy.intercept('POST', '/bff/v1/mission_actions', { statusCode: 201 }).as('createMissionAction') @@ -371,7 +381,7 @@ context('Side Window > Mission Form > Land Control', () => { // Request cy.waitForLastRequest( - '@createMission', + '@updateMission', { body: { controlUnits: [ @@ -466,7 +476,6 @@ context('Side Window > Mission Form > Land Control', () => { isClosed: false, isGeometryComputedFromControls: true, isUnderJdp: true, - isValid: true, missionSource: 'MONITORFISH', missionTypes: ['LAND'] } diff --git a/frontend/cypress/e2e/side_window/mission_form/main_form.spec.ts b/frontend/cypress/e2e/side_window/mission_form/main_form.spec.ts index aa345f22a5..15c7cf594e 100644 --- a/frontend/cypress/e2e/side_window/mission_form/main_form.spec.ts +++ b/frontend/cypress/e2e/side_window/mission_form/main_form.spec.ts @@ -18,7 +18,7 @@ context('Side Window > Mission Form > Main Form', () => { cy.get('label').contains('Administration 2').should('not.exist') }) - it('Should send the expected data to the API when creating a new mission (required fields only)', () => { + it('Should send the expected data to the API when creating a new mission (required fields)', () => { openSideWindowNewMission() cy.get('div').contains('Mission non enregistrée.') cy.get('.Element-Tag').contains('Enregistrement auto. actif') @@ -42,7 +42,7 @@ context('Side Window > Mission Form > Main Form', () => { } assert.isUndefined(interception.request.body.endDateTimeUtc) - // We only need to accurately test this prop in one test, no need to repeat it for each case + // We need to accurately test this prop in one test, no need to repeat it for each case assert.match(interception.request.body.startDateTimeUtc, expectedStartDateTimeUtc) assert.deepInclude(interception.request.body, { controlUnits: [ @@ -117,7 +117,7 @@ context('Side Window > Mission Form > Main Form', () => { } assert.isUndefined(interception.request.body.endDateTimeUtc) - // We only need to accurately test this prop in one test, no need to repeat it for each case + // We need to accurately test this prop in one test, no need to repeat it for each case assert.match(interception.request.body.startDateTimeUtc, expectedStartDateTimeUtc) assert.deepInclude(interception.request.body, { controlUnits: [ @@ -158,6 +158,13 @@ context('Side Window > Mission Form > Main Form', () => { statusCode: 201 }).as('createMission') + cy.intercept('POST', '/api/v1/missions/1', { + body: { + id: 1 + }, + statusCode: 201 + }).as('updateMission') + cy.fill('Début de mission', [2023, 2, 1, 12, 34]) cy.fill('Fin de mission', [2023, 2, 1, 13, 45]) @@ -168,6 +175,7 @@ context('Side Window > Mission Form > Main Form', () => { cy.fill('Administration 1', 'DDTM') cy.fill('Unité 1', 'Cultures marines – DDTM 40') + cy.wait(500) cy.fill('Moyen 1', ['Semi-rigide 1']) cy.fill('Contact de l’unité 1', 'Bob') cy.fill('Contact de l’unité 1', 'Bob') @@ -178,6 +186,7 @@ context('Side Window > Mission Form > Main Form', () => { cy.fill('Unité 2', 'DREAL Pays-de-La-Loire') cy.fill('Moyen 2', ['ALTAIR', 'ARIOLA']) cy.fill('Contact de l’unité 2', 'Bob 2') + cy.wait(500) cy.fill('CACEM : orientations, observations', 'Une note.') cy.fill('CNSP : orientations, observations', 'Une autre note.') @@ -186,9 +195,8 @@ context('Side Window > Mission Form > Main Form', () => { cy.wait(500) - // Approx. 4 requests are sent to the server cy.waitForLastRequest( - '@createMission', + '@updateMission', { body: { closedBy: 'Doris', @@ -237,7 +245,7 @@ context('Side Window > Mission Form > Main Form', () => { startDateTimeUtc: '2023-02-01T12:34:00.000Z' } }, - 5 + 10 ) .its('response.statusCode') .should('eq', 201) @@ -393,19 +401,24 @@ context('Side Window > Mission Form > Main Form', () => { }) it('Should close a new mission', () => { + cy.intercept('POST', '/api/v1/missions/1', { + body: { + id: 1, + isClosed: true + }, + statusCode: 201 + }).as('updateMission') openSideWindowNewMission() - fillSideWindowMissionFormBase(Mission.MissionTypeLabel.SEA, true) + fillSideWindowMissionFormBase(Mission.MissionTypeLabel.SEA, false) cy.fill('Clôturé par', 'Doris') - cy.wait(300) + cy.wait(500) cy.clickButton('Clôturer') - cy.wait(250) - cy.waitForLastRequest( - '@createMission', + '@updateMission', { body: { // We check this prop to be sure all the data is there (this is the last field to be filled) @@ -414,7 +427,7 @@ context('Side Window > Mission Form > Main Form', () => { isGeometryComputedFromControls: false } }, - 5 + 10 ) .its('response.statusCode') .should('eq', 201) @@ -642,7 +655,7 @@ context('Side Window > Mission Form > Main Form', () => { .its('mockEventSources' as any) .then(mockEventSources => { // URL sur la CI : http://0.0.0.0:8081/api/v1/missions/sse' - // URL en local : //localhost:8081/api/v1/missions/sse + // URL en local : /api/v1/missions/sse mockEventSources['http://0.0.0.0:8081/api/v1/missions/sse'].emitOpen() mockEventSources['http://0.0.0.0:8081/api/v1/missions/sse'].emit( 'MISSION_UPDATE', @@ -761,6 +774,7 @@ context('Side Window > Mission Form > Main Form', () => { .its('mockEventSources' as any) .then(mockEventSources => { // URL sur la CI : http://0.0.0.0:8081/api/v1/missions/sse' + // URL en local : //localhost:8081/api/v1/missions/sse mockEventSources['http://0.0.0.0:8081/api/v1/missions/sse'].emitOpen() mockEventSources['http://0.0.0.0:8081/api/v1/missions/sse'].emit( 'MISSION_UPDATE', @@ -823,11 +837,13 @@ context('Side Window > Mission Form > Main Form', () => { body: [], statusCode: 200 }) + cy.wait(500) cy.window() .its('mockEventSources' as any) .then(mockEventSources => { - // URL sur la CI : http://0.0.0.0:8081/api/v1/missions/sse' + // URL sur la CI : http://0.0.0.0:8081/api/v1/missions/sse + // URL en local : //localhost:8081/api/v1/missions/sse mockEventSources['http://0.0.0.0:8081/api/v1/missions/sse'].emitOpen() mockEventSources['http://0.0.0.0:8081/api/v1/missions/sse'].emit( 'MISSION_UPDATE', @@ -878,6 +894,7 @@ context('Side Window > Mission Form > Main Form', () => { }) ) }) + cy.wait(250) cy.fill('CNSP : orientations, observations', 'Une autre note (dummy updtae to send a request).') diff --git a/frontend/cypress/e2e/side_window/mission_form/sea_control.spec.ts b/frontend/cypress/e2e/side_window/mission_form/sea_control.spec.ts index 9d3f577f80..c6642d841d 100644 --- a/frontend/cypress/e2e/side_window/mission_form/sea_control.spec.ts +++ b/frontend/cypress/e2e/side_window/mission_form/sea_control.spec.ts @@ -524,10 +524,20 @@ context('Side Window > Mission Form > Sea Control', () => { cy.clickButton('Ajouter un contrôle en mer') const now = getUtcDateInMultipleFormats() - cy.intercept('POST', '/api/v1/mission', { + cy.intercept('POST', '/api/v1/missions', { + body: { + id: 1 + }, statusCode: 201 }).as('createMission') + cy.intercept('POST', '/api/v1/missions/1', { + body: { + id: 1 + }, + statusCode: 201 + }).as('updateMission') + cy.intercept('POST', '/bff/v1/mission_actions', { statusCode: 201 }).as('createMissionAction') @@ -588,7 +598,7 @@ context('Side Window > Mission Form > Sea Control', () => { // Request cy.waitForLastRequest( - '@createMission', + '@updateMission', { body: { controlUnits: [ @@ -683,7 +693,6 @@ context('Side Window > Mission Form > Sea Control', () => { isClosed: false, isGeometryComputedFromControls: true, isUnderJdp: true, - isValid: true, missionSource: 'MONITORFISH', missionTypes: ['SEA'] } @@ -736,18 +745,20 @@ context('Side Window > Mission Form > Sea Control', () => { cy.wait(500) cy.fill('Saisi par', 'Marlin') + cy.wait(500) // Remove the PAM control unit cy.get('span[role="button"][title="Clear"]').eq(0).click({ force: true }) + cy.wait(500) cy.get('span[role="button"][title="Clear"]').eq(1).click({ force: true }) - cy.fill('Unité 1', 'Cultures marines – DDTM 40') + cy.wait(500) + cy.fill('Unité 1', 'Cultures marines – DDTM 30') + cy.wait(500) cy.get('legend') .filter(':contains("Autre(s) contrôle(s) effectué(s) par l’unité sur le navire")') .should('have.length', 0) - cy.wait(500) - // ------------------------------------------------------------------------- // Request diff --git a/frontend/cypress/e2e/side_window/mission_form/utils.ts b/frontend/cypress/e2e/side_window/mission_form/utils.ts index f2869b5609..4469941bba 100644 --- a/frontend/cypress/e2e/side_window/mission_form/utils.ts +++ b/frontend/cypress/e2e/side_window/mission_form/utils.ts @@ -85,5 +85,6 @@ export const fillSideWindowMissionFormBase = ( cy.fill('Administration 1', 'DDTM') cy.fill('Unité 1', 'Cultures marines – DDTM 40') + cy.wait(500) cy.fill('Moyen 1', ['Semi-rigide 2']) } diff --git a/frontend/cypress/mappings/create-mission-with-unit-scenario.json b/frontend/cypress/mappings/create-mission-with-unit-scenario.json new file mode 100644 index 0000000000..52219f89d7 --- /dev/null +++ b/frontend/cypress/mappings/create-mission-with-unit-scenario.json @@ -0,0 +1,98 @@ +{ + "mappings": [ + { + "scenarioName": "Create mission 78", + "requiredScenarioState": "Started", + "priority": 1, + "request": { + "method": "POST", + "url": "/api/v1/missions", + "bodyPatterns" : [ { + "matches": ".*\"observationsCnsp\": \"Scénario qui permet de stubber l'API MonitorEnv\".*" + } ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*" + }, + "jsonBody": { + "id": 78, + "missionTypes": ["LAND"], + "controlUnits": [ + { + "id": 10003, + "administration": "DDTM", + "name": "DML 2A (historique)", + "resources": [], + "contact": null, + "isArchived": false + } + ], + "openBy": "", + "closedBy": "", + "observationsCacem": "", + "observationsCnsp": "Scénario qui permet de stubber l'API MonitorEnv", + "facade": null, + "geom": null, + "startDateTimeUtc": "2023-03-15T23:21:14.923703Z", + "endDateTimeUtc": "2023-03-12T02:52:15.193661Z", + "createdAtUtc": "2023-03-15T23:21:14.923703Z", + "updatedAtUtc": "2023-03-15T23:21:14.923703Z", + "envActions": [], + "missionSource": "MONITORFISH", + "isClosed": false, + "isUnderJdp": false, + "isGeometryComputedFromControls": false + } + } + }, + { + "scenarioName": "Get mission 78", + "requiredScenarioState": "Started", + "request": { + "method": "GET", + "url": "/api/v1/missions/78" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*" + }, + "jsonBody": { + "id": 78, + "missionTypes": ["LAND"], + "controlUnits": [ + { + "id": 10003, + "administration": "DDTM", + "name": "DML 2A (historique)", + "resources": [], + "contact": null, + "isArchived": false + } + ], + "openBy": "", + "closedBy": "", + "observationsCacem": "", + "observationsCnsp": "Scénario qui permet de stubber l'API MonitorEnv", + "facade": null, + "geom": null, + "startDateTimeUtc": "2023-03-15T23:21:14.923703Z", + "endDateTimeUtc": "2023-03-12T02:52:15.193661Z", + "createdAtUtc": "2023-03-15T23:21:14.923703Z", + "updatedAtUtc": "2023-03-15T23:21:14.923703Z", + "envActions": [], + "missionSource": "MONITORFISH", + "isClosed": false, + "isUnderJdp": false, + "isGeometryComputedFromControls": false + } + } + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 856f9c14ef..5be56ac5f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -119,7 +119,9 @@ "postcss": "8.4.31", "postcss-syntax": "0.36.2", "prettier": "3.0.0", + "puppeteer": "^21.7.0", "react-testing-library": "8.0.1", + "ts-jest": "^29.1.2", "type-fest": "4.0.0", "typescript": "5.2.2", "vite": "4.5.2", @@ -3782,6 +3784,27 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@puppeteer/browsers": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz", + "integrity": "sha512-PuvK6xZzGhKPvlx3fpfdM2kYY3P/hB1URtK8wA7XUJ6prn6pp22zvJHu48th0SGcHL9SutbPHrFuQgfXTFobWA==", + "dev": true, + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.1", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=16.3.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "1.9.6", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.6.tgz", @@ -4748,6 +4771,12 @@ "node": ">= 10" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -6179,6 +6208,18 @@ "node": ">=0.8" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -6260,6 +6301,12 @@ "dequal": "^2.0.3" } }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "dev": true + }, "node_modules/babel-eslint": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", @@ -6542,6 +6589,15 @@ "node": ">= 0.4.0" } }, + "node_modules/basic-ftp": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.4.tgz", + "integrity": "sha512-8PzkB0arJFV4jJWSGOYR+OEic6aeKMu/osRhBULN6RY0ykby6LKhbmuQ5ublvaas5BOwboah5D87nrHyuh8PPA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -6679,6 +6735,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -7003,6 +7071,19 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.2.tgz", + "integrity": "sha512-PbVOSddxgKyj+JByqavWMNqWPCoCaT6XK5Z1EFe168sxnB/BM51LnZEPXSbFcFAJv/+u2B4XNTs9uXxy4GW3cQ==", + "dev": true, + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "9.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -7362,6 +7443,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7704,6 +7794,15 @@ "node": ">=0.10" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.1.tgz", + "integrity": "sha512-MZd3VlchQkp8rdend6vrx7MmVDJzSNTBvghvKjirLkD+WTChA3KUf0jkE68Q4UyctNqI11zZO9/x2Yx+ub5Cvg==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -8071,6 +8170,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8096,6 +8209,12 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1203626", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1203626.tgz", + "integrity": "sha512-nEzHZteIUZfGCZtTiS1fRpC8UZmsfD1SiyPvaUNvS13dvKf666OAm8YTi0+Ca3n1nLEyu49Cy4+dPWpaHFJk9g==", + "dev": true + }, "node_modules/diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -9898,6 +10017,12 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -10373,6 +10498,53 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.2.tgz", + "integrity": "sha512-5KLucCJobh8vBY1K07EFV4+cPZH3mrV9YeAruUseCQKHB58SGjjT2l9/eA9LD082IiuMjSlFJEcdJ27TXvbZNw==", + "dev": true, + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.0", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-uri/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/get-uri/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/getos": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", @@ -11053,6 +11225,12 @@ "node": ">= 0.4" } }, + "node_modules/ip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz", + "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==", + "dev": true + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -13399,6 +13577,12 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -13579,6 +13763,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -14212,6 +14402,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true + }, "node_modules/mkdirp": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", @@ -14222,6 +14418,12 @@ "node": "*" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -14280,6 +14482,15 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "peer": true }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -14738,6 +14949,77 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz", + "integrity": "sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==", + "dev": true, + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "pac-resolver": "^7.0.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/http-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.0.tgz", + "integrity": "sha512-Fd9lT9vJbHYRACT8OhCbZBbxr6KRSawSovFpy8nDGshaK99S/EBhVIHp9+crhxrsZOuvLpgL1n23iyPg6Rl2hg==", + "dev": true, + "dependencies": { + "degenerator": "^5.0.0", + "ip": "^1.1.8", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -15192,6 +15474,15 @@ "node": ">= 0.6.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -15239,6 +15530,78 @@ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" }, + "node_modules/proxy-agent": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.1.tgz", + "integrity": "sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/http-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -15266,6 +15629,41 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "21.7.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-21.7.0.tgz", + "integrity": "sha512-Yy+UUy0b9siJezbhHO/heYUoZQUwyqDK1yOQgblTt0l97tspvDVFkcW9toBlnSvSfkDmMI3Dx9cZL6R8bDArHA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "cosmiconfig": "8.3.6", + "puppeteer-core": "21.7.0" + }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=16.13.2" + } + }, + "node_modules/puppeteer-core": { + "version": "21.7.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-21.7.0.tgz", + "integrity": "sha512-elPYPozrgiM3phSy7VDUJCVWQ07SPnOm78fpSaaSNFoQx5sur/MqhTSro9Wz8lOEjqCykGC6WRkwxDgmqcy1dQ==", + "dev": true, + "dependencies": { + "@puppeteer/browsers": "1.9.1", + "chromium-bidi": "0.5.2", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1203626", + "ws": "8.16.0" + }, + "engines": { + "node": ">=16.13.2" + } + }, "node_modules/pure-rand": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", @@ -15321,6 +15719,12 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "node_modules/quick-lru": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", @@ -16520,6 +16924,16 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -16634,6 +17048,52 @@ "ms": "^2.1.1" } }, + "node_modules/socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "dev": true, + "dependencies": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz", + "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks/node_modules/ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "dev": true + }, "node_modules/sort-asc": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", @@ -16769,6 +17229,16 @@ "stubs": "^3.0.0" } }, + "node_modules/streamx": { + "version": "2.15.6", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", + "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==", + "dev": true, + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -17135,6 +17605,28 @@ "node": ">=6" } }, + "node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "dev": true, + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/teeny-request": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.1.1.tgz", @@ -17500,6 +17992,82 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-jest": { + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/ts-toolbelt": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", @@ -17766,6 +18334,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -17993,6 +18571,12 @@ "fast-url-parser": "^1.1.3" } }, + "node_modules/urlpattern-polyfill": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz", + "integrity": "sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==", + "dev": true + }, "node_modules/use-debounce": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz", @@ -18697,9 +19281,9 @@ } }, "node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/frontend/package.json b/frontend/package.json index dd45be77a4..84368cfa29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "build:local": "ENV_PROFILE=local npm run build", "cypress:open": "cypress open --browser firefox --config-file ./config/cypress.config.ts --e2e", "dev": "dotenv -e ../infra/configurations/frontend/.env.local vite --port 3000", + "dev-puppeteer": "dotenv -e ../infra/configurations/frontend/.env.local -v FRONTEND_MONITORENV_URL=//localhost:8882 vite --port 3000", "bundle-sw": "esbuild src/workers/serviceWorker.ts --bundle --outfile=public/service-worker.js", "prepare": "cd .. && ./frontend/node_modules/.bin/husky install ./frontend/config/husky", "start": "vite preview --port 3000", @@ -22,8 +23,10 @@ "test:e2e:flaky": "./cypress/support/test_flaky_test.sh", "test:e2e:generate": "./scripts/clean_wiremock_stubs.sh && node ./scripts/generate_wiremock_stubs.mjs", "test:e2e:open": "cypress open --browser firefox --config-file ./config/cypress.config.ts --e2e", + "test:multi-windows:open": "jest --config=./config/multi-windows/jest.config.js --detectOpenHandles", + "test:multi-windows:run": "CI=true jest --config=./config/multi-windows/jest.config.js --detectOpenHandles", "test:lint": "eslint ./src", - "test:lint:partial": "eslint --config=./.eslintrc.partial.js ./src ./cypress", + "test:lint:partial": "eslint --config=./.eslintrc.partial.js ./src ./cypress ./puppeteer", "test:lint:partial:fix": "npm run test:lint:partial -- --fix", "test:type": "tsc", "test:unit": "jest --config=./config/jest.config.js --detectOpenHandles", @@ -140,7 +143,9 @@ "postcss": "8.4.31", "postcss-syntax": "0.36.2", "prettier": "3.0.0", + "puppeteer": "21.7.0", "react-testing-library": "8.0.1", + "ts-jest": "29.1.2", "type-fest": "4.0.0", "typescript": "5.2.2", "vite": "4.5.2", diff --git a/frontend/puppeteer/README.md b/frontend/puppeteer/README.md new file mode 100644 index 0000000000..4968c50a08 --- /dev/null +++ b/frontend/puppeteer/README.md @@ -0,0 +1,15 @@ +# Multi-windows tests + +These E2E tests are meant to **only** test multi-windows interactions (see ADR @adrs/0002-mission-form-sync-e2e-tests.md). + +### Run + +Multi-windows tests will run MonitorEnv (app and db) as docker containers. MonitorEnv APIs will allow us to create new Missions, listen to Mission events (with SSE), etc. + +> The `monitorenv-app` docker image version used in `docker-compose.yml` and `docker-compose.dev.yml` is the version with stabilized Mission APIs. + +To run `puppeteer` multi-windows tests: +1. In a first terminal, execute `make run-back-for-puppeteer`. +2. In a second terminal, execute `make run-front-for-puppeteer`. +3. In a third terminal, execute `cd frontend && npm run test:multi-windows:open` to execute tests. + diff --git a/frontend/puppeteer/docker-compose.dev.yml b/frontend/puppeteer/docker-compose.dev.yml new file mode 100644 index 0000000000..f279e8b30d --- /dev/null +++ b/frontend/puppeteer/docker-compose.dev.yml @@ -0,0 +1,44 @@ +services: + monitorenv-db: + image: docker.pkg.github.com/mtes-mct/monitorenv/monitorenv-database:pg11-ts1.7.4-postgis3.3.2 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=monitorenvdb + volumes: + - monitorenv-db-data:/var/lib/postgresql/data + ports: + - 5433:5432 + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 1s + timeout: 1s + retries: 30 + + monitorenv-app: + image: docker.pkg.github.com/mtes-mct/monitorenv/monitorenv-app:loup_add-mission-form-auto-save-and-update-refactoring + container_name: monitorenv_backend + environment: + - ENV_DB_URL=jdbc:postgresql://monitorenv-db:5432/monitorenvdb?user=postgres&password=postgres + - SPRING_PROFILES_ACTIVE=test + - HOST_IP=127.0.0.1 + - REACT_APP_GEOSERVER_REMOTE_URL=http://0.0.0.0:8081 + - REACT_APP_GEOSERVER_NAMESPACE=monitorenv + - REACT_APP_CYPRESS_TEST=true + - REACT_APP_MISSION_FORM_AUTO_UPDATE=true + ports: + - 8882:8880 + - 8002:8000 + - 5002:5000 + - 5003:5001 + depends_on: + - monitorenv-db + restart: always + logging: + driver: "json-file" + options: + max-size: "1024m" + +volumes: + monitorenv-db-data: + driver: local diff --git a/frontend/puppeteer/docker-compose.yml b/frontend/puppeteer/docker-compose.yml new file mode 100644 index 0000000000..dcdacbd762 --- /dev/null +++ b/frontend/puppeteer/docker-compose.yml @@ -0,0 +1,128 @@ +services: + monitorfish-db: + image: timescale/timescaledb-postgis:1.7.4-pg11 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=monitorfishdb + volumes: + - monitorfish-db-data:/var/lib/postgresql/data + ports: + - $DB_PUBLIC_PORT:5432 + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 1s + timeout: 5s + retries: 60 + + monitorenv-db: + image: docker.pkg.github.com/mtes-mct/monitorenv/monitorenv-database:pg11-ts1.7.4-postgis3.3.2 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=monitorenvdb + volumes: + - monitorenv-db-data:/var/lib/postgresql/data + ports: + - 5433:5432 + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 1s + timeout: 1s + retries: 30 + + flyway: + image: flyway/flyway + command: migrate -password=postgres -schemas=public -user=postgres -url=jdbc:postgresql://monitorfish-db:5432/monitorfishdb + volumes: + - ../../backend/src/main/resources/db/:/flyway/sql + depends_on: + monitorfish-db: + condition: service_healthy + + monitorfish-app: + image: monitorfish-app:$MONITORFISH_VERSION + container_name: monitorfish_backend + environment: + - ENV_DB_URL=jdbc:postgresql://monitorfish-db:5432/monitorfishdb?user=postgres&password=postgres + - SPRING_PROFILES_ACTIVE=local + - HOST_IP=127.0.0.1 + - SENTRY_DSN= + - MONITORENV_URL=http://monitorenv-app:8880 + - MONITORFISH_API_PROTECTED_API_KEY=APIKEY + - FRONTEND_GEOSERVER_LOCAL_URL=http://0.0.0.0:8081 + - FRONTEND_GEOSERVER_REMOTE_URL=http://0.0.0.0:8081 + - FRONTEND_MAPBOX_KEY=pk.eyJ1IjoibW9uaXRvcmZpc2giLCJhIjoiY2tsdHJ6dHhhMGZ0eDJ2bjhtZmJlOHJmZiJ9.bdi1cO-cUcZKXdkEkqAoZQ + - FRONTEND_MONITORENV_URL=http://0.0.0.0:8882 + - FRONTEND_OIDC_AUTHORITY=https://authentification.recette.din.developpement-durable.gouv.fr/authSAML/oidc/monitorfish + - FRONTEND_OIDC_CLIENT_ID=monitorfish + - FRONTEND_OIDC_ENABLED=false + - FRONTEND_OIDC_REDIRECT_URI=https://monitorfish.din.developpement-durable.gouv.fr + - FRONTEND_SENTRY_DSN=https://a5f3272efa794bb9ada2ffea90f2fec5@sentry.incubateur.net/8 + - FRONTEND_SHOM_KEY=rg8ele7cft4ujkwjspsmtwas + - VITE_SMALL_CHAT_SNIPPET= + - FRONTEND_MISSION_FORM_AUTO_SAVE_ENABLED=true + - FRONTEND_MISSION_FORM_AUTO_UPDATE_ENABLED=true + ports: + - 8880:8880 + - 8000:8000 + - 5000:5000 + - 5001:5001 + depends_on: + flyway: + condition: service_completed_successfully + geoserver-stubs: + condition: service_healthy + restart: always + logging: + driver: 'json-file' + options: + max-size: '1024m' + + monitorenv-app: + image: docker.pkg.github.com/mtes-mct/monitorenv/monitorenv-app:loup_add-mission-form-auto-save-and-update-refactoring + container_name: monitorenv_backend + environment: + - ENV_DB_URL=jdbc:postgresql://monitorenv-db:5432/monitorenvdb?user=postgres&password=postgres + - SPRING_PROFILES_ACTIVE=test + - HOST_IP=127.0.0.1 + - REACT_APP_GEOSERVER_REMOTE_URL=http://0.0.0.0:8081 + - REACT_APP_GEOSERVER_NAMESPACE=monitorenv + - REACT_APP_CYPRESS_TEST=true + - REACT_APP_MISSION_FORM_AUTO_UPDATE=true + ports: + - 8882:8880 + - 8002:8000 + - 5002:5000 + - 5003:5001 + depends_on: + - monitorenv-db + restart: always + logging: + driver: "json-file" + options: + max-size: "1024m" + + geoserver-stubs: + image: rodolpheche/wiremock + ports: + - 8081:8080 + volumes: + - ./mappings:/home/wiremock/mappings + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl --fail + http://localhost:8080/geoserver/wfs?service=WFS&version=1.1.0&request=GetFeature&typename=monitorfish:regulations&outputFormat=application/json&CQL_FILTER=topic=%27Ouest%20Cotentin%20Bivalves%27%20AND%20zone=%27Praires%20Ouest%20cotentin%27 + || exit 1 ' + ] + interval: 1s + timeout: 1s + retries: 30 + +volumes: + monitorfish-db-data: + driver: local + monitorenv-db-data: + driver: local diff --git a/frontend/puppeteer/e2e/missions.spec.ts b/frontend/puppeteer/e2e/missions.spec.ts new file mode 100644 index 0000000000..9762ce658a --- /dev/null +++ b/frontend/puppeteer/e2e/missions.spec.ts @@ -0,0 +1,169 @@ +import { beforeEach, expect, it } from '@jest/globals' +import { platform } from 'os' + +import { assertContains, getFirstTab, getInputContent, listenToConsole, wait } from './utils' +import { SeaFrontGroup } from '../../src/domain/entities/seaFront/constants' + +const TIMEOUT = 120 * 1000 + +const IS_CI = Boolean(process.env.CI) +const IS_DARWIN = platform() === 'darwin' +const WEBAPP_PORT = IS_CI ? 8880 : 3000 +const WEBAPP_HOST = IS_DARWIN ? '0.0.0.0' : 'localhost' + +const URL = `http://${WEBAPP_HOST}:${WEBAPP_PORT}/side_window` + +let pageA +let pageB + +describe('Missions Form', () => { + beforeEach(async () => { + // @ts-ignore + pageA = await getFirstTab(browsers[0]) + listenToConsole(pageA, 1) + + // @ts-ignore + pageB = await getFirstTab(browsers[1]) + listenToConsole(pageB, 2) + + /* eslint-disable no-restricted-syntax */ + for (const page of [pageA, pageB]) { + await page.goto(URL, { waitUntil: 'domcontentloaded' }) + await wait(2000) + + await page.waitForSelector('[title="Missions et contrôles"]') + await page.click('[title="Missions et contrôles"]') + + await page.waitForSelector(`[data-cy="side-window-sub-menu-${SeaFrontGroup.NAMO}"]`) + await page.click(`[data-cy="side-window-sub-menu-${SeaFrontGroup.NAMO}"]`) + await wait(2000) + + await page.waitForSelector('.TableBodyRow[data-id="29"] > div > [title="Éditer la mission"]') + await page.click('.TableBodyRow[data-id="29"] > div > [title="Éditer la mission"]') + + await wait(1000) + } + }, 50000) + + it( + 'Two windows must be synchronized on form update', + async () => { + /** + * User A modify "Control unit contact" + */ + await pageB.focus('[name="contact_0"]') + await pageA.focus('[name="contact_0"]') + await wait(2000) + const controlUnitContact = await pageA.waitForSelector('[name="contact_0"]') + // Modify contact on first page + await controlUnitContact.click({ clickCount: 3, delay: 50 }) + await controlUnitContact.type('A new tel. number', { delay: 50 }) + // Wait for the update to be sent + await wait(1000) + // Should send the update to the second page + expect(await getInputContent(pageB, '[name="contact_0"]')).toBe('A new tel. number') + // Erase the value + await controlUnitContact.click({ clickCount: 3, delay: 50 }) + await controlUnitContact.type('contact', { delay: 50 }) + await wait(1000) + + /** + * User B modify "Observations CNSP" + */ + await pageB.focus('[name="observationsCnsp"]') + await pageA.focus('[name="observationsCnsp"]') + const observationsCnsp = await pageB.waitForSelector('[name="observationsCnsp"]') + // Modify contact on first page + await observationsCnsp.click({ clickCount: 3, delay: 50 }) + await observationsCnsp.type("A new observation, as I'm not sure of the purpose of this mission.", { delay: 25 }) + // Wait for the update to be sent + await wait(1000) + // Should send the update to the second page + expect(await getInputContent(pageA, '[name="observationsCnsp"]')).toBe( + "A new observation, as I'm not sure of the purpose of this mission." + ) + // Erase the value + await observationsCnsp.click({ clickCount: 3, delay: 50 }) + await observationsCnsp.type('Aucune', { delay: 50 }) + await wait(1000) + + /** + * User A modify "Observations CACEM" + */ + await pageA.focus('[name="observationsCacem"]') + await pageB.focus('[name="observationsCacem"]') + const observationsCacem = await pageA.waitForSelector('[name="observationsCacem"]') + // Modify contact on first page + await observationsCacem.click({ clickCount: 3 }) + await observationsCacem.type('A new observation for this mission.', { delay: 25 }) + // Wait for the update to be sent + await wait(1000) + // Should send the update to the second page + expect(await getInputContent(pageB, '[name="observationsCacem"]')).toBe('A new observation for this mission.') + // Erase the value + await observationsCacem.click({ clickCount: 3 }) + await observationsCacem.type('Aucune', { delay: 50 }) + await wait(1000) + + /** + * User B modify "Open By" + */ + await pageA.focus('[name="openBy"]') + await pageB.focus('[name="openBy"]') + const openBy = await pageB.waitForSelector('[name="openBy"]') + // Modify contact on first page + await openBy.click({ clickCount: 3 }) + await openBy.type('LTH', { delay: 50 }) + // Wait for the update to be sent + await wait(1000) + // Should send the update to the second page + expect(await getInputContent(pageA, '[name="openBy"]')).toBe('LTH') + // Erase the value + await openBy.click({ clickCount: 3 }) + await openBy.type('FDJ', { delay: 50 }) + await wait(2000) + + /** + * User B close mission + */ + const close = await pageB.waitForSelector('[data-cy="close-mission"]') + await close.click() + await wait(2000) + await pageA.waitForSelector('.Element-Tag') + await assertContains(pageA, '.Element-Tag', 'Clôturée') + await wait(2000) + + /** + * User A reopen mission + */ + const reopen = await pageA.waitForSelector('[data-cy="reopen-mission"]') + await reopen.click() + await wait(2000) + await pageB.waitForSelector('.TableBodyRow[data-id="29"] > div > [title="Éditer la mission"]') + await pageB.click('.TableBodyRow[data-id="29"] > div > [title="Éditer la mission"]') + await wait(250) + await pageB.waitForSelector('.Element-Tag') + await assertContains(pageB, '.Element-Tag', 'En cours') + await wait(2000) + + /** + * User B re-close mission + */ + await wait(1000) + const secondClose = await pageB.waitForSelector('[data-cy="close-mission"]') + await secondClose.click() + await wait(2000) + await pageA.waitForSelector('.Element-Tag') + await assertContains(pageA, '.Element-Tag', 'Clôturée') + await wait(2000) + + /** + * User A reopen mission + */ + const finalReopen = await pageA.waitForSelector('[data-cy="reopen-mission"]') + await finalReopen.click() + await wait(5000) + }, + TIMEOUT + ) +}) diff --git a/frontend/puppeteer/e2e/utils.ts b/frontend/puppeteer/e2e/utils.ts new file mode 100644 index 0000000000..248fc793b2 --- /dev/null +++ b/frontend/puppeteer/e2e/utils.ts @@ -0,0 +1,61 @@ +import assert from 'assert' +import { Page, Browser } from 'puppeteer' + +export function listenToConsole(page: Page, index: number) { + page + .on('console', message => { + const messageType = message.type().substr(0, 3).toUpperCase() + console.log(`[Page ${index}] ${messageType}: ${message.text()}`) + + if (messageType === 'ERR') { + console.log(message.args(), message.stackTrace()) + if (message.text().includes('/sse')) { + // If the SSE connection fails, the browser will restart it, it is not an application error + return + } + + throw new Error(message.text()) + } + }) + .on('response', response => { + if (response.url().includes('/bff/') || response.url().includes('/api/')) { + console.log(`[Page ${index}] HTTP ${response.request().method()} ${response.status()}: ${response.url()}`) + } + }) +} + +export async function assertContains(page: Page, selector: string, text: string) { + // TODO Remove ts-ignore when TS version is 4.9.3: + // @ts-ignore: https://github.com/puppeteer/puppeteer/issues/9369 + const nodes = await page.$$eval(selector, elements => elements.map(element => element.textContent)) + const node = nodes.find(content => content?.includes(text)) + + assert.ok(node, `${selector} of value ${text} not found in array ${nodes}.`) +} + +export async function getTextContent(page: Page, selector: string) { + const element = await page.waitForSelector(selector) + + return element && element.evaluate(el => el.textContent) +} + +export async function getInputContent(page: Page, selector: string) { + const element = await page.waitForSelector(selector) + + // From Puppeteer doc: + // If you are using TypeScript, you may have to provide an explicit type to the first argument of the pageFunction. + // By default it is typed as Element[], but you may need to provide a more specific sub-type + // @ts-ignore + return element && element.evaluate((el: HTMLInputElement) => el.value) +} + +export async function getFirstTab(browser: Browser) { + const [firstTab] = await browser.pages() + + return firstTab +} + +export function wait(ms: number) { + /* eslint-disable no-promise-executor-return */ + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/frontend/puppeteer/puppeteer_environment.ts b/frontend/puppeteer/puppeteer_environment.ts new file mode 100644 index 0000000000..4e2001d12c --- /dev/null +++ b/frontend/puppeteer/puppeteer_environment.ts @@ -0,0 +1,37 @@ +import { promises as fs } from 'fs' +import NodeEnvironment from 'jest-environment-node' +import os from 'os' +import path from 'path' +import puppeteer from 'puppeteer' + +import { wait } from './e2e/utils' + +const TEMP_DIRECTORY = path.join(os.tmpdir(), 'jest_puppeteer_global_setup') + +export default class PuppeteerEnvironment extends NodeEnvironment { + override async setup() { + await super.setup() + await wait(5000) + + const wsEndpoints = await fs.readFile(path.join(TEMP_DIRECTORY, 'wsEndpoints'), { encoding: 'utf-8' }) + + if (!wsEndpoints) { + console.error('ERROR: Endpoints file not found.') + process.exit() + } + + // @ts-ignore + this.global.browsers = [] + + for (const wsEndpoint of wsEndpoints.split('\\n')) { + // Connect puppeteer to the browsers we created during the global setup + // @ts-ignore + this.global.browsers.push( + await puppeteer.connect({ + browserWSEndpoint: wsEndpoint, + defaultViewport: null + }) + ) + } + } +} diff --git a/frontend/puppeteer/setup.ts b/frontend/puppeteer/setup.ts new file mode 100644 index 0000000000..44a327cb47 --- /dev/null +++ b/frontend/puppeteer/setup.ts @@ -0,0 +1,51 @@ +import { promises as fs } from 'fs' +import os from 'os' +import path from 'path' +import puppeteer from 'puppeteer' + +const TEMP_DIRECTORY = path.join(os.tmpdir(), 'jest_puppeteer_global_setup') +const NUMBER_OF_BROWSERS = 2 +const WIDTH = 1020 +const HEIGHT = 880 + +console.log(`Running in ${process.env.CI ? 'headless' : 'browser'} mode.`) + +// +export default async () => { + const browsers = [] + + // Launch browsers side to side + /* eslint-disable no-plusplus */ + for (let i = 0; i < NUMBER_OF_BROWSERS; i++) { + const browser = await puppeteer.launch({ + // Chrome additional arguments to set browser size and position + args: [ + `--window-size=${WIDTH},${HEIGHT}`, + `--window-position=${WIDTH * i},0`, + '--enable-features=ExperimentalJavaScript' + ], + defaultViewport: null, + headless: process.env.CI ? 'new' : false, + product: 'firefox' + }) + + const version = await browser.version() + console.log('\nBrowser version: ', version) + + // @ts-ignore + browsers.push(browser) + } + + // use the file system to expose the browsers wsEndpoint for TestEnvironments + await fs.mkdir(TEMP_DIRECTORY, { recursive: true }) + + await fs.writeFile( + path.join(TEMP_DIRECTORY, 'wsEndpoints'), + // @ts-ignore + browsers.map(browser => browser.wsEndpoint()).join('\\n') + ) + + // store all browser instances so we can teardown them later + // this global is only available in the teardown but not in TestEnvironments + global.__BROWSERS__ = browsers +} diff --git a/frontend/puppeteer/teardown.ts b/frontend/puppeteer/teardown.ts new file mode 100644 index 0000000000..0f298f214c --- /dev/null +++ b/frontend/puppeteer/teardown.ts @@ -0,0 +1,17 @@ +import os from 'os' +import path from 'path' +import rimraf from 'rimraf' + +const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup') + +export default async () => { + // Close all browsers + for (const browser of global.__BROWSERS__) { + await browser.close() + } + + // clean-up the temporary file used to write the browsers wsEndpoints + if (!process.env.CI) { + rimraf.sync(DIR) + } +} diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 92abca5e7c..bff8dca8f0 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -14,9 +14,6 @@ const MAX_RETRIES = 2 // Using local MonitorEnv stubs: export const MONITORENV_API_URL = import.meta.env.FRONTEND_MONITORENV_URL -// Using local MonitorEnv instance: -// const MONITORENV_API_URL = 'http://0.0.0.0:9880' - // ============================================================================= // Monitorenv API diff --git a/frontend/src/api/missionAction.ts b/frontend/src/api/missionAction.ts index ff558e6262..d23a4fba6c 100644 --- a/frontend/src/api/missionAction.ts +++ b/frontend/src/api/missionAction.ts @@ -8,6 +8,8 @@ const GET_MISSION_ACTIONS_ERROR_MESSAGE = "Nous n'avons pas pu récupérer les a export const missionActionApi = monitorfishApi.injectEndpoints({ endpoints: builder => ({ createMissionAction: builder.mutation({ + // TODO To remove when FRONTEND_MISSION_FORM_AUTO_SAVE_ENABLED feature flag is ON + // As all mission will be fetched when closing the mission form invalidatesTags: () => [{ type: 'Missions' }, { type: 'MissionActions' }], query: missionAction => ({ body: missionAction, @@ -17,6 +19,8 @@ export const missionActionApi = monitorfishApi.injectEndpoints({ }), deleteMissionAction: builder.mutation({ + // TODO To remove when FRONTEND_MISSION_FORM_AUTO_SAVE_ENABLED feature flag is ON + // As all mission will be fetched when closing the mission form invalidatesTags: () => [{ type: 'Missions' }, { type: 'MissionActions' }], query: missionActionId => ({ method: 'DELETE', @@ -25,12 +29,16 @@ export const missionActionApi = monitorfishApi.injectEndpoints({ }), getMissionActions: builder.query({ + // TODO To remove when FRONTEND_MISSION_FORM_AUTO_SAVE_ENABLED feature flag is ON + // As all mission will be fetched when closing the mission form providesTags: () => [{ type: 'MissionActions' }], query: missionId => `/mission_actions?missionId=${missionId}`, transformErrorResponse: response => new ApiError(GET_MISSION_ACTIONS_ERROR_MESSAGE, response) }), updateMissionAction: builder.mutation({ + // TODO To remove when FRONTEND_MISSION_FORM_AUTO_SAVE_ENABLED feature flag is ON + // As all mission will be fetched when closing the mission form invalidatesTags: () => [{ type: 'Missions' }, { type: 'MissionActions' }], query: missionAction => ({ body: missionAction, diff --git a/frontend/src/context/MissionEventContext.ts b/frontend/src/context/MissionEventContext.ts new file mode 100644 index 0000000000..9077dbb128 --- /dev/null +++ b/frontend/src/context/MissionEventContext.ts @@ -0,0 +1,5 @@ +import React from 'react' + +import type { Mission } from '../domain/entities/mission/types' + +export const MissionEventContext = React.createContext(undefined) diff --git a/frontend/src/domain/actions.ts b/frontend/src/domain/actions.ts index c53336ea08..f0a126c1c7 100644 --- a/frontend/src/domain/actions.ts +++ b/frontend/src/domain/actions.ts @@ -1,5 +1,5 @@ -import { missionSliceActions } from './shared_slices/Mission' import { missionDispatchers } from './use_cases/mission' +import { missionSliceActions } from '../features/SideWindow/MissionForm/slice' export const missionActions = { ...missionDispatchers, diff --git a/frontend/src/domain/entities/controlUnits/utils.ts b/frontend/src/domain/entities/controlUnits/utils.ts index 1b942c559f..9d71e3fb0e 100644 --- a/frontend/src/domain/entities/controlUnits/utils.ts +++ b/frontend/src/domain/entities/controlUnits/utils.ts @@ -9,7 +9,6 @@ export function getControlUnitsOptionsFromControlUnits( controlUnits: LegacyControlUnit.LegacyControlUnit[] | undefined = [], selectedAdministrations?: string[] ): { - activeControlUnits: LegacyControlUnit.LegacyControlUnit[] administrationsAsOptions: Option[] unitsAsOptions: Option[] } { @@ -28,7 +27,6 @@ export function getControlUnitsOptionsFromControlUnits( const unitsAsOptions = getOptionsFromStrings(uniqueSortedUnits) return { - activeControlUnits, administrationsAsOptions, unitsAsOptions } diff --git a/frontend/src/domain/entities/mission/index.ts b/frontend/src/domain/entities/mission/index.ts index 10127ea527..65462d8005 100644 --- a/frontend/src/domain/entities/mission/index.ts +++ b/frontend/src/domain/entities/mission/index.ts @@ -16,7 +16,7 @@ import { MonitorFishLayer } from '../layers/types' import { OpenLayersGeometryType } from '../map/constants' import type { MissionWithActions } from './types' -import type { MissionMainFormValues } from '../../../features/SideWindow/MissionForm/types' +import type { MissionMainFormValues, MissionActionFormValues } from '../../../features/SideWindow/MissionForm/types' import type { MultiPolygon } from 'ol/geom' import MissionStatus = Mission.MissionStatus @@ -102,7 +102,9 @@ export const getMissionFeatureZone = (mission: Mission.Mission | MissionMainForm return feature } -export const getMissionActionFeature = (action: MissionAction.MissionAction): Feature | undefined => { +export const getMissionActionFeature = ( + action: MissionAction.MissionAction | MissionActionFormValues +): Feature | undefined => { if (!action.longitude || !action.latitude) { return undefined } diff --git a/frontend/src/domain/entities/mission/types.ts b/frontend/src/domain/entities/mission/types.ts index 88f81ce0ec..0945555d9b 100644 --- a/frontend/src/domain/entities/mission/types.ts +++ b/frontend/src/domain/entities/mission/types.ts @@ -18,7 +18,8 @@ export namespace Mission { isClosed: boolean isGeometryComputedFromControls: boolean isUnderJdp?: boolean | undefined - isValid: boolean + // For internal Formik validation purpose + isValid?: boolean | undefined missionSource: MissionSource missionTypes: MissionType[] observationsCacem?: string diff --git a/frontend/src/features/SideWindow/MissionForm/ActionList/Item.tsx b/frontend/src/features/SideWindow/MissionForm/ActionList/Item.tsx index e4bd5502a3..85cdfdc562 100644 --- a/frontend/src/features/SideWindow/MissionForm/ActionList/Item.tsx +++ b/frontend/src/features/SideWindow/MissionForm/ActionList/Item.tsx @@ -24,26 +24,26 @@ import type { MissionActionFormValues } from '../types' import type { Promisable } from 'type-fest' export type ItemProps = { - initialValues: MissionActionFormValues isSelected: boolean + missionAction: MissionActionFormValues onDuplicate: () => Promisable onRemove: () => Promisable onSelect: () => Promisable } -export function Item({ initialValues, isSelected, onDuplicate, onRemove, onSelect }: ItemProps) { +export function Item({ isSelected, missionAction, onDuplicate, onRemove, onSelect }: ItemProps) { const draft = useMainAppSelector(state => state.mission.draft) const natinfsAsOptions = useGetNatinfsAsOptions() const isControlAction = - initialValues.actionType === MissionAction.MissionActionType.AIR_CONTROL || - initialValues.actionType === MissionAction.MissionActionType.LAND_CONTROL || - initialValues.actionType === MissionAction.MissionActionType.SEA_CONTROL + missionAction.actionType === MissionAction.MissionActionType.AIR_CONTROL || + missionAction.actionType === MissionAction.MissionActionType.LAND_CONTROL || + missionAction.actionType === MissionAction.MissionActionType.SEA_CONTROL const [actionLabel, ActionIcon] = useMemo(() => { - const vesselName = initialValues.vesselName === UNKNOWN_VESSEL.vesselName ? 'INCONNU' : initialValues.vesselName + const vesselName = missionAction.vesselName === UNKNOWN_VESSEL.vesselName ? 'INCONNU' : missionAction.vesselName - switch (initialValues.actionType) { + switch (missionAction.actionType) { case MissionAction.MissionActionType.AIR_CONTROL: return [getActionTitle('Contrôle aérien', vesselName, '- Navire inconnu'), Icon.Plane] @@ -51,8 +51,8 @@ export function Item({ initialValues, isSelected, onDuplicate, onRemove, onSelec return [ getActionTitle( 'Surveillance aérienne', - initialValues.numberOfVesselsFlownOver - ? `${initialValues.numberOfVesselsFlownOver} pistes survolées` + missionAction.numberOfVesselsFlownOver + ? `${missionAction.numberOfVesselsFlownOver} pistes survolées` : undefined, 'à renseigner' ), @@ -63,7 +63,7 @@ export function Item({ initialValues, isSelected, onDuplicate, onRemove, onSelec return [getActionTitle('Contrôle à la débarque', vesselName, '- Navire inconnu'), Icon.Anchor] case MissionAction.MissionActionType.OBSERVATION: - return [getActionTitle('', initialValues.otherComments, 'Note libre à renseigner'), Icon.Note] + return [getActionTitle('', missionAction.otherComments, 'Note libre à renseigner'), Icon.Note] case MissionAction.MissionActionType.SEA_CONTROL: return [getActionTitle('Contrôle en mer', vesselName, '- Navire inconnu'), Icon.FleetSegment] @@ -71,14 +71,14 @@ export function Item({ initialValues, isSelected, onDuplicate, onRemove, onSelec default: throw new FrontendError('`initialValues.actionType` does not match the enum') } - }, [initialValues]) + }, [missionAction]) const infractionTags = useMemo(() => { - const allInfractions = getMissionActionInfractionsFromMissionActionFormValues(initialValues, true) + const allInfractions = getMissionActionInfractionsFromMissionActionFormValues(missionAction, true) if (!allInfractions.length) { return [] } - const nonPendingInfractions = getMissionActionInfractionsFromMissionActionFormValues(initialValues) + const nonPendingInfractions = getMissionActionInfractionsFromMissionActionFormValues(missionAction) const pendingInfractions = allInfractions.filter( ({ infractionType }) => infractionType === MissionAction.InfractionType.PENDING ) @@ -109,28 +109,28 @@ export function Item({ initialValues, isSelected, onDuplicate, onRemove, onSelec ) return [...infractionsRecapTags, infractionsTag] - }, [initialValues, natinfsAsOptions]) + }, [missionAction, natinfsAsOptions]) const redTags = useMemo( () => [ - ...(initialValues.hasSomeGearsSeized ? ['Appréhension engin'] : []), - ...(initialValues.hasSomeSpeciesSeized ? ['Appréhension espèce'] : []), - ...(initialValues.seizureAndDiversion ? ['Appréhension navire'] : []) + ...(missionAction.hasSomeGearsSeized ? ['Appréhension engin'] : []), + ...(missionAction.hasSomeSpeciesSeized ? ['Appréhension espèce'] : []), + ...(missionAction.seizureAndDiversion ? ['Appréhension navire'] : []) ].map(label => ( {label} )), - [initialValues] + [missionAction] ) const startDateAsDayjs = useMemo( - () => initialValues.actionDatetimeUtc && getLocalizedDayjs(initialValues.actionDatetimeUtc), - [initialValues] + () => missionAction.actionDatetimeUtc && getLocalizedDayjs(missionAction.actionDatetimeUtc), + [missionAction] ) - const isOpen = isControlAction && draft?.mainFormValues && !draft?.mainFormValues.isClosed && !initialValues.closedBy + const isOpen = isControlAction && draft?.mainFormValues && !draft?.mainFormValues.isClosed && !missionAction.closedBy return ( <> @@ -146,7 +146,7 @@ export function Item({ initialValues, isSelected, onDuplicate, onRemove, onSelec - {!initialValues.isValid && ( + {!missionAction.isValid && ( Veuillez compléter les champs manquants dans cette action de contrôle. )} diff --git a/frontend/src/features/SideWindow/MissionForm/ActionList/index.tsx b/frontend/src/features/SideWindow/MissionForm/ActionList/index.tsx index 5381d64c2a..d3eb56957d 100644 --- a/frontend/src/features/SideWindow/MissionForm/ActionList/index.tsx +++ b/frontend/src/features/SideWindow/MissionForm/ActionList/index.tsx @@ -69,12 +69,12 @@ export function ActionList({ {!actionsFormValues.length && Aucune action n’est ajoutée pour le moment.} {actionsFormValues.length > 0 && - actionsFormValues.map((actionInitialValues, index) => ( + actionsFormValues.map((action, index) => ( onDuplicate(index)} onRemove={() => onRemove(index)} onSelect={() => onSelect(index)} diff --git a/frontend/src/features/SideWindow/MissionForm/MainForm/FormikMultiControlUnitPicker/ControlUnitSelect.tsx b/frontend/src/features/SideWindow/MissionForm/MainForm/FormikMultiControlUnitPicker/ControlUnitSelect.tsx index 6286d10135..efaa88b142 100644 --- a/frontend/src/features/SideWindow/MissionForm/MainForm/FormikMultiControlUnitPicker/ControlUnitSelect.tsx +++ b/frontend/src/features/SideWindow/MissionForm/MainForm/FormikMultiControlUnitPicker/ControlUnitSelect.tsx @@ -9,20 +9,22 @@ import { TextInput, useNewWindow } from '@mtes-mct/monitor-ui' -import { useCallback, useMemo, useState } from 'react' +import { useField } from 'formik' +import { uniqBy } from 'lodash' +import { useCallback, useMemo } from 'react' import styled from 'styled-components' import { findControlUnitByname, mapControlUnitsToUniqueSortedNamesAsOptions, - mapControlUnitToSortedResourcesAsOptions + mapToSortedResourcesAsOptions } from './utils' import { FIVE_MINUTES } from '../../../../../api/APIWorker' import { Mission } from '../../../../../domain/entities/mission/types' import { useMainAppSelector } from '../../../../../hooks/useMainAppSelector' +import { isNotArchived } from '../../../../../utils/isNotArchived' import { useGetEngagedControlUnitsQuery } from '../../apis' import { INITIAL_MISSION_CONTROL_UNIT } from '../../constants' -import { isValidControlUnit } from '../../utils' import type { LegacyControlUnit } from '../../../../../domain/types/legacyControlUnit' import type { MissionMainFormValues } from '../../types' @@ -45,7 +47,6 @@ export type ControlUnitSelectProps = { nextControlUnit: LegacyControlUnit.LegacyControlUnit | LegacyControlUnit.LegacyControlUnitDraft ) => Promisable onDelete: (index: number) => Promisable - value: LegacyControlUnit.LegacyControlUnit | LegacyControlUnit.LegacyControlUnitDraft } export function ControlUnitSelect({ allAdministrationsAsOptions, @@ -54,12 +55,14 @@ export function ControlUnitSelect({ error, index, onChange, - onDelete, - value + onDelete }: ControlUnitSelectProps) { const { newWindowContainerRef } = useNewWindow() const selectedPath = useMainAppSelector(state => state.sideWindow.selectedPath) const { data: engagedControlUnitsData } = useGetEngagedControlUnitsQuery(undefined, { pollingInterval: FIVE_MINUTES }) + const [{ value }, ,] = useField( + `controlUnits.${index}` + ) const engagedControlUnits = useMemo(() => { if (!engagedControlUnitsData) { @@ -69,9 +72,10 @@ export function ControlUnitSelect({ return engagedControlUnitsData }, [engagedControlUnitsData]) - const [controlledValue, setControlledValue] = useState(value) - const [selectedControlUnit, setSelectedControlUnit] = useState( - isValidControlUnit(value) ? value : undefined + // Include archived control units (and administrations) if they're already selected + const activeAndSelectedControlUnits = useMemo( + () => allControlUnits.filter(controlUnit => isNotArchived(controlUnit) || value.name === controlUnit.name) || [], + [allControlUnits, value] ) const engagedControlUnit = engagedControlUnits.find(engaged => engaged.controlUnit.id === value.id) @@ -79,21 +83,36 @@ export function ControlUnitSelect({ const isEdition = selectedPath.id const filteredNamesAsOptions = useMemo((): Option[] => { - if (!allControlUnits || !controlledValue.administration) { + if (!allControlUnits || !value.administration) { return allNamesAsOptions } const selectedAdministrationControlUnits = allControlUnits.filter( - ({ administration }) => administration === controlledValue.administration + ({ administration }) => administration === value.administration ) return mapControlUnitsToUniqueSortedNamesAsOptions(selectedAdministrationControlUnits) - }, [allControlUnits, allNamesAsOptions, controlledValue]) + }, [allControlUnits, allNamesAsOptions, value]) + + // Include archived resources if they're already selected + const activeWithSelectedControlUnitResources = useMemo(() => { + const activeControlUnitResources = + activeAndSelectedControlUnits.find(unit => unit.administration === value.administration && unit.id === value.id) + ?.resources || [] + // TODO Remove LegacyControlUnitResource to filter archived resources : + // .filter(isNotArchived) + + const resources = [...activeControlUnitResources, ...value.resources] + + return uniqBy(resources, 'id') + }, [activeAndSelectedControlUnits, value]) - const selectedControlUnitResourcesAsOptions = useMemo( + const controlUnitResourcesAsOptions = useMemo( (): Option[] => - selectedControlUnit ? mapControlUnitToSortedResourcesAsOptions(selectedControlUnit) : [], - [selectedControlUnit] + activeWithSelectedControlUnitResources + ? mapToSortedResourcesAsOptions(activeWithSelectedControlUnitResources) + : [], + [activeWithSelectedControlUnitResources] ) const handleAdministrationChange = useCallback( @@ -103,9 +122,6 @@ export function ControlUnitSelect({ administration: nextAdministration } - setControlledValue(nextControlUnit) - setSelectedControlUnit(undefined) - onChange(index, nextControlUnit) }, [index, onChange] @@ -122,48 +138,41 @@ export function ControlUnitSelect({ nextSelectedControlUnit ? { ...nextSelectedControlUnit, - contact: controlledValue.contact, - resources: controlledValue.resources + contact: value.contact, + resources: value.resources } : { ...INITIAL_MISSION_CONTROL_UNIT, - administration: controlledValue.administration + administration: value.administration } - setControlledValue(nextControlUnit) - setSelectedControlUnit(nextSelectedControlUnit) - onChange(index, nextControlUnit) }, - [allControlUnits, controlledValue, index, isLoading, onChange] + [allControlUnits, value, index, isLoading, onChange] ) const handleResourcesChange = useCallback( (nextResources: LegacyControlUnit.LegacyControlUnitResource[] | undefined) => { const nextControlUnit: LegacyControlUnit.LegacyControlUnitDraft = { - ...controlledValue, + ...value, resources: nextResources || [] } - setControlledValue(nextControlUnit) - onChange(index, nextControlUnit) }, - [controlledValue, index, onChange] + [value, index, onChange] ) const handleContactChange = useCallback( (nextValue: string | undefined) => { const nextControlUnit: LegacyControlUnit.LegacyControlUnitDraft = { - ...controlledValue, + ...value, contact: nextValue } - setControlledValue(nextControlUnit) - onChange(index, nextControlUnit) }, - [controlledValue, index, onChange] + [value, index, onChange] ) const handleDelete = useCallback(() => { @@ -204,7 +213,7 @@ export function ControlUnitSelect({ onChange={handleAdministrationChange} options={allAdministrationsAsOptions} searchable - value={controlledValue.administration} + value={value.administration} />