Mercurial
diff hg-web/e2e/app_e2e_test.js @ 226:3fa4bf481f42
[merge] Merge hg-web into default
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 14:42:01 -0700 |
| parents | 70de0c80d093 |
| children | c5129452493e |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/app_e2e_test.js Sun Aug 02 14:42:01 2026 -0700 @@ -0,0 +1,417 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { chromium } = require('playwright-core'); + +const BASE_URL = 'http://127.0.0.1:6970'; +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`, + ); + } + return result.stdout.trim(); +} + +function mercurialEnvironment(home) { + return { + ...process.env, + HGPLAIN: '1', + HGRCPATH: '', + HOME: home, + }; +} + +function stopProcess(child) { + if (!child || child.exitCode !== null) return Promise.resolve(); + child.kill('SIGTERM'); + return new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForHttp(url, child, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`); +} + +function createFixtureRepository(root) { + const options = { env: mercurialEnvironment(root) }; + run('hg', ['init', root], options); + fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'README.md'), + '# Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n', + ); + fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n'); + fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n'); + fs.writeFileSync( + path.join(root, 'BUILD'), + 'cc_library(\n name = "fixture",\n srcs = ["src/main.c"],\n)\n', + ); + fs.writeFileSync( + path.join(root, 'pixel.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=', + 'base64', + ), + ); + fs.writeFileSync(path.join(root, 'movie.mp4'), Buffer.from('00000018667479706d703432', 'hex')); + fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3')); + fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n')); + fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3])); + run('hg', ['--repository', root, 'add'], options); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options); + const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + + fs.writeFileSync( + path.join(root, 'README.md'), + '# Updated Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n\nSecond revision.\n', + ); + fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n'); + run('hg', ['add', 'new-file.txt'], { ...options, cwd: root }); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options); + const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + return { firstNode, tipNode }; +} + +async function assertJson(pathname, status = 200) { + const response = await fetch(`${BASE_URL}${pathname}`); + const body = await response.text(); + assert.equal(response.status, status, `${pathname} status: ${body}`); + return JSON.parse(body); +} + +async function assertPageHasNoBrowserErrors(browser, pathname, assertion) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`)); + page.on('console', message => { + if (message.type() === 'error') errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' }); + assert.equal(response.status(), 200, `${pathname} document status`); + await assertion(page); + if (errors.length > 0) { + throw new Error(`${pathname} browser errors\n${errors.join('\n')}`); + } + await page.close(); +} + +async function main() { + assert.ok(RUNFILES, 'rules_js runfiles path is required'); + assert.ok(WORKSPACE, 'rules_js workspace name is required'); + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-')); + const appLogs = []; + const hgLogs = []; + let hgServer; + let appServer; + let browser; + + try { + const { firstNode, tipNode } = createFixtureRepository(fixtureRoot); + const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); + const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server'); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + + assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`); + assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`); + + hgServer = spawn( + 'hg', + [ + '--repository', fixtureRoot, + 'serve', + '--address', '127.0.0.1', + '--port', '4444', + '--accesslog', '-', + '--errorlog', '-', + ], + { + env: mercurialEnvironment(fixtureRoot), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString())); + hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString())); + + appServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + stdio: ['ignore', 'pipe', 'pipe'], + }); + appServer.stdout.on('data', chunk => appLogs.push(chunk.toString())); + appServer.stderr.on('data', chunk => appLogs.push(chunk.toString())); + + await waitForHttp(`${BASE_URL}/`, appServer, appLogs); + if (hgServer.exitCode !== null) { + throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`); + } + await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs); + + for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} shell route`); + assert.match(await response.text(), /<title>Zenbu Repository<\/title>/); + } + for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} static asset`); + assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`); + } + for (const pathname of ['/hg-web-background.jpg', '/pencil_texture.png']) { + assert.equal((await fetch(`${BASE_URL}${pathname}`)).status, 404); + } + + const rootList = await assertJson('/api/repo/list'); + assert.ok(rootList.directories.some(entry => entry.basename === 'docs')); + assert.ok(rootList.files.some(entry => entry.basename === 'README.md')); + + const nestedList = await assertJson('/api/repo/list?path=docs'); + assert.ok(nestedList.files.some(entry => entry.basename === 'README.md')); + + const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`); + assert.equal(fileResponse.status, 200); + assert.match(await fileResponse.text(), /int main/); + + for (const [filename, contentType, disposition] of [ + ['pixel.png', 'image/png', 'inline'], + ['movie.mp4', 'video/mp4', 'inline'], + ['sound.mp3', 'audio/mpeg', 'inline'], + ['document.pdf', 'application/pdf', 'inline'], + ['archive.bin', 'application/octet-stream', 'attachment'], + ]) { + const response = await fetch( + `${BASE_URL}/api/repo/file?path=${encodeURIComponent(filename)}`, + ); + assert.equal(response.status, 200, `${filename} response`); + assert.match(response.headers.get('content-type') || '', new RegExp(`^${contentType}`)); + assert.equal(response.headers.get('content-disposition'), disposition); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.ok((await response.arrayBuffer()).byteLength > 0); + } + const binaryResponse = await fetch(`${BASE_URL}/api/repo/file?path=archive.bin`); + assert.deepEqual( + Buffer.from(await binaryResponse.arrayBuffer()), + Buffer.from([0, 1, 2, 3]), + ); + + const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`); + assert.equal(readmeResponse.status, 200); + assert.match(await readmeResponse.text(), /Nested README/); + assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204); + + const graph = await assertJson('/api/graph/tip?style=json'); + assert.equal(graph.node, tipNode); + assert.ok(graph.changesets.length >= 2); + + const changeset = await assertJson(`/api/changeset/${tipNode}`); + assert.equal(changeset.node, tipNode); + assert.equal(changeset.desc, 'Update fixture'); + assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added')); + assert.ok(changeset.diff.length > 0); + + for (const pathname of [ + '/api/repo/list?path=..%2Fetc', + '/api/repo/file?path=..%2FREADME.md', + '/api/graph/not-a-node?style=json', + '/api/changeset/not-a-node', + ]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 400, `${pathname} rejects invalid input`); + } + assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404); + + const identify = run( + 'hg', + ['identify', `${BASE_URL}/repo`], + { env: mercurialEnvironment(fixtureRoot) }, + ); + assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`)); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + + await assertPageHasNoBrowserErrors(browser, '/', async page => { + await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor(); + await page.getByText('Recent Commits').waitFor(); + await page.getByText('Repository Files').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + assert.equal( + await page.locator('.graph-container').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.locator('.theme-toggle').click(); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory', async page => { + await page.getByText('Repository Files').waitFor(); + await page.getByRole('link', { name: 'README.md' }).first().click(); + await page.getByText('Updated Fixture Repository').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + await page.keyboard.press('Escape'); + + let delayedReadmeRequested = false; + let markReadmeRequested; + const readmeRequested = new Promise(resolve => { + markReadmeRequested = resolve; + }); + await page.route(/\/api\/repo\/readme\?path=/, async route => { + delayedReadmeRequested = true; + markReadmeRequested(); + await new Promise(resolve => setTimeout(resolve, 500)); + await route.continue(); + }); + await page.getByRole('link', { name: 'docs' }).click(); + await Promise.race([ + readmeRequested, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000); + }), + ]); + await page.getByRole('link', { name: 'root' }).click(); + await page.getByText('Updated Fixture Repository').waitFor(); + await page.waitForTimeout(600); + assert.equal(await page.getByText('Documentation').count(), 0); + assert.equal(delayedReadmeRequested, true); + + await page.getByRole('link', { name: 'pixel.png' }).click(); + const image = page.locator('.static-file-image'); + await image.waitFor(); + await image.evaluate(element => { + const imageElement = element; + if (imageElement.complete) return; + return new Promise((resolve, reject) => { + imageElement.addEventListener('load', resolve, { once: true }); + imageElement.addEventListener('error', reject, { once: true }); + }); + }); + assert.equal(await image.evaluate(element => element.naturalWidth), 1); + await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor(); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'BUILD' }).click(); + const buildCode = page.locator('code.language-python'); + await buildCode.waitFor(); + await page.getByText('cc_library').waitFor(); + assert.match(await buildCode.textContent(), /name = "fixture"/); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'src' }).click(); + await page.getByRole('link', { name: 'main.c' }).click(); + await page.getByText('int main(void)').waitFor(); + await page.keyboard.press('Escape'); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => { + await page.getByText('Documentation').waitFor(); + await page.getByRole('link', { name: 'README.md' }).click(); + await page.getByText('Nested README.').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/graph', async page => { + await page.getByText('Commit Graph').waitFor(); + await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip')); + const graphUrl = page.url(); + const rows = page.locator('.graph-row'); + await rows.first().click(); + await page.waitForURL(/\/changeset\/[0-9a-f]+$/); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.waitForFunction(expected => window.location.href === expected, graphUrl); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => { + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor(); + await page.getByText('added', { exact: true }).waitFor(); + await page.getByRole('region', { name: 'Changeset diff' }).waitFor(); + await page.locator('.diff-column-headings').getByText('Before').first().waitFor(); + await page.locator('.diff-column-headings').getByText('After').first().waitFor(); + await page.locator('.diff-left.diff-remove').filter({ hasText: '# Fixture Repository' }).waitFor(); + await page.locator('.diff-right.diff-add').filter({ hasText: '# Updated Fixture Repository' }).waitFor(); + assert.equal( + await page.locator('.diff-left.diff-context').filter({ hasText: '<script>' }).first().textContent(), + '<script>window.__hgWebXss = true</script>', + ); + assert.equal( + await page.locator('.changeset-paper').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.getByRole('button', { name: firstNode.slice(0, 12) }).click(); + await page.waitForFunction( + expectedPath => window.location.pathname === expectedPath, + `/changeset/${firstNode}`, + ); + await page.getByRole('heading', { name: 'Initial fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => { + await page.getByText('Recent Commits').waitFor(); + }); + + await stopProcess(hgServer); + const unavailable = await fetch(`${BASE_URL}/api/repo/list`); + assert.equal(unavailable.status, 502); + assert.equal((await fetch(`${BASE_URL}/`)).status, 200); + } finally { + if (browser) await browser.close(); + await stopProcess(appServer); + await stopProcess(hgServer); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});