569d887d1e
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.
455 lines
14 KiB
JavaScript
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);
|