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
20 changes: 20 additions & 0 deletions .github/workflows/browser-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Browser tests

on:
pull_request:
push:
branches: [main]

jobs:
chromium:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:browser
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,16 @@ Start the server with the following command:
```bash
npm start
```

## Browser tests

The browser suite renders fixture pages from the real Pug views and serves the
app's scripts locally. It does not need MongoDB or a running BPMN server.

```bash
npm ci
npx playwright install chromium
npm run test:browser
```

Pull requests run these tests in Chromium through GitHub Actions.
77 changes: 77 additions & 0 deletions browser-tests/animation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { chromium } from 'playwright';
import { join } from 'node:path';
import { renderInstanceFixture, renderModelsFixture, root } from './instanceFixture.mjs';

async function startFixtureServer() {
const app = express();
app.use('/vendor/gsap', express.static(join(root, 'node_modules/gsap/dist')));
app.use(express.static(join(root, 'public')));
app.get('/', (_request, response) => response.send(renderInstanceFixture()));
app.get('/models', (_request, response) => response.send(renderModelsFixture()));
const server = app.listen(0, '127.0.0.1');
await new Promise(resolve => server.once('listening', resolve));
return { server, url: `http://127.0.0.1:${server.address().port}` };
}

test('instance Show Animation plays and can be replayed', { timeout: 20000 }, async () => {
const { server, url } = await startFixtureServer();
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(url, { waitUntil: 'domcontentloaded' });

assert.equal(await page.locator('script[src="/vendor/gsap/gsap.min.js"]').count(), 1);
assert.equal(await page.evaluate(() => typeof gsap), 'object');
assert.equal(await page.evaluate(() => typeof MotionPathPlugin !== 'undefined'), true);
assert.equal(await page.locator('#jsonInfo').count(), 1);

const button = page.getByRole('button', { name: 'Show Animation' });
const flow = page.locator('[data-element-id="Flow_1"]');
await button.click();
await page.waitForFunction(() =>
document.querySelector('[data-element-id="Flow_1"]').classList.contains('Completed'),
);
assert.equal((await flow.getAttribute('class')).includes('Completed'), true);

await button.click();
await page.waitForFunction(() =>
document.querySelector('[data-element-id="Flow_1"]').classList.contains('Completed'),
);
assert.deepEqual(errors, []);
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
});

test('model search updates the preview and section navigation', { timeout: 20000 }, async () => {
const { server, url } = await startFixtureServer();
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(url + '/models', { waitUntil: 'domcontentloaded' });

await page.getByRole('searchbox', { name: 'Search models' }).fill('invoice');
assert.deepEqual(await page.locator('#process option').allTextContents(), ['Invoice']);
assert.equal(await page.locator('#selected-model-name').innerText(), 'Invoice');
assert.equal(await page.locator('#modelLink').getAttribute('href'), '/model/edit/Invoice/');

await page.locator('.workbench-tabs [data-section="tasks"]').click();
await page.locator('#tasks').waitFor({ state: 'visible' });
assert.equal(await page.locator('#tasks').isVisible(), true);
assert.equal(await page.locator('#processes').isVisible(), false);
assert.deepEqual(errors, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent the model fixture from raising a page error

The /models fixture renders index.pug through layoutHome, whose shared head loads SVGHelper.js. On DOMContentLoaded, that script calls scanSVG(), which executes decorations.forEach(...), but this view never defines decorations; Chromium therefore records ReferenceError: decorations is not defined, and this assertion fails on every browser-test workflow run. Initialize the expected global in the fixture or guard/fix the shared helper before requiring an empty page-error list.

Useful? React with 👍 / 👎.

} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
});
61 changes: 61 additions & 0 deletions browser-tests/instanceFixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pug from 'pug';

