Files
twenty/packages/twenty-website/scripts/product-stepper-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

323 lines
11 KiB
JavaScript

import {
createBattery,
launchBrowser,
NEW_BASE,
OLD_BASE,
openPage,
} from './battery-kit.mjs';
// A/B battery for the product stepper: section chrome, the sticky scroll
// choreography at pinned positions, the visual crossfade, and the three
// interactive editors (entity drag, workflow beat, layout toggle+reorder).
const HEADING = 'Go the extra mile';
const { compare, fail, finish, ok } = createBattery('product-stepper-parity');
// The old site tints the stepper section with 5% black over white; ours
// bakes the same color as a solid token. Composite before comparing.
const compositeOverWhite = (cssColor) => {
const match = /rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)/.exec(
cssColor ?? '',
);
if (!match) return cssColor;
const alpha = match[4] === undefined ? 1 : Number(match[4]);
const channel = (value) =>
Math.round(Number(value) * alpha + 255 * (1 - alpha));
return `rgb(${channel(match[1])}, ${channel(match[2])}, ${channel(match[3])})`;
};
const browser = await launchBrowser();
// Scrolls the stepper section to a fraction of its own scrollable track.
async function scrollSectionTo(page, trackFraction) {
await page.evaluate(
({ needle, fraction }) => {
const heading = [...document.querySelectorAll('h2')].find((el) =>
el.textContent.includes(needle),
);
const section = heading?.closest('section');
if (!section) return;
const rect = section.getBoundingClientRect();
const top = rect.top + window.scrollY;
const scrollable = section.offsetHeight - window.innerHeight;
window.scrollTo(0, top + fraction * Math.max(scrollable, 0));
},
{ needle: HEADING, fraction: trackFraction },
);
await page.waitForTimeout(700);
}
function readStepperState(page) {
return page.evaluate((needle) => {
const heading = [...document.querySelectorAll('h2')].find((el) =>
el.textContent.includes(needle),
);
const section = heading?.closest('section');
if (!section) return null;
const sectionStyle = getComputedStyle(section);
const headingStyle = getComputedStyle(heading);
// Step rows: each holds a 22px icon box; read its block's inline
// opacity/transform vars or styles.
const stepLabels = ['Data model', 'Automation', 'Layout'];
const steps = stepLabels.map((label) => {
const labelNode = [...section.querySelectorAll('span, h3, div')].find(
(el) => el.childElementCount === 0 && el.textContent.trim() === label,
);
let block = labelNode;
while (block && block.parentElement !== null) {
const style = getComputedStyle(block);
if (style.transitionProperty.includes('opacity')) break;
block = block.parentElement;
}
const blockStyle = block ? getComputedStyle(block) : null;
const iconBox = labelNode?.parentElement?.querySelector('div');
return {
opacity: blockStyle ? Number(blockStyle.opacity).toFixed(2) : null,
moving: blockStyle ? blockStyle.transform !== 'none' : null,
iconBoxBackground: iconBox
? getComputedStyle(iconBox).backgroundColor
: null,
};
});
// The visual slides: absolute crossfading layers inside the frame.
const slides = [...section.querySelectorAll('div')].filter((el) => {
const style = getComputedStyle(el);
return (
style.position === 'absolute' &&
style.transitionProperty.includes('opacity') &&
style.transitionDuration.includes('0.4s') &&
el.querySelector('svg')
);
});
const frame = [...section.querySelectorAll('div')].find((el) => {
const ratio = el.offsetWidth / el.offsetHeight;
return el.querySelector('img') && Math.abs(ratio - 672 / 705) < 0.01;
});
const frameImages = frame
? [...frame.querySelectorAll('img')].map((img) => img.naturalWidth > 0)
: [];
return {
sectionBackground: sectionStyle.backgroundColor,
headingStyle: `${headingStyle.fontSize} ${headingStyle.fontWeight}`,
steps,
slideOpacities: slides.map((el) => Number(getComputedStyle(el).opacity)),
frameAspectOk: Boolean(frame),
frameImages,
};
}, HEADING);
}
// Finds a draggable card by its label inside the active slide and returns
// its center plus the connector layer's path data for follow checks.
function readDraggable(page, labelText) {
return page.evaluate((label) => {
const node = [...document.querySelectorAll('span, div')].find(
(el) => el.childElementCount === 0 && el.textContent.trim() === label,
);
let card = node;
while (card) {
const style = getComputedStyle(card);
if (style.position === 'absolute' && style.cursor === 'grab') break;
card = card.parentElement;
}
if (!card) return null;
const rect = card.getBoundingClientRect();
const svg = card.parentElement?.querySelector('svg');
return {
x: rect.x + rect.width / 2,
y: rect.y + 10,
left: rect.x,
paths: svg
? [...svg.querySelectorAll('path')].map((p) => p.getAttribute('d'))
: [],
};
}, labelText);
}
const oldPage = await openPage(browser, `${OLD_BASE}/product`);
const newPage = await openPage(browser, `${NEW_BASE}/product`);
const pages = [
['old', oldPage],
['new', newPage],
];
// --- Step 1: section chrome + initial choreography ---------------------
await scrollSectionTo(oldPage, 0.05);
await scrollSectionTo(newPage, 0.05);
const oldStart = await readStepperState(oldPage);
const newStart = await readStepperState(newPage);
if (!oldStart || !newStart) {
fail(
'stepper present on both sites',
`old=${Boolean(oldStart)} new=${Boolean(newStart)}`,
);
} else {
compare(
'section background (composited)',
compositeOverWhite(oldStart.sectionBackground),
compositeOverWhite(newStart.sectionBackground),
);
compare('heading style', oldStart.headingStyle, newStart.headingStyle);
compare(
'step choreography at start',
oldStart.steps.map((step) => `${step.opacity}|${step.moving}`),
newStart.steps.map((step) => `${step.opacity}|${step.moving}`),
);
compare(
'active step icon box ink',
oldStart.steps[0].iconBoxBackground,
newStart.steps[0].iconBoxBackground,
);
compare(
'first slide active, rest hidden',
oldStart.slideOpacities,
newStart.slideOpacities,
);
if (newStart.frameAspectOk && newStart.frameImages.every(Boolean)) {
ok('frame holds 672/705 with both artwork images loaded');
} else {
fail(
'frame holds 672/705 with both artwork images loaded',
JSON.stringify(newStart),
);
}
}
// --- Mid-track: second step takes over ----------------------------------
await scrollSectionTo(oldPage, 0.5);
await scrollSectionTo(newPage, 0.5);
const oldMid = await readStepperState(oldPage);
const newMid = await readStepperState(newPage);
compare(
'step choreography mid-track',
oldMid.steps.map((step) => `${step.opacity}|${step.moving}`),
newMid.steps.map((step) => `${step.opacity}|${step.moving}`),
);
compare(
'second slide active mid-track',
oldMid.slideOpacities,
newMid.slideOpacities,
);
// Workflow beat: while the second slide is active, check badges tick in.
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(2600);
// eslint-disable-next-line no-await-in-loop
const visibleChecks = await page.evaluate(() => {
return [...document.querySelectorAll('span')].filter((el) => {
const style = getComputedStyle(el);
return (
style.width === '12px' &&
style.height === '12px' &&
Number(style.opacity) === 1 &&
el.querySelector('svg')
);
}).length;
});
if (visibleChecks >= 1) {
ok(`workflow beat ticks check badges on ${name} site`, `${visibleChecks}`);
} else {
fail(`workflow beat ticks check badges on ${name} site`, '0 visible');
}
}
// --- Back to step 1: entity drag + edges follow -------------------------
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollSectionTo(page, 0.05);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(600);
// eslint-disable-next-line no-await-in-loop
const before = await readDraggable(page, 'Workspaces');
if (!before) {
fail(`entity card found on ${name} site`, 'no Workspaces card');
continue;
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(before.x, before.y);
// eslint-disable-next-line no-await-in-loop
await page.mouse.down();
for (const step of [10, 25, 40]) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.move(before.x + step, before.y + step);
}
// eslint-disable-next-line no-await-in-loop
await page.mouse.up();
// eslint-disable-next-line no-await-in-loop
const after = await readDraggable(page, 'Workspaces');
const moved = after && Math.abs(after.left - before.left) >= 30;
const edgesFollowed =
after && JSON.stringify(after.paths) !== JSON.stringify(before.paths);
if (moved && edgesFollowed) {
ok(`entity drag moves card and edges follow on ${name} site`);
} else {
fail(
`entity drag moves card and edges follow on ${name} site`,
`moved=${moved} edges=${edgesFollowed}`,
);
}
}
// --- Step 3: layout visibility toggle ------------------------------------
for (const [name, page] of pages) {
// eslint-disable-next-line no-await-in-loop
await scrollSectionTo(page, 0.85);
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(700);
// eslint-disable-next-line no-await-in-loop
const eye = await page.evaluate(() => {
// The hidden ICP field renders the slashed eye (a 3-13 diagonal line).
const slashed = [...document.querySelectorAll('svg')].find((svg) =>
[...svg.querySelectorAll('path')].some(
(path) => path.getAttribute('d') === 'M3 3l10 10',
),
);
if (!slashed) return null;
const rect = slashed.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
});
if (!eye) {
fail(`hidden-field eye found on ${name} site`, 'no slashed eye glyph');
continue;
}
// The field row captures the pointer on press (drag affordance), which
// retargets synthesized clicks on both sites — drive the button itself.
// eslint-disable-next-line no-await-in-loop
await page.evaluate(() => {
const slashed = [...document.querySelectorAll('svg')].find((svg) =>
[...svg.querySelectorAll('path')].some(
(path) => path.getAttribute('d') === 'M3 3l10 10',
),
);
slashed?.closest('span, div')?.click();
});
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(250);
// eslint-disable-next-line no-await-in-loop
const slashedCount = await page.evaluate(
() =>
[...document.querySelectorAll('svg')].filter((svg) =>
[...svg.querySelectorAll('path')].some(
(path) => path.getAttribute('d') === 'M3 3l10 10',
),
).length,
);
if (slashedCount === 0) {
ok(`layout eye toggle reveals the hidden field on ${name} site`);
} else {
fail(
`layout eye toggle reveals the hidden field on ${name} site`,
`${slashedCount} slashed eyes remain`,
);
}
}
await finish(browser);