diff --git a/.github/workflows/e2e-partial.yaml b/.github/workflows/e2e-partial.yaml new file mode 100644 index 00000000..838da8f3 --- /dev/null +++ b/.github/workflows/e2e-partial.yaml @@ -0,0 +1,94 @@ +name: E2E (Playwright) + +on: + workflow_call: + + +jobs: + playwright-tests: + timeout-minutes: 60 + strategy: + matrix: + shardIndex: [1,2,3,4] + shardTotal: [4] + name: E2E Playwright Testing ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Task + uses: arduino/setup-task@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - uses: pnpm/action-setup@v3.0.0 + with: + version: 9.12.2 + + - name: Install dependencies + run: pnpm install + working-directory: frontend + + - name: Install Go Dependencies + run: go mod download + working-directory: backend + + - name: Run E2E Tests + run: task test:e2e -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - uses: actions/upload-artifact@v4 + name: Upload partial Playwright report + if: ${{ !cancelled() }} + with: + name: blob-report-${{ matrix.shardIndex }} + path: frontend/blob-report/ + retention-days: 2 + + merge-reports: + # Merge reports after playwright-tests, even if some shards have failed + if: ${{ !cancelled() }} + needs: [playwright-tests] + name: Merge Playwright Reports + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - uses: pnpm/action-setup@v3.0.0 + with: + version: 9.12.2 + + - name: Install dependencies + run: pnpm install + working-directory: frontend + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 + with: + path: frontend/all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into HTML Report + run: pnpm exec playwright merge-reports --reporter html,github ./all-blob-reports + working-directory: frontend + + - name: Upload HTML report + uses: actions/upload-artifact@v4 + with: + name: html-report--attempt-${{ github.run_attempt }} + path: frontend/playwright-report + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/partial-frontend.yaml b/.github/workflows/partial-frontend.yaml index 0786b9eb..422faee1 100644 --- a/.github/workflows/partial-frontend.yaml +++ b/.github/workflows/partial-frontend.yaml @@ -1,4 +1,4 @@ -name: Frontend / E2E +name: Frontend on: workflow_call: @@ -114,7 +114,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: lts/* - uses: pnpm/action-setup@v3.0.0 with: diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index e94977ac..7112a6d6 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -17,5 +17,9 @@ jobs: uses: ./.github/workflows/partial-backend.yaml frontend-tests: - name: "Frontend and End-to-End Tests" - uses: ./.github/workflows/partial-frontend.yaml \ No newline at end of file + name: "Frontend Tests" + uses: ./.github/workflows/partial-frontend.yaml + + e2e-tests: + name: "End-to-End Playwright Tests" + uses: ./.github/workflows/e2e-partial.yaml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 60f66aff..b769d9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,9 @@ backend/api docs/.vitepress/cache/ /.data/ + +# Playwright +frontend/test-results/ +frontend/playwright-report/ +frontend/blob-report/ +frontend/playwright/.cache/ diff --git a/Taskfile.yml b/Taskfile.yml index f2bd78aa..ecdf6755 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -81,6 +81,15 @@ tasks: - go run ./app/api/ {{ .CLI_ARGS }} silent: false + go:ci: + env: + HBOX_DEMO: true + desc: Runs all go test and lint related tasks + dir: backend + cmds: + - go run ./app/api/ {{ .CLI_ARGS }} & + silent: true + go:test: desc: Runs all go tests using gotestsum - supports passing gotestsum args dir: backend @@ -162,6 +171,13 @@ tasks: cmds: - pnpm dev + ui:ci: + desc: Run frontend build in CI mode + dir: frontend + cmds: + - pnpm dev & + silent: true + ui:fix: desc: Runs prettier and eslint on the frontend dir: frontend @@ -200,6 +216,17 @@ tasks: - cd frontend && pnpm run test:ci silent: true + test:e2e: + desc: Runs end-to-end test on a live server + dir: frontend + cmds: + - task: go:ci + - task: ui:ci + - pnpm exec playwright install-deps + - pnpm exec playwright install + - sleep 30 + - TEST_SHUTDOWN_API_SERVER=true pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }} + pr: desc: Runs all tasks required for a PR cmds: diff --git a/frontend/package.json b/frontend/package.json index b7acd483..66e15bd8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@iconify-json/mdi": "^1.2.3", "@intlify/unplugin-vue-i18n": "^4.0.0", "@nuxtjs/eslint-config-typescript": "^12.1.0", + "@playwright/test": "^1.49.1", "@types/markdown-it": "^13.0.9", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e8385c5f..b5ae2612 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: '@nuxtjs/eslint-config-typescript': specifier: ^12.1.0 version: 12.1.0(eslint@8.57.1)(typescript@5.6.2) + '@playwright/test': + specifier: ^1.49.1 + version: 1.49.1 '@types/markdown-it': specifier: ^13.0.9 version: 13.0.9 @@ -1586,6 +1589,11 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.49.1': + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -3364,6 +3372,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4556,6 +4569,16 @@ packages: pkg-types@2.1.0: resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==} + playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -7745,6 +7768,10 @@ snapshots: '@pkgr/core@0.1.1': {} + '@playwright/test@1.49.1': + dependencies: + playwright: 1.49.1 + '@polka/url@1.0.0-next.28': {} '@redocly/ajv@8.11.2': @@ -9885,6 +9912,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11270,6 +11300,14 @@ snapshots: exsolve: 1.0.1 pathe: 2.0.3 + playwright-core@1.49.1: {} + + playwright@1.49.1: + dependencies: + playwright-core: 1.49.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} portfinder@1.0.33: diff --git a/frontend/test/e2e/login.browser.spec.ts b/frontend/test/e2e/login.browser.spec.ts new file mode 100644 index 00000000..9c2d20c9 --- /dev/null +++ b/frontend/test/e2e/login.browser.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from "@playwright/test"; + +test("valid login", async ({ page }) => { + await page.goto("/home"); + await expect(page).toHaveURL("/"); + await page.fill("input[type='text']", "demo@example.com"); + await page.fill("input[placeholder='Password']", "demo"); + await page.click("button[type='submit']"); + await expect(page).toHaveURL("/home"); +}); + +test("invalid login", async ({ page }) => { + await page.goto("/home"); + await expect(page).toHaveURL("/"); + await page.fill("input[type='text']", "dummy@example.com"); + await page.fill("input[placeholder='Password']", "dummy"); + await page.click("button[type='submit']"); + await expect(page.locator("div[class*='top-2']")).toHaveText("Invalid email or password"); + await expect(page).toHaveURL("/"); +}); + +test("registration", async ({ page }) => { + test.slow(); + test.fixme(); + // Register a new user + await page.goto("/home"); + await expect(page).toHaveURL("/"); + await page.click("button[class$='btn-wide']"); + await page.fill( + "html > body > div:nth-of-type(1) > div > div:nth-of-type(2) > div:nth-of-type(2) > div > div > form > div > div > div:nth-of-type(1) > input", + "test@example.com" + ); + await page.fill( + "html > body > div:nth-of-type(1) > div > div:nth-of-type(2) > div:nth-of-type(2) > div > div > form > div > div > div:nth-of-type(2) > input", + "Test User" + ); + await page.fill("input[placeholder='Password']", "ThisIsAStrongDemoPass"); + await page.click("button[class$='mt-2']"); + await expect(page).toHaveURL("/"); + + // Try to register the same user again (it should fail) + await page.goto("/home"); + await expect(page).toHaveURL("/"); + await page.click("button[class$='btn-wide']"); + await page.fill( + "html > body > div:nth-of-type(1) > div > div:nth-of-type(2) > div:nth-of-type(2) > div > div > form > div > div > div:nth-of-type(1) > input", + "test@example.com" + ); + await page.fill( + "html > body > div:nth-of-type(1) > div > div:nth-of-type(2) > div:nth-of-type(2) > div > div > form > div > div > div:nth-of-type(2) > input", + "Test User" + ); + await page.fill("input[placeholder='Password']", "ThisIsAStrongDemoPass"); + await page.click("button[class$='mt-2']"); + await expect(page).toHaveURL("/"); + await expect(page.locator("div[class*='top-2']")).toHaveText("Problem registering user"); +}); diff --git a/frontend/test/playwright.config.ts b/frontend/test/playwright.config.ts new file mode 100644 index 00000000..458efa38 --- /dev/null +++ b/frontend/test/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + reporter: process.env.CI ? "blob" : "html", + use: { + baseURL: process.env.E2E_BASE_URL || "http://localhost:3000", + trace: "on-all-retries", + video: "retry-with-video", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + globalTeardown: require.resolve("./playwright.teardown"), +}); diff --git a/frontend/test/playwright.teardown.ts b/frontend/test/playwright.teardown.ts new file mode 100644 index 00000000..78f5b380 --- /dev/null +++ b/frontend/test/playwright.teardown.ts @@ -0,0 +1,16 @@ +import { exec } from "child_process"; + +function globalTeardown() { + if (process.env.TEST_SHUTDOWN_API_SERVER) { + const pc = exec("pkill -SIGTERM api"); // Kill background API process + const fr = exec("pkill -SIGTERM task"); // Kill background Frontend process + pc.stdout?.on("data", (data: void) => { + console.log(`stdout: ${data}`); + }); + fr.stdout?.on("data", (data: void) => { + console.log(`stdout: ${data}`); + }); + } +} + +export default globalTeardown; diff --git a/frontend/test/vitest.config.ts b/frontend/test/vitest.config.ts index 5f236103..f3838986 100644 --- a/frontend/test/vitest.config.ts +++ b/frontend/test/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ // @ts-ignore test: { globalSetup: "./test/setup.ts", + include: ["**/*.test.ts"], }, resolve: { alias: {