From c23a0bc96b9733a6dac9fd76b405ab616fcde4a0 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 19 Nov 2024 11:03:10 +0700 Subject: [PATCH] Run playwright tests in our gha integration test workflow (#1132) * run playwright tests after S&R tests in the gha k8s instance * ensure maildev is forwarded properly * install dependancies during playwright setup * fix email base url for gha environment * fix regex used for AddMemberModal tests * ensure return to path used in LoginRedirect is relative for invite emails * Exclude viewer page playwright tests in GHA because Mongo is not available * Run Playwright tests even if dotnet tests fail * use develop image tag for lexbox-hgweb and fw-headless in gha integration tests * remove unused environment variables from frontend tests * pull playwright tests into a reusable action and call them from both staging and gha integration tests --------- Co-authored-by: Tim Haasdyk --- .github/actions/playwright-tests/action.yaml | 55 ++++++ .github/workflows/integration-test-gha.yaml | 14 +- .github/workflows/integration-test.yaml | 44 +---- backend/LexBoxApi/Services/EmailService.cs | 1 + deployment/gha/kustomization.yaml | 4 +- deployment/gha/lexbox.patch.yaml | 3 + frontend/tests/envVars.ts | 16 -- frontend/tests/viewerPage.test.ts | 179 ++++++++++--------- 8 files changed, 175 insertions(+), 141 deletions(-) create mode 100644 .github/actions/playwright-tests/action.yaml diff --git a/.github/actions/playwright-tests/action.yaml b/.github/actions/playwright-tests/action.yaml new file mode 100644 index 000000000..c6e460977 --- /dev/null +++ b/.github/actions/playwright-tests/action.yaml @@ -0,0 +1,55 @@ +name: Setup and run playwright tests +inputs: + lexbox-hostname: + description: 'The hostname of the lexbox server, should include port if not 80' + required: true + lexbox-default-password: + description: 'The default password for the lexbox server' + required: true + viewer-tests: + description: 'Whether to run viewer tests' + required: false + default: 'true' + +runs: + using: composite + steps: + # First we need to setup Node... + - uses: actions/setup-node@v4 + with: + node-version-file: 'frontend/package.json' + # Then we can set up pnpm... + - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + package_json_file: 'frontend/package.json' + # Then we can have Node set up package caching + - uses: actions/setup-node@v4 + with: + node-version-file: 'frontend/package.json' + cache: 'pnpm' + cache-dependency-path: 'frontend/pnpm-lock.yaml' + - name: Playwright setup + shell: bash + working-directory: frontend + run: pnpm install + - name: Set up Playwright dependencies + shell: bash + working-directory: frontend + run: pnpm exec playwright install --with-deps + + - name: Integration tests (Playwright) + id: playwright-tests + shell: bash + env: + TEST_SERVER_HOSTNAME: ${{ inputs.lexbox-hostname }} + TEST_DEFAULT_PASSWORD: ${{ inputs.lexbox-default-password }} + working-directory: frontend + run: pnpm run test ${{ inputs.viewer-tests != 'true' && '-g "^(?!.*Viewer Page).*"' || '' }} + + - name: Upload playwright results + if: ${{ always() && steps.playwright-tests.outcome != 'skipped' }} + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: | + ./frontend/test-results diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml index 98a5dbe86..a604eea41 100644 --- a/.github/workflows/integration-test-gha.yaml +++ b/.github/workflows/integration-test-gha.yaml @@ -76,7 +76,9 @@ jobs: kubectl wait --for=condition=Ready --timeout=120s pod -l 'app.kubernetes.io/component=controller' -n languagedepot kubectl wait --for=condition=Ready --timeout=120s pod -l 'app in (lexbox, ui, hg, db)' -n languagedepot - name: forward ingress - run: kubectl port-forward service/ingress-nginx-controller 6579:80 -n languagedepot & + run: | + kubectl port-forward service/ingress-nginx-controller 6579:80 -n languagedepot & + kubectl port-forward service/lexbox 1080:1080 -n languagedepot & - name: verify ingress run: curl -v http://localhost:6579 - name: build @@ -89,6 +91,16 @@ jobs: TEST_PROJECT_CODE: 'sena-3' TEST_DEFAULT_PASSWORD: 'pass' run: dotnet test LexBoxOnly.slnf --logger GitHubActions --filter "Category=Integration|Category=FlakyIntegration" --blame-hang-timeout 40m + + ##playwright tests + - name: Setup and run playwright tests + if: ${{ !cancelled() }} + uses: ./.github/actions/playwright-tests + with: + lexbox-hostname: 'localhost:6579' + lexbox-default-password: 'pass' + viewer-tests: 'false' # exclude the viewer page tests, because mongo is not available + - name: status if: failure() run: | diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 49bd0dd2d..d35311121 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -152,8 +152,6 @@ jobs: runs-on: ${{ inputs.runs-on }} steps: - uses: actions/checkout@v4 - with: - submodules: true - name: Setup self-hosted dependencies if: ${{ inputs.runs-on == 'self-hosted' }} run: | @@ -164,47 +162,23 @@ jobs: sudo apt-get install -f rm powershell_7.4.1-1.deb_amd64.deb pwsh #validate that powershell installed correctly - # First we need to setup Node... - - uses: actions/setup-node@v4 - with: - node-version-file: 'frontend/package.json' - # Then we can set up pnpm... - - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 - with: - package_json_file: 'frontend/package.json' - # Then we can have Node set up package caching - - uses: actions/setup-node@v4 - with: - node-version-file: 'frontend/package.json' - cache: 'pnpm' - cache-dependency-path: 'frontend/pnpm-lock.yaml' - - name: Playwright setup - working-directory: frontend - run: pnpm install && pnpm pretest - - name: Set up Playwright dependencies - working-directory: frontend - if: ${{ inputs.runs-on == 'self-hosted' }} - run: sudo pnpm exec playwright install-deps - - name: Integration tests (Playwright) - env: - TEST_SERVER_HOSTNAME: ${{ vars.TEST_SERVER_HOSTNAME }} - # this is not a typo, we need to use the lf domain because it has a cert that hg will validate - TEST_STANDARD_HG_HOSTNAME: ${{ vars.TEST_STANDARD_HG_HOSTNAME }} - TEST_RESUMABLE_HG_HOSTNAME: ${{ vars.TEST_RESUMABLE_HG_HOSTNAME }} - TEST_PROJECT_CODE: 'sena-3' - TEST_DEFAULT_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} - working-directory: frontend - run: pnpm test + - name: Setup and run playwright tests + if: ${{ !cancelled() }} + uses: ./.github/actions/playwright-tests + with: + lexbox-hostname: ${{ vars.TEST_SERVER_HOSTNAME }} + lexbox-default-password: ${{ secrets.TEST_USER_PASSWORD }} + viewer-tests: 'true' - name: Password protect Playwright traces id: password_protect_test_results - if: ${{ always() }} + if: ${{ !cancelled() }} shell: bash env: ZIP_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} run: 7z a ./playwright-traces.7z -mx=0 -mmt=off ./frontend/test-results -p"$ZIP_PASSWORD" - name: Upload playwright results - if: ${{ always() && steps.password_protect_test_results.outcome == 'success' }} + if: ${{ !cancelled() && steps.password_protect_test_results.outcome == 'success' }} uses: actions/upload-artifact@v4 with: name: playwright-traces-${{ inputs.runs-on }}-hg-${{ inputs.hg-version }} diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index 0e5669066..6a7215c9f 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -162,6 +162,7 @@ private async Task SendInvitationEmail( var httpContext = httpContextAccessor.HttpContext; ArgumentNullException.ThrowIfNull(httpContext); + //using GetPathByAction so the path is relative var returnTo = _linkGenerator.GetPathByAction(httpContext, nameof(Controllers.UserController.HandleInviteLink), "User"); diff --git a/deployment/gha/kustomization.yaml b/deployment/gha/kustomization.yaml index d3a7091e4..0e5e3ec23 100644 --- a/deployment/gha/kustomization.yaml +++ b/deployment/gha/kustomization.yaml @@ -21,4 +21,6 @@ images: - name: ghcr.io/sillsdev/lexbox-ui newTag: develop - name: ghcr.io/sillsdev/lexbox-hgweb - newTag: latest + newTag: develop + - name: ghcr.io/sillsdev/lexbox-fw-headless + newTag: develop diff --git a/deployment/gha/lexbox.patch.yaml b/deployment/gha/lexbox.patch.yaml index 538e31a32..b17fc9286 100644 --- a/deployment/gha/lexbox.patch.yaml +++ b/deployment/gha/lexbox.patch.yaml @@ -8,6 +8,9 @@ spec: spec: containers: - name: lexbox-api + env: + - name: Email__BaseUrl + value: "http://localhost:6579" volumeMounts: - mountPath: /frontend name: gql-schema diff --git a/frontend/tests/envVars.ts b/frontend/tests/envVars.ts index 3b0ea442c..378bb0d28 100644 --- a/frontend/tests/envVars.ts +++ b/frontend/tests/envVars.ts @@ -2,10 +2,6 @@ export const serverHostname = process.env.TEST_SERVER_HOSTNAME ?? 'localhost'; export const isDev = process.env.NODE_ENV === 'development' || serverHostname.startsWith('localhost'); export const httpScheme = isDev ? 'http://' : 'https://'; export const serverBaseUrl = `${httpScheme}${serverHostname}`; -export const standardHgHostname = process.env.TEST_STANDARD_HG_HOSTNAME ?? 'hg.localhost'; -export const resumableHgHostname = process.env.TEST_RESUMABLE_HG_HOSTNAME ?? 'resumable.localhost'; -export const resumableBaseUrl = `${httpScheme}${resumableHgHostname}`; -export const projectCode = process.env.TEST_PROJECT_CODE ?? 'sena-3'; export const defaultPassword = process.env.TEST_DEFAULT_PASSWORD ?? 'pass'; export const authCookieName = '.LexBoxAuth'; export const invalidJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkphdmFJblVzZSIsImV4cCI6MTY5OTM0ODY2NywiaWF0IjoxNjk5MzQ4NjY3fQ.f8N63gcD_iv-E_x0ERhJwARaBKnZnORaZGe0N2J0VGM'; @@ -14,15 +10,3 @@ export const TEST_TIMEOUT = 40_000; export const TEST_TIMEOUT_2X = TEST_TIMEOUT * 2; export const ACTION_TIMEOUT = 5_000; export const EXPECT_TIMEOUT = 5_000; - -export enum HgProtocol { - Hgweb, - Resumable, -} - -export function getTestHostName(protocol: HgProtocol) : string { - switch (protocol) { - case HgProtocol.Hgweb: return standardHgHostname; - case HgProtocol.Resumable: return resumableHgHostname; - } -} diff --git a/frontend/tests/viewerPage.test.ts b/frontend/tests/viewerPage.test.ts index 765736635..9c1c01fa2 100644 --- a/frontend/tests/viewerPage.test.ts +++ b/frontend/tests/viewerPage.test.ts @@ -6,92 +6,95 @@ import {expect} from '@playwright/test'; import {loginAs} from './utils/authHelpers'; import {test} from './fixtures'; -test('navigate to viewer', async ({ page }) => { - // Step 1: Login - await loginAs(page.request, 'editor', testEnv.defaultPassword); - const userDashboard = await new UserDashboardPage(page).goto(); - - // Step 2: Click through to viewer - const projectPage = await userDashboard.openProject('Sena 3', 'sena-3'); - const viewerPage = await projectPage.clickBrowseInViewer(); - await viewerPage.dismissAboutDialog(); -}); - -test('find entry', async ({ page }) => { - // Step 1: Login to viewer - await loginAs(page.request, 'editor', testEnv.defaultPassword); - const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3').goto(); - await viewerPage.dismissAboutDialog(); - - // Step 2: Use search bar to go to entry "shrew" - await viewerPage.search('animal'); - await expect(viewerPage.searchResults).toHaveCount(5); - await viewerPage.clickSearchResult('shrew'); - - async function verifyViewerState(): Promise { - // Step 3: Check that we are on the shrew entry - await expect(viewerPage.entryDictionaryPreview).toContainText('shrew'); - await expect(viewerPage.page.locator('.field:has-text("Citation form") input:visible')) - .toHaveValue('nkhwizi'); - - // Step 4: Verify entry list filter and order - await expect(viewerPage.entryListItems).toHaveCount(7); - await expect(viewerPage.entryListItems.nth(0)).toContainText('cifuwo'); - await expect(viewerPage.entryListItems.nth(3)).toContainText('shrew'); - await expect(viewerPage.entryListItems.nth(6)).toContainText('nyama'); - } - - await verifyViewerState(); - await viewerPage.page.reload(); - await viewerPage.waitFor(); - await verifyViewerState(); -}); - -test('entry details', async ({ page }) => { - // Step 1: Login to viewer at entry "thembe" - await loginAs(page.request, 'editor', testEnv.defaultPassword); - const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3') - .goto({ urlEnd: '?entryId=49cc9257-90c7-4fe0-a9e0-2c8d72aa5e2b&search=animal' }); - await viewerPage.dismissAboutDialog(); - - // Step 2: verify entry details - // -- Dictionary Preview - const expectPreview = expect(viewerPage.entryDictionaryPreview); - await expectPreview.toContainText('nthembe'); - await expectPreview.toContainText(' Nome '); - await expectPreview.toContainText(' Eng '); - await expectPreview.toContainText('animal skin alone, after it is taken off the body'); - await expectPreview.toContainText(' Por '); - await expectPreview.toContainText('pele de animal'); - - // -- Entry fields - const lexeme = viewerPage.page.locator('.field:has-text("Lexeme") .ws-field-wrapper:visible'); - await expect(lexeme.getByRole('textbox')).toHaveValue('thembe'); - await expect(lexeme.getByLabel('Sen', { exact: true })).toHaveValue('thembe'); - - await expect(viewerPage.page.locator('.field:has-text("Citation form") input:visible')).toHaveValue('nthembe'); - - // -- Sense - await expect(viewerPage.page.locator('[id^="sense"]')).toHaveCount(1); - await expect(viewerPage.page.locator(':text("gloss")')).toHaveCount(1); - - const gloss = viewerPage.page.locator('.field:has-text("Gloss") .ws-field-wrapper:visible'); - await expect(gloss).toHaveCount(2); - await expect(gloss.getByLabel('Eng')).toHaveValue('animal skin'); - await expect(gloss.getByLabel('Por')).toHaveValue('pele de animal'); - - const definition = viewerPage.page.locator('.field:has-text("Definition") .ws-field-wrapper:visible'); - await expect(definition).toHaveCount(1); - await expect(definition.getByLabel('Eng')).toHaveValue('animal skin alone, after it is taken off the body'); - - const grammaticalInfo = viewerPage.page.locator('.field:has-text("Grammatical info") input:visible'); - await expect(grammaticalInfo).toHaveValue('Nome'); - - const semanticDomains = viewerPage.page.locator('.field:has-text("Semantic domain") input:visible'); - await expect(semanticDomains).toHaveValue('1.6.2 Parts of an animal'); - - // -- Example - await expect(viewerPage.page.locator('[id^="example"]')).toHaveCount(1); - const reference = viewerPage.page.locator('.field:has-text("Reference") input'); - await expect(reference).toHaveValue('Wordlist'); +test.describe('Viewer Page', () => { + + test('navigate to viewer', async ({page}) => { + // Step 1: Login + await loginAs(page.request, 'editor', testEnv.defaultPassword); + const userDashboard = await new UserDashboardPage(page).goto(); + + // Step 2: Click through to viewer + const projectPage = await userDashboard.openProject('Sena 3', 'sena-3'); + const viewerPage = await projectPage.clickBrowseInViewer(); + await viewerPage.dismissAboutDialog(); + }); + + test('find entry', async ({page}) => { + // Step 1: Login to viewer + await loginAs(page.request, 'editor', testEnv.defaultPassword); + const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3').goto(); + await viewerPage.dismissAboutDialog(); + + // Step 2: Use search bar to go to entry "shrew" + await viewerPage.search('animal'); + await expect(viewerPage.searchResults).toHaveCount(5); + await viewerPage.clickSearchResult('shrew'); + + async function verifyViewerState(): Promise { + // Step 3: Check that we are on the shrew entry + await expect(viewerPage.entryDictionaryPreview).toContainText('shrew'); + await expect(viewerPage.page.locator('.field:has-text("Citation form") input:visible')) + .toHaveValue('nkhwizi'); + + // Step 4: Verify entry list filter and order + await expect(viewerPage.entryListItems).toHaveCount(7); + await expect(viewerPage.entryListItems.nth(0)).toContainText('cifuwo'); + await expect(viewerPage.entryListItems.nth(3)).toContainText('shrew'); + await expect(viewerPage.entryListItems.nth(6)).toContainText('nyama'); + } + + await verifyViewerState(); + await viewerPage.page.reload(); + await viewerPage.waitFor(); + await verifyViewerState(); + }); + + test('entry details', async ({page}) => { + // Step 1: Login to viewer at entry "thembe" + await loginAs(page.request, 'editor', testEnv.defaultPassword); + const viewerPage = await new ViewerPage(page, 'Sena 3', 'sena-3') + .goto({urlEnd: '?entryId=49cc9257-90c7-4fe0-a9e0-2c8d72aa5e2b&search=animal'}); + await viewerPage.dismissAboutDialog(); + + // Step 2: verify entry details + // -- Dictionary Preview + const expectPreview = expect(viewerPage.entryDictionaryPreview); + await expectPreview.toContainText('nthembe'); + await expectPreview.toContainText(' Nome '); + await expectPreview.toContainText(' Eng '); + await expectPreview.toContainText('animal skin alone, after it is taken off the body'); + await expectPreview.toContainText(' Por '); + await expectPreview.toContainText('pele de animal'); + + // -- Entry fields + const lexeme = viewerPage.page.locator('.field:has-text("Lexeme") .ws-field-wrapper:visible'); + await expect(lexeme.getByRole('textbox')).toHaveValue('thembe'); + await expect(lexeme.getByLabel('Sen', {exact: true})).toHaveValue('thembe'); + + await expect(viewerPage.page.locator('.field:has-text("Citation form") input:visible')).toHaveValue('nthembe'); + + // -- Sense + await expect(viewerPage.page.locator('[id^="sense"]')).toHaveCount(1); + await expect(viewerPage.page.locator(':text("gloss")')).toHaveCount(1); + + const gloss = viewerPage.page.locator('.field:has-text("Gloss") .ws-field-wrapper:visible'); + await expect(gloss).toHaveCount(2); + await expect(gloss.getByLabel('Eng')).toHaveValue('animal skin'); + await expect(gloss.getByLabel('Por')).toHaveValue('pele de animal'); + + const definition = viewerPage.page.locator('.field:has-text("Definition") .ws-field-wrapper:visible'); + await expect(definition).toHaveCount(1); + await expect(definition.getByLabel('Eng')).toHaveValue('animal skin alone, after it is taken off the body'); + + const grammaticalInfo = viewerPage.page.locator('.field:has-text("Grammatical info") input:visible'); + await expect(grammaticalInfo).toHaveValue('Nome'); + + const semanticDomains = viewerPage.page.locator('.field:has-text("Semantic domain") input:visible'); + await expect(semanticDomains).toHaveValue('1.6.2 Parts of an animal'); + + // -- Example + await expect(viewerPage.page.locator('[id^="example"]')).toHaveCount(1); + const reference = viewerPage.page.locator('.field:has-text("Reference") input'); + await expect(reference).toHaveValue('Wordlist'); + }); });