Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
build/vagrant/.vagrant
build/deploy
develop/richdocuments
e2e/node_modules/
e2e/playwright-report/
e2e/test-results/
e2e/dist/
56 changes: 56 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
}
12 changes: 12 additions & 0 deletions e2e/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { spawnSync } from 'child_process';

const CONTAINER_NAME = 'euro-office-e2e';

export default async function globalTeardown(): Promise<void> {
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' });
}
111 changes: 111 additions & 0 deletions e2e/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
24 changes: 24 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'] },
},
],
});
64 changes: 64 additions & 0 deletions e2e/tests/example-page.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <iframe name="frameEditor">.
// Assert that iframe is present and its src points to the document editor app.
const editorIframe = editorPage.locator('iframe[name="frameEditor"]');
await expect(editorIframe).toBeAttached({ timeout: 15_000 });
await expect(editorIframe).toHaveAttribute('src', /documenteditor|spreadsheeteditor|presentationeditor/);

// 3. Wait for the file to finish loading inside the editor iframe.
// The loading mask (#loading-mask) is removed from the DOM once ready.
const frame = editorPage.frameLocator('iframe[name="frameEditor"]');
await expect(frame.locator('#loading-mask')).toBeHidden({ timeout: 30_000 });

// 4. Verify the Insert tab is clickable and becomes active.
const insertTab = frame.locator('a[data-tab="ins"]');
await insertTab.click();
await expect(frame.locator('li.ribtab:has(a[data-tab="ins"])')).toHaveClass(/active/);

// 5. Verify the View tab is clickable and becomes active.
const viewTab = frame.locator('a[data-tab="view"]');
await viewTab.click();
await expect(frame.locator('li.ribtab:has(a[data-tab="view"])')).toHaveClass(/active/);

// 6. Verify typing into the editor works.
// Click the Home tab first to return to the editing surface, then click
// the canvas area and type. A successful keystroke enables the undo button.
await frame.locator('a[data-tab="home"]').click();
await frame.locator('#editor_sdk').click();
await editorPage.keyboard.type('Hello');
await expect(frame.locator('#slot-btn-undo button').first()).not.toHaveClass(/disabled/, { timeout: 5_000 });
});
}
});
13 changes: 13 additions & 0 deletions e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}