waybetter-for-x/apps/desktop/scripts/pane-nav-check.mjs
2026-10-01 06:01:13 +07:00

150 lines
7.8 KiB
JavaScript

// Split-view navigation check: the real pane-nav runtime (src/inject/paneNav.ts) over the static
// x.com fixtures of the theme matrix, in Chromium, at split-pane widths. Per width it asserts that a
// redundant pane parks X's rail off-screen and out of the layout, re-centers the main column, slides
// the rail in after resting on the left edge (not while crossing it), and parks it again after the
// pointer leaves; a lead pane (redundantNav false) and a disabled feature leave X untouched.
// Screenshots go to verification/pane-nav/. The fixtures approximate x.com: not a live X test.
//
// node scripts/pane-nav-check.mjs
import { build } from 'esbuild';
import { mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { join } from 'node:path';
import { chromium } from 'playwright';
import { fixtureHtml } from './theme-matrix/fixtures.mjs';
const root = fileURLToPath(new URL('../', import.meta.url));
const out = join(root, '../../verification/pane-nav');
await mkdir(out, { recursive: true });
const bundle = (await build({
stdin: { contents: `export { installPaneNav } from '${process.env.PANE_NAV_SRC ?? './src/inject/paneNav.ts'}';`, resolveDir: root, loader: 'ts' },
bundle: true, format: 'iife', globalName: 'BTPane', write: false, target: 'chrome120',
})).outputFiles[0].text;
const failures = [];
const check = (label, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
if (!ok) failures.push(label);
};
const browser = await chromium.launch();
// The runtime is installed like the app does it: an init script, before the parser creates <html>.
const context = await browser.newContext({ viewport: { width: 800, height: 760 } });
await context.addInitScript({ content: `${bundle};window.pane = BTPane.installPaneNav(window);` });
const page = await context.newPage();
// X's classic rail (header[role=banner]) and a plain-div rail, the fallback path findRail() marks itself.
const html = fixtureHtml('home', 'dark');
const VARIANTS = {
header: { html, rail: 'header[role="banner"]' },
div: {
html: html.replace('<header role="banner" class="css-175oi2r"', '<div class="css-175oi2r fx-rail"').replace('</header>', '</div>')
.replace(`document.querySelector('header[role="banner"]')`, `document.querySelector('.fx-rail')`),
rail: '.fx-rail',
},
};
if (VARIANTS.div.html.includes('role="banner"')) throw new Error('div variant still has a banner header');
let railSelector = '';
const railBox = () => page.evaluate(selector => {
const rail = document.querySelector(selector);
const box = rail.getBoundingClientRect();
const style = getComputedStyle(rail);
return { left: box.left, right: box.right, width: box.width, position: style.position, visibility: style.visibility };
}, railSelector);
const columnGap = () => page.evaluate(() => {
const row = document.querySelector('main[role="main"] > div').getBoundingClientRect();
return { left: Math.round(row.left), right: Math.round(innerWidth - row.right) };
});
const state = () => page.evaluate(() => document.documentElement.getAttribute('data-bt-pane-nav'));
const setMenu = open => page.evaluate(open => {
let layers = document.getElementById('layers');
if (!layers) { layers = document.createElement('div'); layers.id = 'layers'; document.body.appendChild(layers); }
layers.querySelector('.fake-menu')?.remove();
if (open) layers.insertAdjacentHTML('beforeend', '<div class="fake-menu" role="menu" style="position:fixed;right:40px;top:120px;width:200px;height:80px"></div>');
}, open);
for (const [variant, width] of [['header', 800], ['header', 1100], ['header', 1400], ['div', 800], ['div', 1400]]) {
const label = `${variant} ${width}`;
const file = join(out, `fixture-${variant}.html`);
railSelector = VARIANTS[variant].rail;
await writeFile(file, VARIANTS[variant].html);
await page.setViewportSize({ width, height: 760 });
await page.goto(pathToFileURL(file).href);
const before = await railBox();
await page.screenshot({ path: join(out, `${variant}-${width}-0-lead.png`) });
await page.evaluate(() => window.pane.set({ redundantNav: false }));
check(`${label}: lead pane untouched`, (await state()) === null && (await railBox()).position !== 'fixed');
await page.evaluate(() => window.pane.set({ redundantNav: true }));
await page.waitForTimeout(350);
const parked = await railBox();
const gap = await columnGap();
check(`${label}: rail parked off-screen`, parked.position === 'fixed' && parked.right <= 1 && parked.visibility === 'hidden', JSON.stringify(parked));
check(`${label}: rail keeps X's column width (${before.width > 300 ? 'flex header' : 'icon column'})`, parked.width >= 60 && parked.width <= 320, `width ${parked.width}`);
check(`${label}: main column centered in the freed width`, Math.abs(gap.left - gap.right) <= 2, JSON.stringify(gap));
await page.screenshot({ path: join(out, `${variant}-${width}-1-parked.png`) });
// Crossing the edge quickly does not open it.
await page.mouse.move(400, 300);
await page.mouse.move(4, 300);
await page.mouse.move(400, 300);
await page.waitForTimeout(250);
check(`${label}: crossing the edge does not peek`, (await state()) === 'hidden');
await page.mouse.move(4, 300);
await page.waitForTimeout(450);
const peek = await railBox();
check(`${label}: resting on the edge slides the rail in`, (await state()) === 'peek' && Math.abs(peek.left) <= 1 && peek.visibility === 'visible', JSON.stringify(peek));
const overlay = await columnGap();
check(`${label}: slid-in rail overlays, the column does not move`, overlay.left === gap.left, JSON.stringify(overlay));
await page.mouse.move(40, 300);
await page.waitForTimeout(600);
check(`${label}: stays while the pointer is on the rail`, (await state()) === 'peek');
await page.screenshot({ path: join(out, `${variant}-${width}-2-peek.png`) });
await page.mouse.move(Math.round(width * 0.6), 300);
await page.waitForTimeout(700);
check(`${label}: leaves after the pointer does`, (await state()) === 'hidden' && (await railBox()).visibility === 'hidden');
await page.mouse.move(4, 300);
await page.waitForTimeout(450);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
check(`${label}: Escape parks it`, (await state()) === 'hidden');
await page.mouse.move(Math.round(width * 0.6), 300);
// A menu somewhere else on the page (a post's ··· menu) must not hold the rail out.
await page.mouse.move(4, 300);
await page.waitForTimeout(450);
await page.mouse.move(Math.round(width * 0.6), 300, { steps: 12 });
await setMenu(true);
await page.waitForTimeout(700);
check(`${label}: an unrelated open menu does not hold the rail`, (await state()) === 'hidden');
await setMenu(false);
// One the rail opened (More, account switcher) does, until it closes.
await page.mouse.move(4, 300);
await page.waitForTimeout(450);
await page.mouse.move(40, 740);
await page.mouse.down();
await page.mouse.up();
await setMenu(true);
await page.mouse.move(Math.round(width * 0.6), 300);
await page.waitForTimeout(700);
check(`${label}: the rail's own menu holds it`, (await state()) === 'peek');
await setMenu(false);
await page.waitForTimeout(500);
check(`${label}: parks once that menu closes`, (await state()) === 'hidden');
await page.evaluate(() => window.pane.setEnabled(false));
const restored = await railBox();
check(`${label}: feature off restores X's layout`, (await state()) === null && restored.position !== 'fixed' && Math.abs(restored.width - before.width) <= 1, JSON.stringify(restored));
check(`${label}: edge and style removed`, await page.evaluate(() => !document.getElementById('bt-rail-edge') && !document.getElementById('bt-pane-nav')));
}
await browser.close();
console.log(failures.length ? `\n${failures.length} failed` : '\nall passed');
process.exit(failures.length ? 1 : 0);