diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d5d7a0..0327bb4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -442,3 +442,67 @@ jobs: # name: packages-${{ github.sha }} # path: dist/packages/ # retention-days: 30 + + e2e: + runs-on: self-hosted + needs: combine + # Only run when the image was actually pushed (main branch or version tags) + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + packages: read + + steps: + - name: Restore cache + uses: actions/cache/restore@v4 + with: + path: "${{ github.workspace }}" + key: ${{ runner.os }}-build-${{ github.sha }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine image tag + id: image + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_OUTPUT + else + echo "ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly" >> $GITHUB_OUTPUT + fi + + - name: Pull built image + run: docker pull ${{ steps.image.outputs.ref }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: e2e/package-lock.json + + - name: Install dependencies + working-directory: e2e + run: npm ci + + - name: Install Playwright browsers + working-directory: e2e + run: npx playwright install chromium --with-deps + + - name: Run E2E tests + working-directory: e2e + env: + E2E_IMAGE: ${{ steps.image.outputs.ref }} + run: npm test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ github.sha }} + path: e2e/playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index f6eefe2..7425992 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ build/vagrant/.vagrant build/deploy develop/richdocuments +e2e/node_modules/ +e2e/playwright-report/ +e2e/test-results/ +e2e/dist/ diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..316464a --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,56 @@ +import { spawnSync } from 'child_process'; + +const CONTAINER_NAME = 'euro-office-e2e'; +const HOST_PORT = 8988; +const IMAGE = process.env.E2E_IMAGE ?? 'euro-office/documentserver:latest'; +const TIMEOUT_MS = 5 * 60 * 1000; +const POLL_INTERVAL_MS = 3000; + +async function waitForHealthcheck(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + const text = await res.text(); + if (text.trim() === 'true') { + console.log('[global-setup] healthcheck passed'); + return; + } + } catch { + // Not ready yet — swallow connection errors and retry + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + throw new Error(`[global-setup] Timed out after ${timeoutMs / 1000}s waiting for healthcheck at ${url}`); +} + +export default async function globalSetup(): Promise { + if (process.env.E2E_BASE_URL) { + // Running against a pre-existing server — skip Docker + console.log(`[global-setup] Using existing server at ${process.env.E2E_BASE_URL}`); + return; + } + + // Remove any leftover container from a previous run (idempotent) + spawnSync('docker', ['rm', '-f', CONTAINER_NAME], { stdio: 'ignore' }); + + console.log(`[global-setup] Starting container ${CONTAINER_NAME} from ${IMAGE}...`); + const result = spawnSync( + 'docker', + ['run', '-d', '--name', CONTAINER_NAME, '-p', `${HOST_PORT}:80`, '-e', 'EXAMPLE_ENABLED=true', IMAGE], + { stdio: 'pipe', encoding: 'utf8' }, + ); + + if (result.status !== 0) { + throw new Error(`[global-setup] docker run failed:\n${result.stderr}`); + } + + process.env.E2E_BASE_URL = `http://localhost:${HOST_PORT}`; + // Signal teardown that we own the container + process.env.E2E_DOCKER_MANAGED = 'true'; + + console.log(`[global-setup] Container ID: ${result.stdout.trim().slice(0, 12)}`); + console.log(`[global-setup] Waiting for healthcheck (up to ${TIMEOUT_MS / 1000}s)...`); + + await waitForHealthcheck(`${process.env.E2E_BASE_URL}/healthcheck`, TIMEOUT_MS); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..a4c96ab --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,12 @@ +import { spawnSync } from 'child_process'; + +const CONTAINER_NAME = 'euro-office-e2e'; + +export default async function globalTeardown(): Promise { + if (process.env.E2E_DOCKER_MANAGED !== 'true') { + // Container was not started by us — leave it running + return; + } + console.log(`[global-teardown] Removing container ${CONTAINER_NAME}...`); + spawnSync('docker', ['rm', '-f', CONTAINER_NAME], { stdio: 'inherit' }); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..8d02342 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "euro-office-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "euro-office-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..fdc1d2a --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "euro-office-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:url": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..07c0a39 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + retries: 1, + reporter: [['list'], ['html', { open: 'never' }]], + globalSetup: require.resolve('./global-setup'), + globalTeardown: require.resolve('./global-teardown'), + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8988', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/e2e/tests/example-page.spec.ts b/e2e/tests/example-page.spec.ts new file mode 100644 index 0000000..675d161 --- /dev/null +++ b/e2e/tests/example-page.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; + +const EXAMPLE_PATH = '/example/'; + +const EDITOR_TYPES = [ + { label: 'Document', selector: 'a.try-editor.word', fileExt: 'docx' }, + { label: 'Spreadsheet', selector: 'a.try-editor.cell', fileExt: 'xlsx' }, + { label: 'Presentation', selector: 'a.try-editor.slide', fileExt: 'pptx' }, +] as const; + +test.describe('Example page - Create new', () => { + test.beforeEach(async ({ page }) => { + await page.goto(EXAMPLE_PATH); + await expect(page).toHaveTitle(/ONLYOFFICE|euro-office/i); + }); + + for (const editor of EDITOR_TYPES) { + test(`Create new ${editor.label}`, async ({ page }) => { + // Register the new-tab handler BEFORE clicking so the event is not missed + const newPagePromise = page.context().waitForEvent('page'); + + await page.click(editor.selector); + + const editorPage = await newPagePromise; + + // The page opens as about:blank then navigates to the editor URL. + // Wait for the URL to match the editor route before asserting content. + await editorPage.waitForURL(/\/example\/editor/); + await editorPage.waitForLoadState('domcontentloaded'); + + // 1. URL contains the correct file extension in the fileName param + await expect(editorPage).toHaveURL(new RegExp(`\\.${editor.fileExt}`)); + + // 2. DocsAPI replaces the #iframeEditor mount point with an