const root = dirname(dirname(fileURLToPath(import.meta.url)));
const startedAt = '2026-09-27T10:00:00.000Z';
const steps = [
{ id: 'StartEvent_1', type: 'bpmn:StartEvent', action: 'Started' },
{ id: 'Flow_1', type: 'bpmn:SequenceFlow', action: 'Ended' },
{ id: 'Task_1', type: 'bpmn:Task', action: 'Ended' },
];

export function renderInstanceFixture() {
const items = steps.map((step, index) => ({
id: `item-${index}`, elementId: step.id, name: step.id, type: step.type,
status: 'end', startedAt, endedAt: startedAt,
}));
const definition = {
processes: [],
elements: steps.filter(step => step.type !== 'bpmn:SequenceFlow')
.map(step => ({ id: step.id, type: step.type, description: [], behaviours: [] })),
flows: [{ id: 'Flow_1', type: 'bpmn:SequenceFlow', description: [] }],
};
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="150" viewBox="0 0 600 150">
<g class="djs-element" data-element-id="StartEvent_1">
<g class="djs-visual"><circle cx="65" cy="75" r="20" fill="white" stroke="black"/></g>
</g>
<g class="djs-element" data-element-id="Flow_1">
<g class="djs-visual"><path d="M85 75 L350 75" fill="none" stroke="black" stroke-width="2"/></g>
</g>
<g class="djs-element" data-element-id="Task_1">
<g class="djs-visual"><rect x="350" y="45" width="120" height="60" fill="white" stroke="black"/></g>
</g>
</svg>`;
return pug.renderFile(join(root, 'src/views/InstanceDetails.pug'), {
title: 'Instance Details',
instance: { id: 'browser-test', name: 'Animation Fixture', startedAt, endedAt: startedAt, data: {} },
lastItem: items.at(-1),
logs: steps.map((step, index) => ({
type: 'info', date: startedAt,
message: JSON.stringify({ id: step.id, type: step.type, action: step.action, seq: index + 1 }),
})),
items, vars: [], svg, definition: JSON.stringify(definition),
decorations: JSON.stringify(items.map((item, index) => ({
type: 'seq', id: item.elementId, color: 'black', seq: index + 1,
}))),
user: { isAdmin: () => true },
});
}

export function renderModelsFixture() {
return pug.renderFile(join(root, 'src/views/index.pug'), {
title: 'Workbench',
request: { session: {} },
user: { userName: 'system', userGroups: ['SYSTEM'], isAdmin: () => true },
procs: ['Buy Used Car', 'Invoice'],
waiting: [], instances: [], _csrf: 'browser-test',
});
}

export { root };
5 changes: 5 additions & 0 deletions client-tests/svgHelper.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,8 @@ test('Show Animation does not throw if an item is absent from the diagram', () =
});
assert.doesNotThrow(() => script.endAnimation('missing', 1, 'Ended'));
});

test('shared SVG helper ignores pages without an instance diagram', () => {
const script = loadClientScript('SVGHelper.js');
assert.doesNotThrow(() => script.start());
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"clean": "tsc --build --clean",
"test": "node --import tsx src/test/setup.ts && node --import tsx ./node_modules/mocha/bin/mocha.js --config=src/test/.mocharc.json src/test/feature",
"test:engine": "node --test web-engine.test.mjs",
"test:unit": "node --test apikey-auth.test.mjs web-security.test.mjs account-security.test.mjs dashboard-header.test.mjs"
"test:unit": "node --test apikey-auth.test.mjs web-security.test.mjs account-security.test.mjs dashboard-header.test.mjs",
"test:browser": "node --test browser-tests/*.test.mjs"
},
"dependencies": {
"axios": "^1.18.1",
Expand Down
7 changes: 4 additions & 3 deletions public/javascripts/SVGHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,13 @@ let svg = $('svg');
}
function start()
{
scanSVG()


svg = $('svg');
if (!svg.get(0))
return;

if (typeof decorations === 'undefined' || typeof jsonData === 'undefined')
return;
scanSVG();

$(document).click(function (event) {
let id = $(event.target).parent().attr('data-element-id')
Expand Down
Loading