Files
twenty/packages/twenty-website/scripts/product-feature-parity.mjs
T
Abdullah. 569d887d1e [Website] Cut over to the rebuilt site (#21825)
Renaming the package so any further PRs directed to the website are
targeted to the reworked code instead of diverging. Once merged, I will
start preparing this for deployment to dev to test before releasing to
prod. Any improvements will also be applied to this package.

I avoided making significant changes to API routes so nothing breaks,
but will test it thoroughly today to confirm. That said, everything is
ported - double checked.

Big diff PR, impossible to review, but last one! No more rebuilds.
2026-06-19 10:22:46 +02:00

455 lines
14 KiB
JavaScript

import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
openPage,
} from './battery-kit.mjs';
// A/B battery for the ProductFeature section: both sites are driven down
// the tile grid and compared on lattice styles, tile content styles, the
// counter format, the entrance fade, and the interactive visuals
// (donut sweep, contacts checkbox + drag-scroll, kanban drag-drop-reorder
// with FLIP).
const OLD_URL = `${OLD_BASE}/product`;
const NEW_URL = `${NEW_BASE}/product`;
const { compare, fail, finish, ok } = createBattery('product-feature-parity');
const browser = await launchBrowser();
async function scrollToText(page, text, settleMs) {
const found = await page.evaluate((needle) => {
// The eyebrow is a span on the old site and a p (house Eyebrow) here.
const target = [...document.querySelectorAll('span, p, h2, h3')].find(
(el) => el.textContent.trim() === needle,
);
if (!target) return false;
target.scrollIntoView({ block: 'center' });
return true;
}, text);
await page.waitForTimeout(settleMs);
return found;
}
// The grid + tile anatomy, read from the counters' shared lattice.
function readSectionAnatomy(page) {
return page.evaluate(() => {
const counters = [...document.querySelectorAll('span')].filter((el) =>
/^\d{2} \/ \d{2}$/.test(el.textContent.trim()),
);
if (counters.length === 0) return null;
let grid = counters[0].parentElement;
while (grid) {
const style = getComputedStyle(grid);
const holdsAll = counters.every((counter) => grid.contains(counter));
if (holdsAll && style.borderTopWidth === '1px') break;
grid = grid.parentElement;
}
const gridStyle = grid ? getComputedStyle(grid) : null;
const cellEdges = grid
? [...grid.children].map((cell) => {
const style = getComputedStyle(cell);
return `${style.borderRightWidth} ${style.borderBottomWidth}`;
})
: [];
let entrance = counters[0].parentElement;
while (entrance) {
const style = getComputedStyle(entrance);
if (
style.transitionProperty.includes('opacity') &&
style.transitionDuration.includes('0.6s')
)
break;
entrance = entrance.parentElement;
}
const entranceStyle = entrance ? getComputedStyle(entrance) : null;
const headerRow = counters[0].parentElement;
const category = headerRow?.querySelector('span');
const categoryStyle = category ? getComputedStyle(category) : null;
const counterStyle = getComputedStyle(counters[0]);
const heading = [...document.querySelectorAll('h3')].find((el) =>
grid?.contains(el),
);
const headingStyle = heading ? getComputedStyle(heading) : null;
return {
counters: counters.map((el) => el.textContent.trim()),
gridBorder: gridStyle
? `${gridStyle.borderTopWidth} ${gridStyle.borderTopColor} r${gridStyle.borderRadius}`
: null,
cellEdges,
entranceTransition: entranceStyle
? `${entranceStyle.transitionProperty} ${entranceStyle.transitionDuration}`
: null,
entranceOpacity: entranceStyle?.opacity ?? null,
categoryStyle: categoryStyle
? `${categoryStyle.fontFamily.slice(0, 24)} ${categoryStyle.fontSize} ${categoryStyle.letterSpacing} ${categoryStyle.textTransform} ${categoryStyle.color}`
: null,
counterStyle: `${counterStyle.fontSize} ${counterStyle.color}`,
headingStyle: headingStyle
? `${headingStyle.fontSize} ${headingStyle.fontWeight} ${headingStyle.color}`
: null,
};
});
}
// The donut's value arc: the dasharrayed circle with the 1.2s sweep.
function readDonutState(page) {
return page.evaluate(() => {
const circle = [...document.querySelectorAll('circle')].find((el) => {
const style = getComputedStyle(el);
return (
style.strokeDasharray !== 'none' &&
style.transitionDuration.includes('1.2s')
);
});
if (!circle) return null;
const style = getComputedStyle(circle);
return {
transition: `${style.transitionProperty} ${style.transitionDuration}`,
dashoffset: Number.parseFloat(style.strokeDashoffset),
};
});
}
// The contacts mini-table root, found from its view header. The hero's
// mockup also shows an "All Companies" view, so the match is scoped to
// the dark feature-card surface (#1d1d25).
const CONTACTS_ROOT = `
const root = [...document.querySelectorAll('span')]
.filter((el) => el.textContent.trim() === 'All Companies')
.map((el) => el.parentElement?.parentElement)
.find(
(candidate) =>
candidate &&
getComputedStyle(candidate).backgroundColor === 'rgb(29, 29, 37)',
);
`;
// "All Companies" first matches the hero viewbar, so scrolling goes
// through the scoped root rather than scrollToText.
async function scrollToContacts(page) {
await page.evaluate(`(() => {
${CONTACTS_ROOT}
root?.scrollIntoView({ block: 'center' });
})()`);
await page.waitForTimeout(1400);
}
function readContactsCheckbox(page) {
return page.evaluate(`(() => {
${CONTACTS_ROOT}
if (!root) return null;
const boxes = [...root.querySelectorAll('div')].filter((el) => {
const style = getComputedStyle(el);
return style.width === '14px' && style.borderRadius === '3px';
});
const rowBox = boxes[1];
if (!rowBox) return null;
const rect = rowBox.getBoundingClientRect();
const style = getComputedStyle(rowBox);
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
background: style.backgroundColor,
borderColor: style.borderTopColor,
};
})()`);
}
function readContactsViewport(page) {
return page.evaluate(`(() => {
${CONTACTS_ROOT}
if (!root) return null;
const viewport = [...root.querySelectorAll('div')].find(
(el) => getComputedStyle(el).overflowX === 'auto',
);
if (!viewport) return null;
const rect = viewport.getBoundingClientRect();
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
scrollLeft: viewport.scrollLeft,
};
})()`);
}
// The pipeline board, found from its header; cards carry OPP- record ids.
function readPipelineState(page) {
return page.evaluate(() => {
const title = [...document.querySelectorAll('span')].find(
(el) => el.textContent.trim() === 'All opportunities',
);
const root = title?.parentElement?.parentElement;
if (!root) return null;
const counts = ['Identified', 'Qualified'].map((label) => {
const pill = [...root.querySelectorAll('span')].find(
(el) => el.textContent.trim() === label,
);
return pill?.nextElementSibling?.textContent.trim() ?? null;
});
const cardRect = (recordId) => {
const cards = [...root.querySelectorAll('div')].filter(
(el) =>
getComputedStyle(el).cursor === 'grab' &&
el.textContent.includes(recordId),
);
const card = cards.at(-1);
if (!card) return null;
const rect = card.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
return {
counts,
github: cardRect('OPP-1'),
airbnb: cardRect('OPP-8'),
};
});
}
function countFlipAnimations(page) {
return page.evaluate(
() =>
document
.getAnimations()
.filter(
(animation) =>
animation.constructor.name === 'Animation' &&
animation.playState === 'running',
).length,
);
}
const oldPage = await openPage(browser, OLD_URL);
const newPage = await openPage(browser, NEW_URL);
const pages = [
['old', oldPage],
['new', newPage],
];
// --- Section anatomy + lattice + entrance + tile styles ---------------
const anatomies = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
const found = await scrollToText(page, 'Core Features', 1100);
if (!found) fail(`feature section present on ${name} site`, 'no eyebrow');
// eslint-disable-next-line no-await-in-loop
anatomies[name] = await readSectionAnatomy(page);
}
if (!anatomies.old || !anatomies.new) {
fail(
'feature tiles readable on both sites',
`old=${Boolean(anatomies.old)} new=${Boolean(anatomies.new)}`,
);
} else {
compare(
'tile counters match',
anatomies.old.counters,
anatomies.new.counters,
);
compare(
'grid lattice border',
anatomies.old.gridBorder,
anatomies.new.gridBorder,
);
compare(
'cell edge lattice',
anatomies.old.cellEdges,
anatomies.new.cellEdges,
);
compare(
'entrance fade transition',
anatomies.old.entranceTransition,
anatomies.new.entranceTransition,
);
compare(
'entrance settled opacity',
anatomies.old.entranceOpacity,
anatomies.new.entranceOpacity,
);
compare(
'category label style',
anatomies.old.categoryStyle,
anatomies.new.categoryStyle,
);
compare(
'counter style',
anatomies.old.counterStyle,
anatomies.new.counterStyle,
);
compare(
'tile heading style',
anatomies.old.headingStyle,
anatomies.new.headingStyle,
);
}
// --- Donut sweep (spotlight active state) ------------------------------
const donuts = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollToText(page, 'Sales performances', 1700);
// eslint-disable-next-line no-await-in-loop
donuts[name] = await readDonutState(page);
}
if (!donuts.old || !donuts.new) {
fail(
'donut arc present on both sites',
`old=${Boolean(donuts.old)} new=${Boolean(donuts.new)}`,
);
} else {
compare(
'donut sweep transition',
donuts.old.transition,
donuts.new.transition,
);
if (Math.abs(donuts.old.dashoffset - donuts.new.dashoffset) <= 1) {
ok(
'donut sweep settles at the same arc',
`old ${donuts.old.dashoffset.toFixed(1)} vs new ${donuts.new.dashoffset.toFixed(1)}`,
);
} else {
fail(
'donut sweep settles at the same arc',
`old ${donuts.old.dashoffset} vs new ${donuts.new.dashoffset}`,
);
}
}
// --- Contacts: checkbox + drag-scroll ----------------------------------
const checkboxes = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollToContacts(page);
// eslint-disable-next-line no-await-in-loop
const before = await readContactsCheckbox(page);
if (!before) {
fail(`contacts checkbox found on ${name} site`, 'no 14px box');
continue;
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.click(before.x, before.y);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(250);
// eslint-disable-next-line no-await-in-loop
checkboxes[name] = { before, after: await readContactsCheckbox(page) };
}
if (checkboxes.old && checkboxes.new) {
compare(
'checkbox unchecked chrome',
`${checkboxes.old.before.background} ${checkboxes.old.before.borderColor}`,
`${checkboxes.new.before.background} ${checkboxes.new.before.borderColor}`,
);
compare(
'checkbox checked chrome',
`${checkboxes.old.after.background} ${checkboxes.old.after.borderColor}`,
`${checkboxes.new.after.background} ${checkboxes.new.after.borderColor}`,
);
if (checkboxes.new.before.background !== checkboxes.new.after.background) {
ok('checkbox toggles on click');
} else {
fail('checkbox toggles on click', 'background unchanged');
}
}
const scrolls = {};
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
const viewport = await readContactsViewport(page);
if (!viewport) {
fail(`contacts viewport found on ${name} site`, 'no overflow-x element');
continue;
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(viewport.x, viewport.y);
// eslint-disable-next-line no-await-in-loop
await page.mouse.down();
for (const step of [40, 80, 120, 160]) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(viewport.x - step, viewport.y);
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.up();
// eslint-disable-next-line no-await-in-loop
const after = await readContactsViewport(page);
scrolls[name] = { from: viewport.scrollLeft, to: after?.scrollLeft ?? null };
}
if (scrolls.old && scrolls.new) {
if (scrolls.old.to > scrolls.old.from && scrolls.new.to > scrolls.new.from) {
ok(
'contacts table drag-scrolls on both sites',
`old +${scrolls.old.to - scrolls.old.from}px, new +${scrolls.new.to - scrolls.new.from}px`,
);
} else {
fail(
'contacts table drag-scrolls on both sites',
`old ${JSON.stringify(scrolls.old)} vs new ${JSON.stringify(scrolls.new)}`,
);
}
}
// --- Pipeline: drag-drop-reorder + FLIP --------------------------------
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollToText(page, 'All opportunities', 1400);
// eslint-disable-next-line no-await-in-loop
const before = await readPipelineState(page);
if (!before?.github || !before.airbnb) {
fail(`pipeline cards found on ${name} site`, JSON.stringify(before));
continue;
}
compare(`pipeline lane counts at rest (${name})`, before.counts, ['2', '2']);
const grabX = before.github.x + before.github.width / 2;
const grabY = before.github.y + 12;
// Drop above Airbnb's midpoint: lane 2, index 0 — both resident cards
// get displaced, so the FLIP animations must fire.
const dropX = before.airbnb.x + before.airbnb.width / 2;
const dropY = before.airbnb.y + 5;
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(grabX, grabY);
// eslint-disable-next-line no-await-in-loop
await page.mouse.down();
for (const step of [0.25, 0.5, 0.75, 1]) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(
grabX + (dropX - grabX) * step,
grabY + (dropY - grabY) * step,
);
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.up();
// eslint-disable-next-line no-await-in-loop
const flipCount = await countFlipAnimations(page);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(450);
// eslint-disable-next-line no-await-in-loop
const after = await readPipelineState(page);
if (after && after.counts[0] === '1' && after.counts[1] === '3') {
ok(`pipeline drop reorders lanes on ${name} site`, '2/2 → 1/3');
} else {
fail(
`pipeline drop reorders lanes on ${name} site`,
`counts ${JSON.stringify(after?.counts)}`,
);
}
if (flipCount >= 1) {
ok(`displaced cards FLIP on ${name} site`, `${flipCount} running`);
} else {
fail(`displaced cards FLIP on ${name} site`, 'no WAAPI animation running');
}
}
await finish(browser);