diff --git a/docker/deployment/db-deployment.yaml b/docker/deployment/db-deployment.yaml index b808d4b381..dd2039a941 100644 --- a/docker/deployment/db-deployment.yaml +++ b/docker/deployment/db-deployment.yaml @@ -49,7 +49,7 @@ spec: spec: containers: - name: db - image: mongo:4.0 + image: mongo:4.2 # https://kubernetes.io/docs/concepts/configuration/manage-resources-containers resources: requests: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f809d316d3..a4dae5ea19 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -193,7 +193,7 @@ services: command: sh -c "postconf -e 'default_transport = retry:no outbound email allowed' && /run.sh" db: - image: mongo:4.0 + image: mongo:4.2 container_name: lf-db ports: # exposed this to host for admin tools diff --git a/docker/ssl/Caddyfile b/docker/ssl/Caddyfile index 0e47c89c5a..6d694de469 100644 --- a/docker/ssl/Caddyfile +++ b/docker/ssl/Caddyfile @@ -1,7 +1,7 @@ # https://caddyserver.com/docs/caddyfile { #debug - #auto_https disable_redirects + auto_https disable_redirects } localhost { diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index c9ea7d80e9..0946f35b0b 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -6,6 +6,7 @@ Welcome! We're glad that you are interested in helping develop Language Forge. - [Supported Development Environments](#supported-development-environments) - [Project Setup](#project-setup) - [Running the App Locally](#running-the-app-locally) + - [Mobile Device Testing on a Branch](#mobile-device-testing-on-a-branch) - [Tests](#tests) - [Running Playwright E2E Tests](#running-playwright-e2e-tests) - [Running Protractor E2E Tests](#running-protractor-e2e-tests) @@ -53,19 +54,36 @@ On Windows, the project should be opened with the [Remote - WSL](https://marketp 1. You should see a landing page, click "Login" 1. Use `admin` and `password` to login -> Sometimes there may be a need to hit the locally running app from a device other than the machine the app is running on. In order to do that, you'll need to do the following: -> 1. Figure out your local ip address -> 1. Access the app via http at that address -> -> On a Mac for example: -> ``` -> ifconfig | grep broadcast -> inet 192.168.161.99 netmask 0xfffffc00 broadcast 192.168.163.255 -> ``` -> -> then hit `http://192.168.161.99` from your phone or other device on the same network. -> -> NOTE: disabling cache on your device may not be trivial, you'll either need to wipe the site settings on your device's browser or you'll need to do it via USB debugging. +Note: The application is accessible via HTTP or HTTPS. HTTPS is required for service-worker functionality. + +### Mobile device testing on a branch ### + +Sometimes there may be a need to hit the locally running app from a device other than the machine the app is running on. In order to do that, you'll need to do the following: +### If your machine's firewall is already configured for external access e.g. you use Docker Desktop ### + +1. Figure out your local ip address +1. Access the app via http at that address + +On a Mac for example: +``` +ifconfig | grep broadcast + inet 192.168.161.99 netmask 0xfffffc00 broadcast 192.168.163.255 +``` + +then hit `http://192.168.161.99` from your phone or other device on the same network. + +NOTE: disabling cache on your device may not be trivial, you'll either need to wipe the site settings on your device's browser or you'll need to do it via USB debugging. + +### If your machine's firewall is not configured for external port 80/443 access, you can use ngrok ### + +[Here](https://gist.github.com/SalahHamza/799cac56b8c2cd20e6bfeb8886f18455) are instructions for installing ngrok on WSL (Linux Subsystem for Windows). +[Here](https://ngrok.com/download) are instructions for installing ngrok on Windows, Mac OS, Linux, or Docker. + +Once ngrok is installed, run: +`./ngrok http http://localhost` +in a bash terminal. The same command with https://localhost may not work, so be careful to try http://localhost in particular. + +ngrok will return two URLs, one http and one https, that contain what is being served in localhost. Test on another device using one or both of these URLs. ## Tests diff --git a/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php b/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php index 5f26a83e6a..f510d8591e 100644 --- a/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php +++ b/src/Api/Model/Languageforge/Lexicon/Command/LexUploadCommands.php @@ -117,6 +117,7 @@ public static function uploadAudioFile($projectId, $mediaType, $tmpFilePath) $filePath = self::mediaFilePath($folderPath, $fileNamePrefix, $fileName); $moveOk = copy($fileName, $filePath); + //unlink the converted file from its temporary location @unlink($fileName); @@ -130,6 +131,7 @@ public static function uploadAudioFile($projectId, $mediaType, $tmpFilePath) $data = new MediaResult(); $data->path = $project->getAudioFolderPath($project->getAssetsRelativePath()); $data->fileName = $fileNamePrefix . '_' . $fileName; //if the file has been converted, $fileName = converted file + $data->fileSize = filesize($filePath); $response->result = true; //If this audio upload is replacing old audio, the previous file(s) for the entry are deleted from the assets @@ -226,6 +228,7 @@ public static function uploadImageFile($projectId, $mediaType, $tmpFilePath) $data = new MediaResult(); $data->path = $project->getImageFolderPath($project->getAssetsRelativePath()); $data->fileName = $fileNamePrefix . '_' . $fileName; + $data->fileSize = filesize($filePath); $response->result = true; } else { $data = new ErrorResult(); diff --git a/src/angular-app/bellows/shared/sound-player.component.ts b/src/angular-app/bellows/shared/sound-player.component.ts index cdf1f3285d..869181bfab 100644 --- a/src/angular-app/bellows/shared/sound-player.component.ts +++ b/src/angular-app/bellows/shared/sound-player.component.ts @@ -16,9 +16,13 @@ export class SoundController implements angular.IController { $onInit(): void { - this.slider = this.$element.find('.seek-slider').get(0) as HTMLInputElement; + //So that duration appears immediately once it is available + this.audioElement.addEventListener('durationchange', () => { + this.$scope.$apply(); + }); + this.audioElement.addEventListener('ended', () => { this.$scope.$apply(() => { if (this.playing) { diff --git a/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts b/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts index 657cf1d233..aad11db1c6 100644 --- a/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts +++ b/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts @@ -115,6 +115,9 @@ export class FieldAudioController implements angular.IController { this.dcFilename = response.data.data.fileName; this.showAudioUpload = false; this.notice.push(this.notice.SUCCESS, 'File uploaded successfully.'); + if(response.data.data.fileSize > 1000000){ //1 MB file size limit 2022-10 + this.notice.push(this.notice.WARN, 'WARNING: Because the audio file - ' + response.data.data.fileName + ' - is larger than 1 MB, it will not be synced with FLEx.'); + } } else { this.notice.push(this.notice.ERROR, response.data.data.errorMessage); } diff --git a/src/angular-app/languageforge/lexicon/editor/field/dc-picture.component.ts b/src/angular-app/languageforge/lexicon/editor/field/dc-picture.component.ts index d1b42dbafc..e3b0a4dcd4 100644 --- a/src/angular-app/languageforge/lexicon/editor/field/dc-picture.component.ts +++ b/src/angular-app/languageforge/lexicon/editor/field/dc-picture.component.ts @@ -113,6 +113,9 @@ export class FieldPictureController implements angular.IController { if (isUploadSuccess) { this.upload.progress = 100.0; this.addPicture(response.data.data.fileName); + if(response.data.data.fileSize > 1000000){ //1 MB file size limit 2022-10 + this.notice.push(this.notice.WARN, 'WARNING: Because the image file - ' + response.data.data.fileName + ' - is larger than 1 MB, it will not be synced with FLEx.'); + } this.upload.showAddPicture = false; } else { this.upload.progress = 0; diff --git a/test/e2e/change-password.spec.ts b/test/e2e/change-password.spec.ts index e08bf7b986..d2d6695871 100644 --- a/test/e2e/change-password.spec.ts +++ b/test/e2e/change-password.spec.ts @@ -58,7 +58,7 @@ test.describe('E2E Change Password app', () => { const loginPage = new LoginPage(page); await loginPage.loginAs(member.username, newPassword); const pageHeader = new PageHeader(page); - await expect (pageHeader.myProjects.button).toBeVisible(); + await expect(pageHeader.myProjects.button).toBeVisible(); // TODO: is flaky, fix it }); }); diff --git a/test/e2e/editor-entry.spec.ts b/test/e2e/editor-entry.spec.ts index 9197f44ef4..0e8326a08a 100644 --- a/test/e2e/editor-entry.spec.ts +++ b/test/e2e/editor-entry.spec.ts @@ -47,7 +47,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { }); test('Entries list has correct number of entries', async () => { - expect(await editorPageManager.entriesListPage.getTotalNumberOfEntries()).toEqual(lexEntriesIds.length.toString()); + await editorPageManager.entriesListPage.expectTotalNumberOfEntries(lexEntriesIds.length); }); test('Search function works correctly', async () => { @@ -64,8 +64,8 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Can click on first entry', async () => { await editorPageManager.entriesListPage.clickOnEntry(constants.testEntry1.lexeme.th.value); - //expect(await editorPage.entryCard.entryName.inputValue()).toEqual(constants.testEntry1.lexeme.th.value); - expect(await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).inputValue()).toEqual(constants.testEntry1.lexeme.th.value); + //await expect(editorPage.entryCard.entryName).toHaveValue(constants.testEntry1.lexeme.th.value); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toHaveValue(constants.testEntry1.lexeme.th.value); }); }); @@ -93,7 +93,9 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Entry 1: edit page has correct definition, part of speech', async () => { await editorPageManager.goto(); - expect(await (await editorPageManager.getTextarea(editorPageManager.senseCard, 'Definition', 'en')).inputValue()).toEqual(constants.testEntry1.senses[0].definition.en.value); + await expect(editorPageManager.getTextarea( + editorPageManager.senseCard, 'Definition', 'en')) + .toHaveValue(constants.testEntry1.senses[0].definition.en.value); // TODO: when the partOfSpeech bug is fixed, we can uncomment the following line //expect(await editorPageManager.getSelectedValueFromSelectDropdown(editorPageManager.senseCard, 'Part of Speech')) // .toEqual(constants.testEntry1.senses[0].partOfSpeech.value); @@ -105,7 +107,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await (await editorPageManager.configurationPage.getCheckbox('Entry Fields', 'Citation Form', 'Hidden if Empty')).uncheck(); await editorPageManager.configurationPage.applyButton.click(); await editorPageManager.goto(); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, 'Citation Form', 'th')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, 'Citation Form', 'th')).toBeVisible(); }); test('Citation form field overrides lexeme form in dictionary citation view', async () => { @@ -118,7 +120,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { if ((await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.innerText()).includes('Show Extra Fields')) { await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.click(); } - const citationFormInput = await editorPageManager.getTextarea(editorPageManager.entryCard, 'Citation Form', 'th'); + const citationFormInput = editorPageManager.getTextarea(editorPageManager.entryCard, 'Citation Form', 'th'); await citationFormInput.fill('citation form'); await expect(editorPageManager.renderedDivs).toContainText(['citation form', 'citation form']); @@ -139,7 +141,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { expect(picture).not.toBeUndefined(); const caption = await editorPageManager.getPictureCaption(picture); expect(caption).not.toBeUndefined(); - expect(await caption.inputValue()).toEqual(constants.testEntry1.senses[0].pictures[0].caption.en.value); + await expect(caption).toHaveValue(constants.testEntry1.senses[0].pictures[0].caption.en.value); }); test('File upload drop box is displayed when Add Picture is clicked and can be cancelled', async () => { @@ -217,15 +219,15 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await editorPageManager.goto(); picture = await editorPageManager.getPicture(editorPageManager.senseCard, constants.testEntry1.senses[0].pictures[0].fileName); expect(picture).toBeUndefined(); - expect(await editorPageManager.getPicturesOuterDiv(editorPageManager.senseCard)).not.toBeVisible(); + expect(editorPageManager.getPicturesOuterDiv(editorPageManager.senseCard)).not.toBeVisible(); // TODO: potentially put this in a function if ((await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.innerText()).includes('Show Extra Fields')) { await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.click(); } - expect(await editorPageManager.getPicturesOuterDiv(editorPageManager.senseCard)).toBeVisible(); + expect(editorPageManager.getPicturesOuterDiv(editorPageManager.senseCard)).toBeVisible(); // hide extra fields await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.click(); - expect(await editorPageManager.getPicturesOuterDiv(editorPageManager.senseCard)).not.toBeVisible(); + expect(editorPageManager.getPicturesOuterDiv(editorPageManager.senseCard)).not.toBeVisible(); picture = await editorPageManager.getPicture(editorPageManager.senseCard, constants.testEntry1.senses[0].pictures[0].fileName); expect(picture).toBeUndefined(); }); @@ -250,7 +252,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Audio input system is present, playable and has "more" control (member)', async () => { await editorPageMember.goto(); - const audio: Locator = await editorPageMember.getSoundplayer(editorPageMember.entryCard, lexemeLabel, 'taud'); + const audio: Locator = editorPageMember.getSoundplayer(editorPageMember.entryCard, lexemeLabel, 'taud'); await expect(audio).toBeVisible(); await expect(audio.locator(editorPageMember.audioPlayer.playIconSelector)).toBeVisible(); // check if this audio player is the only one in this card @@ -266,7 +268,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Word 2 (without audio): audio input system is not playable but has "upload" button (member)', async () => { await editorPageMember.goto(lexEntriesIds[1]); - const audio: Locator = await editorPageMember.getSoundplayer(editorPageMember.entryCard, lexemeLabel, 'taud'); + const audio: Locator = editorPageMember.getSoundplayer(editorPageMember.entryCard, lexemeLabel, 'taud'); await expect(editorPageMember.entryCard.locator(editorPageMember.audioPlayer.playIconSelector + ' >> visible=true')).toHaveCount(0); await expect(audio.locator(editorPageMember.audioPlayer.togglePlaybackAnchorSelector)).not.toBeVisible(); await expect(audio.locator(editorPageMember.audioPlayer.dropdownToggleSelector)).toBeEnabled(); @@ -285,7 +287,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Audio Input System is playable but does not have "more" control (observer)', async () => { await editorPageObserver.goto(); - const audio: Locator = await editorPageObserver.getSoundplayer(editorPageObserver.entryCard, lexemeLabel, 'taud'); + const audio: Locator = editorPageObserver.getSoundplayer(editorPageObserver.entryCard, lexemeLabel, 'taud'); await expect(audio.locator(editorPageObserver.audioPlayer.playIconSelector)).toBeVisible(); // check if this audio player is the only one in this card await expect(editorPageObserver.entryCard.locator(editorPageObserver.audioPlayer.playIconSelector + ' >> visible=true')).toHaveCount(1); @@ -298,7 +300,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Word 2 (without audio): audio input system is not playable and does not have "upload" button (observer)', async () => { await editorPageObserver.goto(lexEntriesIds[1]); - const audio: Locator = await editorPageObserver.getSoundplayer(editorPageObserver.entryCard, lexemeLabel, 'taud'); + const audio: Locator = editorPageObserver.getSoundplayer(editorPageObserver.entryCard, lexemeLabel, 'taud'); await expect(editorPageObserver.entryCard.locator(editorPageObserver.audioPlayer.playIconSelector + ' >> visible=true')).toHaveCount(0); await expect(audio.locator(editorPageObserver.audioPlayer.togglePlaybackAnchorSelector)).not.toBeVisible(); await expect(audio.locator(editorPageObserver.audioPlayer.dropdownToggleSelector)).not.toBeVisible(); @@ -310,7 +312,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test.describe('Manager', () => { let audio: Locator; test.beforeAll(async () => { - audio = await editorPageManager.getSoundplayer(editorPageManager.entryCard, lexemeLabel, 'taud'); + audio = editorPageManager.getSoundplayer(editorPageManager.entryCard, lexemeLabel, 'taud'); }) test('Audio input system is present, playable and has "more" control (manager)', async () => { @@ -354,9 +356,8 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await editorPageManager.page.waitForURL( new RegExp(`.*\/app\/lexicon\/${project.id}\/#!\/editor\/entry\/${lexEntriesIds[2]}.*`, 'gm') ); - expect(await (await editorPageManager.getTextarea( - editorPageManager.senseCard.first(), 'Definition', 'en')).inputValue() - ).toEqual(constants.testMultipleMeaningEntry1.senses[0].definition.en.value); + await expect(editorPageManager.getTextarea( + editorPageManager.senseCard.first(), 'Definition', 'en')).toHaveValue(constants.testMultipleMeaningEntry1.senses[0].definition.en.value); }); test('Word 2 (without audio): audio input system is not playable but has "upload" button (manager)', async () => { @@ -396,13 +397,13 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { ]); await fileChooser.setFiles('test/e2e/shared-files/' + constants.testMockPngUploadFile.name); - expect(noticeElement.notice).toHaveCount(1); + await expect(noticeElement.notice).toHaveCount(1); await expect(noticeElement.notice).toBeVisible(); await expect(noticeElement.notice).toContainText(constants.testMockPngUploadFile.name + ' is not an allowed audio file. Ensure the file is'); const dropbox = editorPageManager.entryCard.locator(editorPageManager.dropbox.dragoverFieldSelector); await expect(dropbox).toBeVisible(); await noticeElement.closeButton.click(); - expect(noticeElement.notice).toHaveCount(0); + await expect(noticeElement.notice).toHaveCount(0); // Can upload audio file const [fileChooser2] = await Promise.all([ @@ -410,7 +411,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { editorPageManager.page.locator(editorPageManager.dropbox.browseButtonSelector).click(), ]); await fileChooser2.setFiles('test/e2e/shared-files/' + constants.testMockMp3UploadFile.name); - expect(noticeElement.notice).toHaveCount(1); + await expect(noticeElement.notice).toHaveCount(1); await expect(noticeElement.notice).toBeVisible(); await expect(noticeElement.notice).toContainText('File uploaded successfully'); await expect(editorPageManager.entryCard.locator(editorPageManager.audioPlayer.playIconSelector + ' >> visible=true')).toHaveCount(1); @@ -426,7 +427,9 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Word 2: edit page has correct definition, part of speech', async () => { await editorPageManager.goto(lexEntriesIds[1]); - expect(await (await editorPageManager.getTextarea(editorPageManager.senseCard, 'Definition', 'en')).inputValue()).toEqual(constants.testEntry2.senses[0].definition.en.value); + await expect(editorPageManager.getTextarea( + editorPageManager.senseCard, 'Definition', 'en')) + .toHaveValue(constants.testEntry2.senses[0].definition.en.value); // TODO: when part of speech is fixed, uncomment and fix test // expect(await editorPageManager.getSelectedValueFromSelectDropdown(editorPageManager.senseCard, 'Part of Speech')) @@ -448,12 +451,12 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Word with multiple definitions: edit page has correct definitions, parts of speech', async () => { await editorPageManager.goto(lexEntriesIds[2]); - expect(await (await editorPageManager.getTextarea( + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.first(), 'Definition', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].definition.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].definition.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1), 'Definition', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].definition.en.value); + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].definition.en.value); // TODO: when part of speech is fixed, uncomment and fix test // expect(await editorPageManager.getSelectedValueFromSelectDropdown(editorPageManager.senseCard.nth(0), 'Part of Speech')) @@ -465,76 +468,76 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { test('Word with multiple meanings: edit page has correct example sentences, translations', async () => { await editorPageManager.goto(lexEntriesIds[2]); - expect(await (await editorPageManager.getTextarea( + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.first().locator(editorPageManager.exampleCardSelector + ' >> nth=0'), 'Sentence', 'th')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].examples[0].sentence.th.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].examples[0].sentence.th.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.first().locator(editorPageManager.exampleCardSelector + ' >> nth=0'), 'Translation', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].examples[0].translation.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].examples[0].translation.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.first().locator(editorPageManager.exampleCardSelector + ' >> nth=1'), 'Sentence', 'th')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].examples[1].sentence.th.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].examples[1].sentence.th.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.first().locator(editorPageManager.exampleCardSelector + ' >> nth=1'), 'Translation', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].examples[1].translation.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].examples[1].translation.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1).locator(editorPageManager.exampleCardSelector + ' >> nth=0'), 'Sentence', 'th')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].examples[0].sentence.th.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].examples[0].sentence.th.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1).locator(editorPageManager.exampleCardSelector + ' >> nth=0'), 'Translation', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].examples[0].translation.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].examples[0].translation.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1).locator(editorPageManager.exampleCardSelector + ' >> nth=1'), 'Sentence', 'th')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].examples[1].sentence.th.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].examples[1].sentence.th.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1).locator(editorPageManager.exampleCardSelector + ' >> nth=1'), 'Translation', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].examples[1].translation.en.value); + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].examples[1].translation.en.value); }); test('While Show Hidden Fields has not been clicked, hidden fields are hidden if they are empty', async () => { await editorPageManager.goto(lexEntriesIds[2]); - expect(await editorPageManager.getTextarea( + expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(0), 'Semantics Note', 'en')).toHaveCount(0); - expect(await editorPageManager.getTextarea( + expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(0), 'General Note', 'en')).toBeVisible(); if ((await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.innerText()).includes('Show Extra Fields')) { await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.click(); } - expect(await editorPageManager.getTextarea( + expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(0), 'Semantics Note', 'en')).toBeVisible(); - expect(await editorPageManager.getTextarea( + expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(0), 'General Note', 'en')).toBeVisible(); }); test('Word with multiple meanings: edit page has correct general notes, sources', async () => { await editorPageManager.goto(lexEntriesIds[2]); - expect(await (await editorPageManager.getTextarea( + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(0), 'General Note', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].generalNote.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].generalNote.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1), 'General Note', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].generalNote.en.value); + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].generalNote.en.value); if ((await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.innerText()).includes('Show Extra Fields')) { await editorPageManager.lexAppToolbar.toggleExtraFieldsButton.click(); } - expect(await (await editorPageManager.getTextarea( + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(0), 'Source', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].source.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].source.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1), 'Source', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].source.en.value); + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].source.en.value); }); test('Senses can be reordered and deleted', async () => { await editorPageManager.goto(lexEntriesIds[2]); await editorPageManager.senseCard.first().locator(editorPageManager.actionMenu.toggleMenuButtonSelector).first().click(); await editorPageManager.senseCard.first().locator(editorPageManager.actionMenu.moveDownButtonSelector).first().click(); - expect(await (await editorPageManager.getTextarea( + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.first(), 'Definition', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[1].definition.en.value); - expect(await (await editorPageManager.getTextarea( + .toHaveValue(constants.testMultipleMeaningEntry1.senses[1].definition.en.value); + await expect(editorPageManager.getTextarea( editorPageManager.senseCard.nth(1), 'Definition', 'en')) - .inputValue()).toEqual(constants.testMultipleMeaningEntry1.senses[0].definition.en.value); + .toHaveValue(constants.testMultipleMeaningEntry1.senses[0].definition.en.value); }); test('Back to browse page, create new word, check word count, modify new word, autosaves changes, new word visible in editor and list', async () => { @@ -546,13 +549,13 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await expect(editorPageManager.compactEntryListItem).toHaveCount(entryCount); await editorPageManager.entriesListPage.goto(); - expect(await editorPageManager.entriesListPage.getTotalNumberOfEntries()).toEqual(entryCount.toString()); + await editorPageManager.entriesListPage.expectTotalNumberOfEntries(entryCount); // go back to editor await editorPageManager.page.goBack(); - await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) + await (editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) .fill(constants.testEntry3.lexeme.th.value); - await (await editorPageManager.getTextarea(editorPageManager.senseCard, 'Definition', 'en')) + await (editorPageManager.getTextarea(editorPageManager.senseCard, 'Definition', 'en')) .fill(constants.testEntry3.senses[0].definition.en.value); // TODO: when the partOfSpeech bug is fixed, fix this code @@ -565,12 +568,14 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await editorPageManager.page.waitForURL(url => !url.hash.includes('editor/entry/_new_')); await editorPageManager.page.reload(); - const alreadyThere: string = await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).inputValue(); - await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) + const alreadyThere: string = await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th').inputValue(); + await (editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) .fill(alreadyThere + 'a'); await editorPageManager.page.reload(); - expect(await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).inputValue()).toEqual(constants.testEntry3.lexeme.th.value + 'a'); - await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) + await expect((editorPageManager.getTextarea( + editorPageManager.entryCard, lexemeLabel, 'th'))) + .toHaveValue(constants.testEntry3.lexeme.th.value + 'a'); + await (editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) .fill(constants.testEntry3.lexeme.th.value); // New word is visible in edit page @@ -584,7 +589,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await editorPageManager.entriesListPage.filterInputClearButton.click(); // word count is still correct in browse page - expect(await editorPageManager.entriesListPage.getTotalNumberOfEntries()).toEqual(entryCount.toString()); + await editorPageManager.entriesListPage.expectTotalNumberOfEntries(entryCount); // remove new word to restore original word count await editorPageManager.entriesListPage.clickOnEntry(constants.testEntry3.lexeme.th.value); @@ -597,14 +602,15 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await expect(editorPageManager.compactEntryListItem).toHaveCount(lexEntriesIds.length); // previous entry is selected after delete - expect(await (await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')) - .inputValue()).toEqual(constants.testEntry1.lexeme.th.value); + await expect(editorPageManager.getTextarea( + editorPageManager.entryCard, lexemeLabel, 'th')) + .toHaveValue(constants.testEntry1.lexeme.th.value); }); test('Check that Semantic Domain field is visible (for view settings test later)', async () => { await editorPageManager.goto(); // check if label is present - await expect(await editorPageManager.getLabel(editorPageManager.senseCard.first(), 'Semantic Domain')).not.toHaveCount(0); + await expect(editorPageManager.getLabel(editorPageManager.senseCard.first(), 'Semantic Domain')).not.toHaveCount(0); }); }); @@ -632,9 +638,9 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { await editorPageManager.goto(); // word has only "th", "tipa" and "taud" visible expect(await editorPageManager.getNumberOfElementsWithSameLabel(editorPageManager.entryCard, lexemeLabel)).toEqual(3); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toBeVisible(); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'tipa')).toBeVisible(); - await expect(await editorPageManager.getSoundplayer(editorPageManager.entryCard, lexemeLabel, 'taud')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'tipa')).toBeVisible(); + await expect(editorPageManager.getSoundplayer(editorPageManager.entryCard, lexemeLabel, 'taud')).toBeVisible(); // make "en" input system visible for "Word" field await editorPageManager.configurationPage.goto(); @@ -645,7 +651,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { // check if "en" is visible await editorPageManager.goto(); expect(await editorPageManager.getNumberOfElementsWithSameLabel(editorPageManager.entryCard, lexemeLabel)).toEqual(4); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'en')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'en')).toBeVisible(); // make "en" input system invisible for "Word" field await editorPageManager.configurationPage.goto(); @@ -657,7 +663,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { // check if "en" is invisible await editorPageManager.goto(); expect(await editorPageManager.getNumberOfElementsWithSameLabel(editorPageManager.entryCard, lexemeLabel)).toEqual(3); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'en')).not.toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'en')).not.toBeVisible(); }); @@ -673,13 +679,13 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { // verify that contributor can still see "tipa" await editorPageMember.goto(); expect(await editorPageMember.getNumberOfElementsWithSameLabel(editorPageMember.entryCard, lexemeLabel)).toEqual(2); - await expect(await editorPageMember.getTextarea(editorPageMember.entryCard, lexemeLabel, 'th')).toBeVisible(); - await expect(await editorPageMember.getTextarea(editorPageMember.entryCard, lexemeLabel, 'tipa')).toBeVisible(); + await expect(editorPageMember.getTextarea(editorPageMember.entryCard, lexemeLabel, 'th')).toBeVisible(); + await expect(editorPageMember.getTextarea(editorPageMember.entryCard, lexemeLabel, 'tipa')).toBeVisible(); // Word then only has "th" visible for manager role await editorPageManager.goto(); expect(await editorPageManager.getNumberOfElementsWithSameLabel(editorPageManager.entryCard, lexemeLabel)).toEqual(1); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toBeVisible(); // restore visibility of "taud" for "Word" field await editorPageManager.configurationPage.goto(); @@ -690,8 +696,8 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { // Word has only "th" and "taud" visible for manager role await editorPageManager.goto(); expect(await editorPageManager.getNumberOfElementsWithSameLabel(editorPageManager.entryCard, lexemeLabel)).toEqual(2); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toBeVisible(); - await expect(await editorPageManager.getSoundplayer(editorPageManager.entryCard, lexemeLabel, 'taud')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'th')).toBeVisible(); + await expect(editorPageManager.getSoundplayer(editorPageManager.entryCard, lexemeLabel, 'taud')).toBeVisible(); // restore visibility of "tipa" input system for manager role await editorPageManager.configurationPage.goto(); @@ -701,7 +707,7 @@ test.describe('Lexicon E2E Entry Editor and Entries List', () => { // Word has "th", "tipa" and "taud" visible again for manager role await editorPageManager.goto(); expect(await editorPageManager.getNumberOfElementsWithSameLabel(editorPageManager.entryCard, lexemeLabel)).toEqual(3); - await expect(await editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'tipa')).toBeVisible(); + await expect(editorPageManager.getTextarea(editorPageManager.entryCard, lexemeLabel, 'tipa')).toBeVisible(); }); }); diff --git a/test/e2e/lexicon-new-project.spec.ts b/test/e2e/lexicon-new-project.spec.ts new file mode 100644 index 0000000000..5d9229eff9 --- /dev/null +++ b/test/e2e/lexicon-new-project.spec.ts @@ -0,0 +1,535 @@ +import { expect } from '@playwright/test'; +import { test } from './utils/fixtures'; + +import { NewLexProjectPage } from './pages/new-lex-project.page'; +import { EntriesListPage } from './pages/entries-list.page'; +import { NoticeElement } from './components/notice.component'; + +import { Project } from './utils/types'; +import { initTestProject } from './utils/testSetup'; +import constants from './testConstants.json'; + +test.describe('Lexicon E2E New Project wizard app', () => { + let newLexProjectPageMember: NewLexProjectPage; + // project will exist before testing the creation of new projects through the UI + // this existing project is needed as one test tests whether it is impossible to create a new project with the same name + const existingProject: Project = { + name: 'lexicon-new-project_spec_ts Existing Project', + code: 'p00_lexicon-new-project_spec_ts', + id: '' + }; + const newProject01: Project = { + name: 'lexicon-new-project_spec_ts New Project 1', + code: 'lexicon-new-project_spec_ts_new_project_1', // code as it is generated based on the project name + id: '' + }; + const newProject02: Project = { + name: 'lexicon-new-project_spec_ts New Project 2', + code: 'lexicon-new-project_spec_ts_new_project_2', // code as it is generated based on the project name + id: '' + }; + const newProject03: Project = { + name: 'lexicon-new-project_spec_ts New Project 3', + code: 'lexicon-new-project_spec_ts_new_project_3', // code as it is generated based on the project name + id: '' + }; + + + test.beforeAll(async ({ memberTab, request, manager, member }) => { + newLexProjectPageMember = new NewLexProjectPage(memberTab); + existingProject.id = await initTestProject(request, existingProject.code, existingProject.name, manager.username, [member.username]); + }); + + test('Admin can get to wizard', async ({ adminTab }) => { + const newLexProjectPageAdmin: NewLexProjectPage = new NewLexProjectPage(adminTab); + await newLexProjectPageAdmin.goto(); + await expect(newLexProjectPageAdmin.newLexProjectForm).toBeVisible(); + await expect(newLexProjectPageAdmin.chooserPage.createButton).toBeVisible(); + }); + + test('Manager can get to wizard', async ({ managerTab }) => { + const newLexProjectPageManager: NewLexProjectPage = new NewLexProjectPage(managerTab); + await newLexProjectPageManager.goto(); + await expect(newLexProjectPageManager.newLexProjectForm).toBeVisible(); + await expect(newLexProjectPageManager.chooserPage.createButton).toBeVisible(); + }); + + test('Setup: user login and page contains a form', async () => { + await newLexProjectPageMember.goto(); + await expect(newLexProjectPageMember.newLexProjectForm).toBeVisible(); + await expect(newLexProjectPageMember.chooserPage.createButton).toBeVisible(); + }); + + // step 0: chooser + test.describe('Chooser page', () => { + + test.beforeEach(async () => { + await newLexProjectPageMember.goto(); + }); + + test('Cannot see Back or Next buttons', async () => { + await expect(newLexProjectPageMember.backButton).not.toBeVisible(); + await expect(newLexProjectPageMember.nextButton).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + }); + + test('Can navigate to new project form and back', async () => { + await expect(newLexProjectPageMember.chooserPage.createButton).toBeEnabled(); + await newLexProjectPageMember.chooserPage.createButton.click(); + await expect(newLexProjectPageMember.namePage.projectNameInput).toBeVisible(); + + // Can go back to Chooser page + await expect(newLexProjectPageMember.backButton).toBeVisible(); + await newLexProjectPageMember.backButton.click(); + await expect(newLexProjectPageMember.chooserPage.sendReceiveButton).toBeVisible(); + }); + + test('Can navigate to Send and Receive form and back', async ({ member }) => { + await expect(newLexProjectPageMember.chooserPage.sendReceiveButton).toBeEnabled(); + await newLexProjectPageMember.chooserPage.sendReceiveButton.click(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toHaveValue(member.username); + await expect(newLexProjectPageMember.srCredentialsPage.passwordInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).not.toBeVisible(); + + // Can go back to Chooser page + await expect(newLexProjectPageMember.backButton).toBeVisible(); + await newLexProjectPageMember.backButton.click(); + await expect(newLexProjectPageMember.chooserPage.sendReceiveButton).toBeVisible(); + }); + }); + + // step 1: send receive credentials + test.describe('Send Receive Credentials page', () => { + + test.beforeEach(async () => { + await newLexProjectPageMember.goto(); + await newLexProjectPageMember.chooserPage.sendReceiveButton.click(); + }); + + test('Cannot move on if Password is empty', async () => { + await newLexProjectPageMember.expectFormStatusHasNoError(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.nextButton.click(); + + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Password cannot be empty.'); + }); + + test('Cannot move on if username is incorrect and can go back to Chooser page, user and password preserved', async ({ member }) => { + // passwordValid is, incredibly, an invalid password. + // It's valid only in the sense that it follows the password rules + await newLexProjectPageMember.srCredentialsPage.passwordInput.type(constants.passwordValid); + // tab should trigger validation of the password thus making the test less flaky + await newLexProjectPageMember.srCredentialsPage.passwordInput.press('Tab'); + await expect(newLexProjectPageMember.srCredentialsPage.credentialsInvalid).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('The username or password isn\'t valid on LanguageDepot.org.'); + + // can go back to Chooser page, user and password preserved + await expect(newLexProjectPageMember.backButton).toBeVisible(); + await newLexProjectPageMember.backButton.click(); + await expect(newLexProjectPageMember.chooserPage.sendReceiveButton).toBeVisible(); + await newLexProjectPageMember.chooserPage.sendReceiveButton.click(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toHaveValue(member.username); + await expect(newLexProjectPageMember.srCredentialsPage.passwordInput).toHaveValue(constants.passwordValid); + }); + + test('Cannot move on if Login is empty', async () => { + await newLexProjectPageMember.srCredentialsPage.loginInput.fill(''); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Login cannot be empty.'); + }); + + test('Cannot move on if credentials are invalid', async () => { + await newLexProjectPageMember.srCredentialsPage.loginInput.fill(constants.srUsername); + await newLexProjectPageMember.srCredentialsPage.passwordInput.fill(constants.passwordValid); + await expect(newLexProjectPageMember.srCredentialsPage.credentialsInvalid).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.loginOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).not.toBeVisible(); + }); + + test('Can move on when the credentials are valid but cannot move further if no project is selected', async () => { + await newLexProjectPageMember.srCredentialsPage.loginInput.fill(constants.srUsername); + await newLexProjectPageMember.srCredentialsPage.passwordInput.type(constants.srPassword); + await expect(newLexProjectPageMember.srCredentialsPage.loginOk).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.passwordOk).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.srCredentialsPage.loginInput).toBeVisible(); + await expect(newLexProjectPageMember.srCredentialsPage.projectSelect).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Please select a Project.'); + }); + + test('Cannot move on if not a manager of the project', async () => { + await newLexProjectPageMember.srCredentialsPage.loginInput.fill(constants.srUsername); + await newLexProjectPageMember.srCredentialsPage.passwordInput.type(constants.srPassword); + // TODO: consider putting the name of the mock project in testConstants + await newLexProjectPageMember.srCredentialsPage.projectSelect.selectOption({ label: 'mock-name2 (mock-id2, contributor)' }); + await expect(newLexProjectPageMember.srCredentialsPage.projectNoAccess).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('select a Project that you are the Manager of'); + }); + + test('Can move on when a managed project is selected', async () => { + await newLexProjectPageMember.srCredentialsPage.loginInput.fill(constants.srUsername); + await newLexProjectPageMember.srCredentialsPage.passwordInput.type(constants.srPassword); + await newLexProjectPageMember.srCredentialsPage.projectSelect.selectOption({ label: 'mock-name4 (mock-id4, manager)' }); + await expect(newLexProjectPageMember.srCredentialsPage.projectOk).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + }); + + }); + + // removed test: test tested whether cloning information is visible + + // step 1, 2 & 3 + test.describe('New Project', () => { + test.beforeEach(async () => { + await newLexProjectPageMember.goto(); + await newLexProjectPageMember.chooserPage.createButton.click(); + }); + + // step 1: project name + test.describe('New Project Name page', () => { + test('Cannot move on if name is invalid', async () => { + await expect(newLexProjectPageMember.namePage.projectNameInput).toBeVisible(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.namePage.projectNameInput).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Name cannot be empty.'); + }); + + test('Finds the test project already exists', async () => { + await newLexProjectPageMember.namePage.projectNameInput.fill(existingProject.code); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); + await expect(newLexProjectPageMember.namePage.projectCodeExists).toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + expect(await newLexProjectPageMember.namePage.projectCodeInput).toHaveValue(existingProject.code); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText( + 'Another project with code \'' + existingProject.code + + '\' already exists.'); + }); + + test('With a cleared name does not show an error but is still invalid', async () => { + await newLexProjectPageMember.namePage.projectNameInput.fill(''); + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.namePage.projectNameInput).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Name cannot be empty.'); + }); + + test('Can verify that an unused project name is available', async () => { + await newLexProjectPageMember.namePage.projectNameInput.fill(newProject01.name); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); + await expect(newLexProjectPageMember.namePage.projectCodeOk).toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeInput).toHaveValue(newProject01.code); + await newLexProjectPageMember.expectFormStatusHasNoError(); + }); + + test.describe('Project Code tests', () => { + test.beforeEach(async () => { + await newLexProjectPageMember.namePage.projectNameInput.fill(newProject01.name); + }); + + test('Cannot edit project code by default', async () => { + await expect(newLexProjectPageMember.namePage.projectCodeInput).not.toBeVisible(); + }); + + test.describe('Edit Project Code', () => { + test.beforeEach(async () => { + await expect(newLexProjectPageMember.namePage.editProjectCodeCheckbox).toBeVisible(); + await newLexProjectPageMember.namePage.editProjectCodeCheckbox.check(); + }); + + test('Can edit project code when enabled', async () => { + await expect(newLexProjectPageMember.namePage.projectCodeInput).toBeVisible(); + await newLexProjectPageMember.namePage.projectCodeInput.fill('changed_new_project'); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeInput).toHaveValue('changed_new_project'); + await newLexProjectPageMember.expectFormStatusHasNoError(); + }); + + test('Project code cannot be empty; does not show an error but is still invalid', async () => { + await newLexProjectPageMember.namePage.projectCodeInput.fill(''); + await newLexProjectPageMember.namePage.projectCodeInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.namePage.projectNameInput).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Code cannot be empty.'); + }); + + test('Project code can be one character', async () => { + await newLexProjectPageMember.namePage.editProjectCodeCheckbox.check(); + await newLexProjectPageMember.namePage.projectCodeInput.type('a'); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + }); + + test('Project code cannot be uppercase', async () => { + await newLexProjectPageMember.namePage.projectCodeInput.type('A'); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await newLexProjectPageMember.nextButton.click(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Code must begin with a letter'); + await newLexProjectPageMember.namePage.projectCodeInput.type('aB'); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await newLexProjectPageMember.nextButton.click(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Code must begin with a letter'); + }); + + test('Project code cannot start with a number', async () => { + await newLexProjectPageMember.namePage.projectCodeInput.type('1'); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await newLexProjectPageMember.nextButton.click(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Code must begin with a letter'); + }); + + test('Project code cannot use non-alphanumeric and reverts to default when Edit-project-code is disabled', async () => { + await newLexProjectPageMember.namePage.projectCodeInput.type('a?'); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await newLexProjectPageMember.nextButton.click(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Project Code must begin with a letter'); + + // Project code reverts to default when Edit-project-code is disabled + await expect(newLexProjectPageMember.namePage.editProjectCodeCheckbox).toBeVisible(); + await newLexProjectPageMember.namePage.editProjectCodeCheckbox.uncheck(); + await expect(newLexProjectPageMember.namePage.projectCodeInput).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeInput).toHaveValue(newProject01.code); + await newLexProjectPageMember.expectFormStatusHasNoError(); + }); + }); + + }); + }); + + // this test is composed of multiple tests because they all depend subsequently on one another + // step 2: initial data & step 3: verify data + test('Can create project, initial data page with upload & verify data', async () => { + await newLexProjectPageMember.namePage.projectNameInput.type(newProject01.name); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); // trigger project code check + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + // TODO: understand why the following line causes test failure + // await newLexProjectPageMember.expectFormIsValid(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.namePage.projectNameInput).not.toBeVisible(); + await expect(newLexProjectPageMember.initialDataPageBrowseButton).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + + // step 2: initial data + // Initial Data page with upload + // -- cannot see back button and defaults to uploading data + await expect(newLexProjectPageMember.backButton).not.toBeVisible(); + await expect(newLexProjectPageMember.initialDataPageBrowseButton).toBeVisible(); + await expect(newLexProjectPageMember.progressIndicatorStep3Label).toHaveText('Verify'); + await newLexProjectPageMember.expectFormIsNotValid(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + + // Initial Data page with upload + // --cannot upload large file --------------------------------------------------- + const noticeElement = new NoticeElement(newLexProjectPageMember.page); + const [fileChooser] = await Promise.all([ + newLexProjectPageMember.page.waitForEvent('filechooser'), + newLexProjectPageMember.initialDataPageBrowseButton.click(), + ]); + expect(noticeElement.notice).toHaveCount(0); + // TODO: consider putting the name of this file in testConstants + const dummyLargeFileName: string = 'dummy_large_file.zip'; + await fileChooser.setFiles('test/e2e/shared-files/' + dummyLargeFileName); + await expect(newLexProjectPageMember.initialDataPageBrowseButton).toBeVisible(); + await expect(newLexProjectPageMember.verifyDataPage.entriesImported).not.toBeVisible(); + await expect(noticeElement.notice).toBeVisible(); + expect(noticeElement.notice).toHaveCount(1); + await expect(noticeElement.notice).toContainText('is too large. It must be smaller than'); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await noticeElement.closeButton.click(); + + // Initial Data page with upload + // --cannot upload jpg -------------------------------------------------------- + const [fileChooser2] = await Promise.all([ + newLexProjectPageMember.page.waitForEvent('filechooser'), + newLexProjectPageMember.initialDataPageBrowseButton.click(), + ]); + expect(noticeElement.notice).toHaveCount(0); + const jpgFileName: string = 'FriedRiceWithPork.jpg' + await fileChooser2.setFiles('test/e2e/shared-files/' + jpgFileName); + await expect(noticeElement.notice).toBeVisible(); + expect(noticeElement.notice).toHaveCount(1); + await expect(noticeElement.notice).toContainText(jpgFileName + ' is not an allowed compressed file. Ensure the file is'); + await expect(newLexProjectPageMember.initialDataPageBrowseButton).toBeVisible(); + await expect(newLexProjectPageMember.verifyDataPage.entriesImported).not.toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await noticeElement.closeButton.click(); + + // Initial Data page with upload + // --can upload zip file -------------------------------------------------------- + const [fileChooser3] = await Promise.all([ + newLexProjectPageMember.page.waitForEvent('filechooser'), + newLexProjectPageMember.initialDataPageBrowseButton.click(), + ]); + expect(noticeElement.notice).toHaveCount(0); + // TODO: potentially add another test testing for invalid zip file, notice text "Import failed. Status: 400 Bad Request- [object Object]" + // const dummySmallFileName: string = 'dummy_small_file.zip'; + const testLexProjectFileName: string = 'TestLexProject.zip'; + const numberOfEntriesInTestLexProjectFile: number = 2; + await fileChooser3.setFiles('test/e2e/shared-files/' + testLexProjectFileName); + await expect(newLexProjectPageMember.verifyDataPage.entriesImported).toBeVisible(); + expect(noticeElement.notice).toHaveCount(1); + await expect(noticeElement.notice).toContainText('Successfully imported ' + testLexProjectFileName); + await newLexProjectPageMember.expectFormStatusHasNoError(); + + // step 3: verify data + // Verify Data await page + // --displays stats --------------------------------------------------------------- + await expect(newLexProjectPageMember.verifyDataPage.title).toHaveText(/Verify Data/); + await expect(newLexProjectPageMember.verifyDataPage.entriesImported).toHaveText(numberOfEntriesInTestLexProjectFile.toString()); + await newLexProjectPageMember.expectFormStatusHasNoError(); + + // Verify Data await page + // regression avoidance test - should not redirect when button is clicked + // --displays non-critical errors ------------------------------------------------- + // .not.tobeVisible() is the same as .toBeHidden() - fulfil the expectation even if elements does not exist + // to check if element exists in the DOM but is not visible, do: .toHaveCount(1) .not.toBeVisible() + await expect(newLexProjectPageMember.verifyDataPage.importErrors).toHaveCount(1); + await expect(newLexProjectPageMember.verifyDataPage.importErrors).not.toBeVisible(); + await newLexProjectPageMember.verifyDataPage.nonCriticalErrorsButton.click(); + await expect(newLexProjectPageMember.verifyDataPage.title).toHaveText(/Verify Data/); + await newLexProjectPageMember.expectFormStatusHasNoError(); + await expect(newLexProjectPageMember.verifyDataPage.importErrors).toBeVisible(); + await expect(newLexProjectPageMember.verifyDataPage.importErrors).toContainText('range file \'TestProj.lift-ranges\' was not found'); + await newLexProjectPageMember.verifyDataPage.nonCriticalErrorsButton.click(); + await expect(newLexProjectPageMember.verifyDataPage.importErrors).not.toBeVisible(); + + // Verify Data await page + // --can go to lexicon ------------------------------------------------------------ + await expect(newLexProjectPageMember.nextButton).toBeVisible(); + await newLexProjectPageMember.expectFormIsValid(); + await newLexProjectPageMember.nextButton.click(); + await newLexProjectPageMember.page.waitForURL(/editor\/entry/); + const myRe = /(?<=(.*app\/lexicon\/))(.*)(?=(#!\/editor\/entry\/.*))/; + newProject01.id = myRe.exec(newLexProjectPageMember.page.url())[0]; + const entriesListPage: EntriesListPage = new EntriesListPage(newLexProjectPageMember.page, newProject01.id); + await entriesListPage.expectTotalNumberOfEntries(numberOfEntriesInTestLexProjectFile); + }); + + // step 2: initial data & step 3: verify data + test('Create: new empty project & can skip uploading data', async () => { + await newLexProjectPageMember.namePage.projectNameInput.fill(newProject02.name); + await newLexProjectPageMember.namePage.projectNameInput.press('Tab'); + await expect(newLexProjectPageMember.namePage.projectCodeExists).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeAlphanumeric).not.toBeVisible(); + await expect(newLexProjectPageMember.namePage.projectCodeOk).toBeVisible(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.namePage.projectNameInput).not.toBeVisible(); + await expect(newLexProjectPageMember.initialDataPageBrowseButton).toBeVisible(); + + // can skip uploading data + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.expectFormIsNotValid(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.primaryLanguagePageSelectButton).toBeVisible(); + }); + + // step 3 alternate: primary language + test('Primary Language page', async () => { + await newLexProjectPageMember.namePage.projectNameInput.fill(newProject03.name) + await newLexProjectPageMember.nextButton.click(); + await newLexProjectPageMember.nextButton.click(); + + // --Can go back to initial data page (then forward again) ---------------------- + // skip test because of flakiness + /* await expect(newLexProjectPageMember.backButton).toBeVisible(); + await expect(newLexProjectPageMember.backButton).toBeEnabled(); + // TODO: find out why the following line is flaky + await newLexProjectPageMember.backButton.click(); + await expect(newLexProjectPageMember.initialDataPageBrowseButton).toBeVisible(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.expectFormIsNotValid(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.primaryLanguagePageSelectButton).toBeVisible(); + await expect(newLexProjectPageMember.backButton).toBeVisible(); */ + + // --Cannot move on if language is not selected ---------------------------------- + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.nextButton).toBeEnabled(); + await newLexProjectPageMember.expectFormIsNotValid(); + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.primaryLanguagePageSelectButton).toBeVisible(); + await newLexProjectPageMember.expectFormStatusHasError(); + await expect(newLexProjectPageMember.formStatus).toContainText('Please select a primary language for the project.'); + + // --Can search, select and add language ---------------------------------------- + await newLexProjectPageMember.nextButton.click(); + await expect(newLexProjectPageMember.primaryLanguagePageSelectButton).toBeEnabled(); + await newLexProjectPageMember.primaryLanguagePageSelectButton.click(); + await expect(newLexProjectPageMember.selectLanguage.searchLanguageInput).toBeVisible(); + await newLexProjectPageMember.selectLanguage.searchLanguageInput.fill(constants.searchLanguage); + await newLexProjectPageMember.selectLanguage.searchLanguageInput.press('Enter'); + await expect(newLexProjectPageMember.selectLanguage.languageRows.first()).toBeVisible(); + + await expect(newLexProjectPageMember.selectLanguage.addButton).toBeVisible(); + await expect(newLexProjectPageMember.selectLanguage.addButton).not.toBeEnabled(); + await newLexProjectPageMember.selectLanguage.languageRows.first().click(); + await expect(newLexProjectPageMember.selectLanguage.addButton).toBeEnabled(); + await expect(newLexProjectPageMember.selectLanguage.addButton).toHaveText('Add ' + constants.foundLanguage); + await newLexProjectPageMember.selectLanguage.addButton.click(); + await expect(newLexProjectPageMember.selectLanguage.searchLanguageInput).not.toBeVisible(); + }); + }); +}); diff --git a/test/e2e/pages/base-page.ts b/test/e2e/pages/base-page.ts new file mode 100644 index 0000000000..34d7bdcd88 --- /dev/null +++ b/test/e2e/pages/base-page.ts @@ -0,0 +1,12 @@ +import { Locator, Page } from "@playwright/test"; + +export abstract class BasePage { + + constructor(readonly page: Page, readonly url: string, readonly pageLocator?: Locator) { + } + + async goto() { + await this.page.goto(this.url); + await this.pageLocator?.waitFor(); + } +} diff --git a/test/e2e/pages/editor.page.ts b/test/e2e/pages/editor.page.ts index a69f9d6283..89f041a10c 100644 --- a/test/e2e/pages/editor.page.ts +++ b/test/e2e/pages/editor.page.ts @@ -151,7 +151,7 @@ export class EditorPage { await this.lexAppToolbar.backToListButton.click(); } - async getLabel(card: Locator, label: string): Promise { + getLabel(card: Locator, label: string): Locator { return card.locator(`label:has-text("${label}")`).first(); } @@ -159,11 +159,11 @@ export class EditorPage { return card.locator(`label:has-text("${label}")`).count(); } - async getTextarea(card: Locator, field: string, ws: string): Promise { + getTextarea(card: Locator, field: string, ws: string): Locator { return card.locator(`label:has-text("${field}") >> xpath=.. >> div.input-group:has(span.wsid:has-text("${ws}")) >> textarea`); } - async getDropdown(card: Locator, field: string): Promise { + getDropdown(card: Locator, field: string): Locator { return card.locator(`label:has-text("${field}") >> xpath=.. >> select`); } @@ -171,11 +171,11 @@ export class EditorPage { return card.locator(`label:has-text("${field}") >> xpath=.. >> select >> [selected="selected"]`).innerText(); } - async getSoundplayer(card: Locator, field: string, ws: string): Promise { + getSoundplayer(card: Locator, field: string, ws: string): Locator { return card.locator(`label:has-text("${field}") >> xpath=.. >> div.input-group:has(span.wsid:has-text("${ws}")) >> dc-audio`); } - async getPicturesOuterDiv(card: Locator): Promise { + getPicturesOuterDiv(card: Locator): Locator { return card.locator('[data-ng-switch-when="pictures"]'); } diff --git a/test/e2e/pages/entries-list.page.ts b/test/e2e/pages/entries-list.page.ts index 5e15118139..ee15de0546 100644 --- a/test/e2e/pages/entries-list.page.ts +++ b/test/e2e/pages/entries-list.page.ts @@ -31,12 +31,11 @@ export class EntriesListPage { async goto() { await this.page.goto(this.url); - // JeanneSonTODO: wait for an element on the page to be visible - await this.page.waitForTimeout(3000); } - async getTotalNumberOfEntries(): Promise { - return this.totalNumberOfEntries.innerText(); + async expectTotalNumberOfEntries(nEntries: number) { + // format: "3 / 3" + await expect(this.totalNumberOfEntries).toHaveText(`${nEntries.toString()} / ${nEntries.toString()}`); } async findEntry(lexeme: string): Promise { diff --git a/test/e2e/pages/new-lex-project.page.ts b/test/e2e/pages/new-lex-project.page.ts new file mode 100644 index 0000000000..68083f1923 --- /dev/null +++ b/test/e2e/pages/new-lex-project.page.ts @@ -0,0 +1,88 @@ +import { expect, Page } from '@playwright/test'; +import { BasePage } from './base-page'; + +export class NewLexProjectPage extends BasePage { + + readonly newLexProjectForm = this.page.locator('#new-lex-project-form'); + + // form controls + readonly backButton = this.page.locator('#back-button'); + readonly nextButton = this.page.locator('#next-button'); + readonly formStatus = this.page.locator('#form-status'); + readonly progressIndicatorStep3Label = this.page.locator('#progress-indicator-step3-label'); + + // step 0: chooser + readonly chooserPage = { + sendReceiveButton: this.page.locator('#send-receive-button'), + createButton: this.page.locator('#create-button'), + }; + + // step 1: project name + readonly namePage = { + projectNameInput: this.page.locator('#project-name'), + projectCodeInput: this.page.locator('#project-code'), + projectCodeUneditableInput: this.page.locator('#project-code-uneditable'), + projectCodeLoading: this.page.locator('#project-code-loading'), + projectCodeExists: this.page.locator('#project-code-exists'), + projectCodeAlphanumeric: this.page.locator('#project-code-alphanumeric'), + projectCodeOk: this.page.locator('#project-code-ok'), + editProjectCodeCheckbox: this.page.locator('#edit-project-code') + }; + + // step 1: send receive credentials + readonly srCredentialsPage = { + loginInput: this.page.locator('#sr-username'), + loginOk: this.page.locator('#username-ok'), + passwordInput: this.page.locator('#sr-password'), + credentialsInvalid: this.page.locator('#credentials-invalid'), + passwordOk: this.page.locator('#password-ok'), + projectNoAccess: this.page.locator('#project-no-access'), + projectOk: this.page.locator('#project-ok'), + projectSelect: this.page.locator('#sr-project-select') + } + + // step 2: initial data + readonly initialDataPageBrowseButton = this.page.locator('#browse-button'); + + // step 3: verify data + readonly verifyDataPage = { + title: this.page.locator('#new-project-verify'), + nonCriticalErrorsButton: this.page.locator('#non-critical-errors-button'), + entriesImported: this.page.locator('#entries-imported'), + importErrors: this.page.locator('#import-errors'), + }; + + // step 3 alternate: primary language + readonly primaryLanguagePageSelectButton = this.page.locator('#select-language-button'); + + // select language modal + readonly selectLanguage = { + searchLanguageInput: this.page.locator('.modal-body >> #search-text-input'), + languageRows: this.page.locator('.modal-body >> [data-ng-repeat*="language in $ctrl.languages"]'), + addButton: this.page.locator('.modal-footer >> #select-language-add-btn'), + }; + + constructor(page: Page) { + super(page, '/app/lexicon/new-project'); + } + + async expectFormStatusHasNoError() { + // this expect was flaky; suspicion: await and retry do not work properly with the "not" negation + // await expect(this.formStatus).not.toHaveClass(/alert-danger/); + // this regular expression finds everything not containing "alert-danger" + await expect(this.formStatus).toHaveClass(/^((?!alert-danger).)*$/); + } + + async expectFormStatusHasError() { + await expect(this.formStatus).toHaveClass(/alert-danger/); + } + + async expectFormIsValid() { + await expect(this.nextButton).toHaveClass(/btn-primary(?:\s|$)/); + } + + async expectFormIsNotValid() { + await expect(this.nextButton).not.toHaveClass(/btn-primary(?:\s|$)/); + } + +} diff --git a/test/e2e/playwright_guide/debugging_dot_only.png b/test/e2e/playwright_guide/debugging_dot_only.png new file mode 100644 index 0000000000..0022d54c15 Binary files /dev/null and b/test/e2e/playwright_guide/debugging_dot_only.png differ diff --git a/test/e2e/playwright_guide/debugging_dot_skip.png b/test/e2e/playwright_guide/debugging_dot_skip.png new file mode 100644 index 0000000000..e3ee73a9a4 Binary files /dev/null and b/test/e2e/playwright_guide/debugging_dot_skip.png differ diff --git a/test/e2e/playwright_guide/playwright_cheatsheet.md b/test/e2e/playwright_guide/playwright_cheatsheet.md new file mode 100644 index 0000000000..17caec5c74 --- /dev/null +++ b/test/e2e/playwright_guide/playwright_cheatsheet.md @@ -0,0 +1,35 @@ +# Playwright Cheatsheet - run and debug tests +## Most simple +1. install VSCode Extension: [*Playwright Test for VSCode*](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) +1. `cd docker` +1. `make` +1. run tests directly with the extension in the side bar +1. ![Screenshot showing VSCode Playwright extension](playwright_extension_sidebar.png "Playwright Test for VSCode") +1. or run tests directly from the spec files + +![Screenshot showing extension in the file](playwright_extension_in_test_file.png) +### Debug tests +- debug tests by setting breakpoints in VSCode and select (right click on green triangle) *Debug Test* +## Simple +1. `cd docker` +1. `make playwright-tests` +This commands executes the playwright tests. Execution is specified in the makefile. +## More options +### Setup +1. `cd docker` +1. `make` +1. `cd ..` +1. `npx playwright test` +### Options +- run only a specific test file: `npx playwright test project.spec.ts` +- run only a specific test: add `.only` to the test +![Screenshot showing how to add .only to a test](debugging_dot_only.png) + - only tests which have the `.only` will be executed (one can add `.only` to multiple tests) +- view how the tests are run: `npx playwright test --headed` +- step through the single lines, clicking *next* : `npx playwright test --debug` + +## Essential to know +1. delete all *storageState.json* files before running the tests: `rm *storageState*` +1. not all tests will be executed but Playwright will indicate how many were executed or skipped, e.g., *4 skipped*, *50 passed*. Tests are skipped when they are ought to be skipped (see screenshot below, `.skip`) or when something goes wrong, e.g. an error in the beforeAll leads to all tests of this block being skipped. + +![Screenshot showing how to add .skip to a test](debugging_dot_skip.png) diff --git a/test/e2e/playwright_guide/playwright_extension_in_test_file.png b/test/e2e/playwright_guide/playwright_extension_in_test_file.png new file mode 100644 index 0000000000..b88f9f82e8 Binary files /dev/null and b/test/e2e/playwright_guide/playwright_extension_in_test_file.png differ diff --git a/test/e2e/playwright_guide/playwright_extension_sidebar.png b/test/e2e/playwright_guide/playwright_extension_sidebar.png new file mode 100644 index 0000000000..8fe4bded92 Binary files /dev/null and b/test/e2e/playwright_guide/playwright_extension_sidebar.png differ diff --git a/test/e2e/project-settings.spec.ts b/test/e2e/project-settings.spec.ts index d5538dc460..d41e72f08a 100644 --- a/test/e2e/project-settings.spec.ts +++ b/test/e2e/project-settings.spec.ts @@ -71,7 +71,7 @@ test.describe('E2E Project Settings app', () => { test('Manager can delete if owner', async () => { await projectSettingsPageManager.projectsPage.goto(); const nProjects = await projectSettingsPageManager.projectsPage.countProjects(); - await projectSettingsPageManager.gotoProjectSettingsDirectly(project4.id, project4.name); + await projectSettingsPageManager.gotoProjectSettingsDirectly(project4.id, project4.name); // TODO: fix this flaky line expect(await projectSettingsPageManager.countNotices()).toBe(0); await projectSettingsPageManager.deleteTab.tabTitle.click(); diff --git a/test/e2e/semantic-domains.spec.ts b/test/e2e/semantic-domains.spec.ts index 3ee050a490..94acbb98ed 100644 --- a/test/e2e/semantic-domains.spec.ts +++ b/test/e2e/semantic-domains.spec.ts @@ -38,7 +38,7 @@ test.describe('Lexicon E2E Semantic Domains Lazy Load', () => { test('Should be using English Semantic Domain for manager', async () => { await editorPage.goto(); - expect(await (await editorPage.getTextarea(editorPage.entryCard, lexemeLabel, 'th')).inputValue()).toEqual(constants.testEntry1.lexeme.th.value); + expect(editorPage.getTextarea(editorPage.entryCard, lexemeLabel, 'th')).toHaveValue(constants.testEntry1.lexeme.th.value); await expect(editorPage.senseCard.locator(editorPage.semanticDomainSelector).first()).toHaveText(semanticDomain1dot1English); await expect(pageHeader.languageDropdownButton).toHaveText('English'); }); diff --git a/test/e2e/shared-files/TestLexProject.zip b/test/e2e/shared-files/TestLexProject.zip new file mode 100644 index 0000000000..f4660eaba0 Binary files /dev/null and b/test/e2e/shared-files/TestLexProject.zip differ diff --git a/test/e2e/shared-files/dummy_large_file.zip b/test/e2e/shared-files/dummy_large_file.zip new file mode 100644 index 0000000000..32ec0f4c41 Binary files /dev/null and b/test/e2e/shared-files/dummy_large_file.zip differ diff --git a/test/e2e/shared-files/dummy_small_file.zip b/test/e2e/shared-files/dummy_small_file.zip new file mode 100644 index 0000000000..6c5d4031e0 Binary files /dev/null and b/test/e2e/shared-files/dummy_small_file.zip differ diff --git a/test/e2e/tsconfig.json b/test/e2e/tsconfig.json index 62b5a02a0d..af8c783484 100644 --- a/test/e2e/tsconfig.json +++ b/test/e2e/tsconfig.json @@ -16,6 +16,9 @@ "sourceMap": true, "suppressImplicitAnyIndexErrors": true, "target": "ES6", + "types": [ + "node" + ], "typeRoots": [ "node_modules/@types" ] diff --git a/test/e2e/utils/globalSetup.ts b/test/e2e/utils/globalSetup.ts index 6a347ae7f4..c8070422fa 100644 --- a/test/e2e/utils/globalSetup.ts +++ b/test/e2e/utils/globalSetup.ts @@ -16,41 +16,45 @@ function createUser(request: APIRequestContext, baseName: string) { } export default async function globalSetup(config: FullConfig) { - for (const project of config.projects) { - const baseURL = project.use?.baseURL ?? ( - config.webServer.port - ? `http://localhost:${config.webServer.port}` - : config.webServer.url - ); - const browserName = project.use?.browserName ?? project.use?.defaultBrowserType; - const projectBrowser = ( - browserName === 'chromium' ? chromium : - browserName === 'firefox' ? firefox : - browserName === 'webkit' ? webkit : - chromium - ); - const browser = await projectBrowser.launch(); - const context = await browser.newContext({ baseURL }); - for (const user of usersToCreate) { - await createUser(context.request, user); - } - await context.close(); - - // Now log in as each user and ensure there's a storage state saved - const sessionLifetime = 365 * 24 * 60 * 60 * 1000; // 1 year, in milliseconds - const now = new Date(); - const sessionCutoff = now.getTime() - sessionLifetime; - for (const user of usersToCreate) { - const path = `${browserName}-${user}-storageState.json`; - if (fs.existsSync(path) && fs.statSync(path)?.ctimeMs >= sessionCutoff) { - // Storage state file is recent, no need to re-create it - continue; - } + try { + for (const project of config.projects) { + const baseURL = project.use?.baseURL ?? ( + config.webServer.port + ? `http://localhost:${config.webServer.port}` + : config.webServer.url + ); + const browserName = project.use?.browserName ?? project.use?.defaultBrowserType; + const projectBrowser = ( + browserName === 'chromium' ? chromium : + browserName === 'firefox' ? firefox : + browserName === 'webkit' ? webkit : + chromium + ); + const browser = await projectBrowser.launch(); const context = await browser.newContext({ baseURL }); - const page = await context.newPage(); - await loginAs(page, user); - await context.storageState({ path }); + for (const user of usersToCreate) { + await createUser(context.request, user); + } await context.close(); + + // Now log in as each user and ensure there's a storage state saved + const sessionLifetime = 365 * 24 * 60 * 60 * 1000; // 1 year, in milliseconds + const now = new Date(); + const sessionCutoff = now.getTime() - sessionLifetime; + for (const user of usersToCreate) { + const path = `${browserName}-${user}-storageState.json`; + if (fs.existsSync(path) && fs.statSync(path)?.ctimeMs >= sessionCutoff) { + // Storage state file is recent, no need to re-create it + continue; + } + const context = await browser.newContext({ baseURL }); + const page = await context.newPage(); + await loginAs(page, user); + await context.storageState({ path }); + await context.close(); + } } + } catch (error) { + throw new Error(`Error in Playwright global setup: ${error}.\n`); } }