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..05ceb9e --- /dev/null +++ b/browser-tests/animation.test.mjs @@ -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, []); + } 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/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/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", 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')