Skip to content

Commit ff35d4d

Browse files
cmdcolinclaude
andcommitted
Add puppeteer-based E2E snapshot tests with weekly cron
Sets up puppeteer + jest-image-snapshot tests that load the MafViewer plugin in a nightly JBrowse instance and verify rendering. The CI workflow now runs E2E tests on every push and weekly on Mondays. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 32871ab commit ff35d4d

9 files changed

Lines changed: 1957 additions & 27 deletions

File tree

.github/workflows/integration.yml

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
11
name: Integration
22

3-
on: push
3+
on:
4+
push:
5+
schedule:
6+
- cron: '0 9 * * 1'
47

58
jobs:
6-
integration:
7-
name: Run integration tests
9+
build-and-lint:
10+
name: Build and lint
811
runs-on: ubuntu-latest
912
if: "!contains(github.event.head_commit.message, 'skip ci')"
1013
steps:
1114
- uses: actions/checkout@v4
12-
- name: Use Node.js 20.x
15+
- name: Use Node.js 22.x
1316
uses: actions/setup-node@v4
1417
with:
15-
node-version: 20.x
18+
node-version: 22.x
1619
- name: Install deps (with cache)
1720
uses: bahmutov/npm-install@v1
18-
- name: lint
19-
run: yarn lint
20-
- name: build
21+
- name: Build
2122
run: yarn build
23+
- name: Lint
24+
run: yarn lint
25+
26+
e2e-tests:
27+
name: E2E tests (nightly)
28+
runs-on: ubuntu-latest
29+
needs: build-and-lint
30+
steps:
31+
- uses: actions/checkout@v4
32+
- name: Use Node.js 22.x
33+
uses: actions/setup-node@v4
34+
with:
35+
node-version: 22.x
36+
- name: Install deps (with cache)
37+
uses: bahmutov/npm-install@v1
38+
- name: Install JBrowse CLI
39+
run: npm install -g @jbrowse/cli
40+
- name: Setup JBrowse nightly
41+
run: jbrowse create .test-jbrowse-nightly --nightly
42+
- name: Run E2E tests
43+
run: TEST_JBROWSE_VERSION=nightly yarn vitest run test/
44+
- name: Upload screenshots on failure
45+
if: failure()
46+
uses: actions/upload-artifact@v4
47+
with:
48+
name: screenshots-nightly
49+
path: test-screenshots/
50+
- name: Upload image snapshot diffs on failure
51+
if: failure()
52+
uses: actions/upload-artifact@v4
53+
with:
54+
name: snapshot-diffs-nightly
55+
path: test/__image_snapshots__/__diff_output__/

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,8 @@ cypress/screenshots
126126

127127
meta.json
128128
.vscode
129+
130+
# E2E test artifacts
131+
.test-jbrowse-*
132+
test-screenshots/
133+
debug-no-canvas.png

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"clean": "rimraf dist",
1818
"start": "node esbuild.mjs --watch",
1919
"test": "vitest",
20+
"test:e2e": "vitest run test/",
21+
"pretest:e2e": "test -d .test-jbrowse-nightly || jbrowse create .test-jbrowse-nightly --nightly",
2022
"bench": "vitest bench",
2123
"bench:memory": "node --expose-gc --experimental-strip-types src/BigMafAdapter/memoryBenchmark.ts",
2224
"format": "prettier --write .",
@@ -34,6 +36,7 @@
3436
"@emotion/react": "^11.10.4",
3537
"@fal-works/esbuild-plugin-global-externals": "^2.1.2",
3638
"@gmod/bbi": "^8.1.1",
39+
"@jbrowse/cli": "^4.1.14",
3740
"@jbrowse/core": "^4.1.3",
3841
"@jbrowse/mobx-state-tree": "^5.4.1",
3942
"@jbrowse/plugin-data-management": "^4.1.3",
@@ -43,6 +46,7 @@
4346
"@mui/x-data-grid": "^8.2.0",
4447
"@types/d3-array": "^3.2.1",
4548
"@types/d3-hierarchy": "^3.1.7",
49+
"@types/jest-image-snapshot": "^6.4.1",
4650
"@types/node": "^25.0.10",
4751
"@types/react": "^19.2.10",
4852
"chalk": "^5.3.0",
@@ -52,10 +56,12 @@
5256
"eslint-plugin-react": "^7.20.3",
5357
"eslint-plugin-react-hooks": "^7.0.1",
5458
"eslint-plugin-unicorn": "^62.0.0",
59+
"jest-image-snapshot": "^6.5.2",
5560
"mobx": "^6.0.0",
5661
"mobx-react": "^9.0.1",
5762
"prettier": "^3.4.2",
5863
"pretty-bytes": "^7.0.0",
64+
"puppeteer": "^24.39.1",
5965
"react": "^19.2.4",
6066
"react-dom": "^19.2.4",
6167
"rimraf": "^6.0.1",

