diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml new file mode 100644 index 0000000..33bc76f --- /dev/null +++ b/.github/workflows/browser-tests.yml @@ -0,0 +1,23 @@ +name: Browser tests +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + selenium: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Use Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: '26.x' + - name: Install dependencies + run: npm ci + - name: Build + run: npm run build + - name: Selenium tests + run: npm run test:e2e diff --git a/README.md b/README.md index a26e414..b7f527d 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,15 @@ Run the dev server: npm run develop ``` -Lint and run the tests (CI runs both on every PR): +Lint and run the tests (CI runs all of them on every PR): ``` npm run lint npm test +npm run build && npm run test:e2e ``` +The browser tests use [Selenium](https://www.selenium.dev/documentation/webdriver/) and Chrome. Selenium Manager finds or downloads Chrome and chromedriver. To use a local install instead, set `CHROME_BIN` and `CHROMEDRIVER`. + Send us a PR / create an issue. If you have a big change coming up, please discuss it with us in an issue first. ## Deploy your own version diff --git a/eslint.config.js b/eslint.config.js index 55749f0..b717b93 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,5 +38,12 @@ export default [ languageOptions: { globals: { ...globals.node } } + }, + { + // Functions passed to executeScript run in the browser + files: ['test/e2e/**/*.js'], + languageOptions: { + globals: { ...globals.browser } + } } ]; diff --git a/package-lock.json b/package-lock.json index 7cd5ab3..43def11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,17 @@ "@eslint/js": "^10.0.1", "eslint": "^10.13.0", "globals": "^17.13.0", + "selenium-webdriver": "^4.51.0", "vite": "^6.0.0" } }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -1147,6 +1155,13 @@ "qified": "^0.10.1" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1553,6 +1568,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1563,6 +1585,13 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1586,6 +1615,13 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1607,6 +1643,19 @@ "dev": true, "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -1631,6 +1680,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1746,6 +1805,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -1825,6 +1891,13 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1855,6 +1928,22 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -1900,6 +1989,46 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/selenium-webdriver": { + "version": "4.51.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.51.0.tgz", + "integrity": "sha512-2FY/x+THHk9uWj+WLBib0r+53D1G0pidox796H5AfbRHhJdPc7CVQPNJi31ip1Tb9duBYRw0zX0wJovnbwSDHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.2", + "tmp": "^0.2.7", + "ws": "^8.22.0" + }, + "engines": { + "node": ">= 22.0.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1933,6 +2062,16 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1950,6 +2089,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -1973,6 +2122,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -2074,6 +2230,28 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index f55506e..31f00ba 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@eslint/js": "^10.0.1", "eslint": "^10.13.0", "globals": "^17.13.0", + "selenium-webdriver": "^4.51.0", "vite": "^6.0.0" }, "scripts": { @@ -44,6 +45,7 @@ "develop": "vite", "lint": "eslint . --max-warnings 0", "preview": "vite preview", - "test": "node --test 'test/*.test.js'" + "test": "node --test 'test/*.test.js'", + "test:e2e": "node --test 'test/e2e/*.test.js'" } } diff --git a/test/e2e/compare.test.js b/test/e2e/compare.test.js new file mode 100644 index 0000000..c02db3c --- /dev/null +++ b/test/e2e/compare.test.js @@ -0,0 +1,174 @@ +// Browser tests for the built site in dist/. Run `npm run build` first. +// +// The server sends the Content-Security-Policy from public/_headers, so +// a test fails if the page needs something the policy blocks. +// +// Selenium Manager finds Chrome and chromedriver. Set CHROME_BIN and +// CHROMEDRIVER to use a local install instead (for example on Linux +// ARM, where Selenium Manager has no Chrome to download). + +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createReadStream, existsSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Builder, By, Key, logging, until } from 'selenium-webdriver'; +import chrome from 'selenium-webdriver/chrome.js'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const csp = readFileSync(join(root, 'public/_headers'), 'utf8') + .match(/Content-Security-Policy: (.*)/)[1]; +const types = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.png': 'image/png', + '.json': 'application/json' +}; + +let server; +let driver; +let base; + +before(async () => { + server = createServer((req, res) => { + const { pathname } = new URL(req.url, 'http://localhost'); + const file = pathname.startsWith('/har/') + ? join(root, 'test', pathname) + : join(root, 'dist', pathname === '/' ? 'index.html' : pathname); + if (!file.startsWith(root) || !existsSync(file)) { + res.writeHead(404); + res.end(); + return; + } + res.setHeader('Content-Type', types[extname(file)] || 'application/octet-stream'); + res.setHeader('Content-Security-Policy', csp); + createReadStream(file).pipe(res); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}/`; + + const options = new chrome.Options().addArguments( + '--headless=new', + '--no-sandbox', + '--window-size=1280,1000' + ); + if (process.env.CHROME_BIN) options.setChromeBinaryPath(process.env.CHROME_BIN); + const prefs = new logging.Preferences(); + prefs.setLevel(logging.Type.BROWSER, logging.Level.ALL); + options.setLoggingPrefs(prefs); + + const builder = new Builder().forBrowser('chrome').setChromeOptions(options); + if (process.env.CHROMEDRIVER) { + builder.setChromeService(new chrome.ServiceBuilder(process.env.CHROMEDRIVER)); + } + driver = await builder.build(); +}); + +after(async () => { + await driver?.quit(); + server?.close(); +}); + +const har = name => `${base}har/browsertime-${name}-wikipedia.har.gz`; +const $ = css => driver.findElement(By.css(css)); +const waitFor = css => driver.wait(until.elementLocated(By.css(css)), 15000); +const browserCell = () => + driver.executeScript(() => [...document.querySelectorAll('.pageXrayTable tr')] + .find(tr => tr.textContent.startsWith('Browser')).cells[1].textContent); +const display = rowClass => + driver.executeScript(c => getComputedStyle(document.getElementsByClassName(c)[0]).display, rowClass); + +test('the start page asks for a URL', async () => { + await driver.get(base); + assert.equal(await $('#footerYear').getText(), String(new Date().getFullYear())); + await $('button[type=submit]').click(); + assert.match(await $('#message').getText(), /at least one HAR/); +}); + +test('pressing Enter in the form compares two HAR files', async () => { + await $('#harurl').sendKeys(har('chrome')); + await $('#harurl2').sendKeys(har('firefox'), Key.ENTER); + await waitFor('.pageXrayTable'); + assert.equal(await driver.getCurrentUrl(), base); + assert.match(await browserCell(), /Chrome/); +}); + +test('every header link points to a section', async () => { + const missing = await driver.executeScript(() => + [...document.querySelectorAll('.header-links a')] + .map(a => a.getAttribute('href').slice(1)) + .filter(id => !document.getElementById(id))); + assert.deepEqual(missing, []); +}); + +test('the waterfalls render', async () => { + await waitFor('#har1 canvas'); + await waitFor('#har2 canvas'); +}); + +test('Switch swaps the HAR files', async () => { + await $('button[data-action=switch]').click(); + assert.match(await browserCell(), /Firefox/); +}); + +test('the run select changes the run', async () => { + await $('#run1Option option[value="2"]').click(); + await driver.wait(async () => (await $('#run1Option').getAttribute('value')) === '2', 5000); +}); + +test('Only differences hides unchanged rows', async () => { + await $('#diffOnlyToggle').click(); + assert.match(await $('.pageXrayTable').getAttribute('class'), /pageXrayTable--diff-only/); + assert.equal(await $('#diffOnlyToggle').getAttribute('aria-pressed'), 'true'); +}); + +for (const section of ['#domainsContent', '#thirdPartyContent']) { + test(`rows in ${section} expand and collapse`, async () => { + const button = await $(`${section} button[data-action=toggle-row]`); + const rows = await button.getAttribute('data-rows'); + await driver.executeScript(el => el.scrollIntoView({ block: 'center' }), button); + await button.click(); + assert.equal(await display(rows), 'table-row'); + await button.click(); + assert.equal(await display(rows), 'none'); + }); +} + +test('the blend slider fades in HAR2', async () => { + const slider = await $('#harBlendSlider'); + await driver.executeScript(el => el.scrollIntoView({ block: 'center' }), slider); + await slider.sendKeys(Key.END); + assert.equal(await $('#har2').getCssValue('opacity'), '1'); +}); + +test('Side by side shows both waterfalls next to each other', async () => { + await $('#waterfallLayoutToggle').click(); + assert.match(await $('.harwrapper').getAttribute('class'), /harwrapper--side-by-side/); +}); + +test('Upload names the new HAR after its file', async () => { + await $('#har2upload').sendKeys(join(root, 'test/har/browsertime-firefox-wikipedia.har.gz')); + await driver.wait(async () => + (await $('.tableXrayHar2Metric').getText()).includes('browsertime-firefox'), 15000); +}); + +test('Upload again shows the start page', async () => { + const button = await $('button[data-action=upload-again]'); + await driver.executeScript(el => el.scrollIntoView({ block: 'center' }), button); + await button.click(); + assert.equal(await $('#choosehars').isDisplayed(), true); +}); + +test('a share link loads the comparison', async () => { + await driver.get(`${base}?har1=${har('chrome')}&har2=${har('firefox')}`); + await waitFor('.pageXrayTable'); +}); + +test('the browser logged no errors or CSP violations', async () => { + const errors = (await driver.manage().logs().get(logging.Type.BROWSER)) + .filter(entry => entry.level.value >= logging.Level.SEVERE.value) + .map(entry => entry.message); + assert.deepEqual(errors, []); +});