From 2acab43758e0b4babfa24187764f51e6fb291b42 Mon Sep 17 00:00:00 2001 From: ralphhanna Date: Sun, 27 Sep 2026 07:56:57 -0400 Subject: [PATCH 1/3] Add Chromium browser tests for instance and model views --- .github/workflows/browser-tests.yml | 20 ++++++++ README.md | 13 +++++ browser-tests/animation.test.mjs | 76 +++++++++++++++++++++++++++++ browser-tests/instanceFixture.mjs | 61 +++++++++++++++++++++++ package.json | 3 +- 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/browser-tests.yml create mode 100644 browser-tests/animation.test.mjs create mode 100644 browser-tests/instanceFixture.mjs diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml new file mode 100644 index 0000000..b2aa688 --- /dev/null +++ b/.github/workflows/browser-tests.yml @@ -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 diff --git a/README.md b/README.md index 670626b..295a6d5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/browser-tests/animation.test.mjs b/browser-tests/animation.test.mjs new file mode 100644 index 0000000..1d137d6 --- /dev/null +++ b/browser-tests/animation.test.mjs @@ -0,0 +1,76 @@ +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), 'function'); + 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(); + assert.equal(await page.locator('#tasks').isVisible(), true); + assert.equal(await page.locator('#processes').isVisible(), false); + assert.deepEqual(errors, []); + } finally { + if (browser) await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +}); diff --git a/browser-tests/instanceFixture.mjs b/browser-tests/instanceFixture.mjs new file mode 100644 index 0000000..6294baf --- /dev/null +++ b/browser-tests/instanceFixture.mjs @@ -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 = ` + + + + + + + + + + `; + 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 }; diff --git a/package.json b/package.json index b87c0b7..a8261dd 100644 --- a/package.json +++ b/package.json @@ -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", From 67509451bf8fc619a2db997ac19798cbb08ef9c4 Mon Sep 17 00:00:00 2001 From: ralphhanna Date: Sun, 27 Sep 2026 07:58:40 -0400 Subject: [PATCH 2/3] Correct browser assertions and wait for section switch --- browser-tests/animation.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/browser-tests/animation.test.mjs b/browser-tests/animation.test.mjs index 1d137d6..05ceb9e 100644 --- a/browser-tests/animation.test.mjs +++ b/browser-tests/animation.test.mjs @@ -28,7 +28,7 @@ test('instance Show Animation plays and can be replayed', { timeout: 20000 }, as 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), 'function'); + 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' }); @@ -66,6 +66,7 @@ test('model search updates the preview and section navigation', { timeout: 20000 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, []); From f920979d9288cc9f522b467f3cc83fddd7908597 Mon Sep 17 00:00:00 2001 From: ralphhanna Date: Sun, 27 Sep 2026 08:00:37 -0400 Subject: [PATCH 3/3] Skip SVG decorations on pages without instance diagrams --- client-tests/svgHelper.test.mjs | 5 +++++ public/javascripts/SVGHelper.js | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client-tests/svgHelper.test.mjs b/client-tests/svgHelper.test.mjs index aec6657..f29c676 100644 --- a/client-tests/svgHelper.test.mjs +++ b/client-tests/svgHelper.test.mjs @@ -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()); +}); diff --git a/public/javascripts/SVGHelper.js b/public/javascripts/SVGHelper.js index 86eeb21..bf73f03 100644 --- a/public/javascripts/SVGHelper.js +++ b/public/javascripts/SVGHelper.js @@ -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')