test/image-snapshot.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import 'vitest'
2+
3+
interface CustomMatchers<R = unknown> {
4+
toMatchImageSnapshot(): R
5+
}
6+
7+
declare module 'vitest' {
8+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
9+
interface Assertion<T = unknown> extends CustomMatchers<T> {}
10+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
11+
interface AsymmetricMatchersContaining extends CustomMatchers {}
12+
}

test/plugin.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
4+
5+
import {
6+
cleanupJBrowse,
7+
createJBrowsePage,
8+
launchBrowser,
9+
setupJBrowse,
10+
startJBrowseServer,
11+
stopServer,
12+
waitForJBrowseLoad,
13+
waitForTrackLoad,
14+
} from './setup'
15+
16+
import type { ChildProcess } from 'node:child_process'
17+
import type { Browser, Page } from 'puppeteer'
18+
19+
const JBROWSE_VERSION = process.env.TEST_JBROWSE_VERSION || 'nightly'
20+
const SCREENSHOT_DIR = path.join('test-screenshots', JBROWSE_VERSION)
21+
22+
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
23+
24+
function screenshot(name: string) {
25+
return path.join(SCREENSHOT_DIR, `${name}.png`)
26+
}
27+
28+
describe('MafViewer Plugin E2E', () => {
29+
let server: ChildProcess | undefined
30+
let browser: Browser | undefined
31+
let page: Page | undefined
32+
const pluginErrors: string[] = []
33+
34+
beforeAll(async () => {
35+
setupJBrowse()
36+
server = await startJBrowseServer()
37+
browser = await launchBrowser()
38+
page = await createJBrowsePage(browser)
39+
40+
page.on('console', msg => {
41+
const text = msg.text()
42+
if (
43+
msg.type() === 'error' &&
44+
(text.includes('plugin') || text.includes('Plugin'))
45+
) {
46+
pluginErrors.push(text)
47+
}
48+
})
49+
50+
await waitForJBrowseLoad(page)
51+
await waitForTrackLoad(page)
52+
}, 180_000)
53+
54+
afterAll(async () => {
55+
if (browser) {
56+
await browser.close()
57+
}
58+
if (server) {
59+
await stopServer(server)
60+
}
61+
await cleanupJBrowse()
62+
})
63+
64+
it('should load JBrowse without errors', async () => {
65+
expect(page).toBeDefined()
66+
const root = await page!.$('#root')
67+
expect(root).not.toBeNull()
68+
await page!.screenshot({ path: screenshot('jbrowse-loaded') })
69+
}, 30_000)
70+
71+
it('should load the MafViewer plugin without errors', async () => {
72+
expect(page).toBeDefined()
73+
74+
if (pluginErrors.length > 0) {
75+
console.log('Plugin errors:', pluginErrors)
76+
}
77+
expect(pluginErrors).toHaveLength(0)
78+
79+
const pluginLoaded = await page!.evaluate(() => {
80+
// @ts-expect-error JBrowse global
81+
const session = window.__jbrowse_session
82+
if (session) {
83+
const plugins = session.jbrowse?.plugins || []
84+
return plugins.some(
85+
(p: { name: string }) =>
86+
p.name === 'MafViewer' ||
87+
p.name === 'MafViewerPlugin' ||
88+
p.name === 'jbrowse-plugin-mafviewer',
89+
)
90+
}
91+
const scripts = Array.from(document.querySelectorAll('script'))
92+
return scripts.some(s => s.src?.includes('mafviewer'))
93+
})
94+
95+
console.log(`Plugin loaded: ${pluginLoaded}`)
96+
expect(pluginLoaded).toBe(true)
97+
}, 30_000)
98+
99+
it('should render MAF track without crashing', async () => {
100+
expect(page).toBeDefined()
101+
await new Promise(r => setTimeout(r, 5000))
102+
await page!.screenshot({ path: screenshot('maf-track-rendered') })
103+
104+
const canvasCount = await page!.$$eval('canvas', els => els.length)
105+
console.log(`Canvas elements found: ${canvasCount}`)
106+
expect(canvasCount).toBeGreaterThan(0)
107+
}, 60_000)
108+
109+
it('should match image snapshot', async () => {
110+
expect(page).toBeDefined()
111+
await new Promise(r => setTimeout(r, 2000))
112+
113+
// Hide the header bar which contains a timestamp
114+
await page!.evaluate(() => {
115+
const header = document.querySelector('header')
116+
if (header) {
117+
;(header as HTMLElement).style.display = 'none'
118+
}
119+
})
120+
121+
const image = await page!.screenshot()
122+
expect(image).toMatchImageSnapshot()
123+
124+
// Restore header
125+
await page!.evaluate(() => {
126+
const header = document.querySelector('header')
127+
if (header) {
128+
;(header as HTMLElement).style.display = ''
129+
}
130+
})
131+
}, 30_000)
132+
})

0 commit comments

Comments
 (0)