Skip to content

Instantly share code, notes, and snippets.

@NoriSte
Last active February 2, 2024 13:45
Show Gist options
  • Save NoriSte/f68599746c0fcb5a0162c0cc086c7a44 to your computer and use it in GitHub Desktop.
Save NoriSte/f68599746c0fcb5a0162c0cc086c7a44 to your computer and use it in GitHub Desktop.
DS coverage script

DS Coverage raw script

🚨🚨🚨 UPDATE: index.js is the original script while index.ts is the most updated one! 🚨🚨

Keep in mind the script is an experiment, I need to add types, refine it, make it readable, etc.

  1. Open a Preply page (whatever environment)
  2. Copy/paste it as is in the Chrome's console and press enter
  3. Look at the logged result
image
;(() => {
const fakeRect = { top: -1, left: -1, width: -1, height: -1 }
const emptyPixel = ' ' // useful to print out the array with monospaced fonts
const pixelMarkerByComponentType = {
nonDsComponent: '0',
layoutComponent: '1',
utilComponent: '2',
outdatedComponent: '3',
leafComponent: '4',
unknownDsComponent: '5',
rebrandComponent: '9',
}
const colorByComponentType = {
nonDsComponent: '#FF0000',
layoutComponent: '#00FF00',
utilComponent: '#00FF00',
outdatedComponent: '#00FF00',
leafComponent: '#00FF00',
unknownDsComponent: '#00FF00',
rebrandComponent: '#FF0000',
}
const componentTypeByPixelMarker = {
0: 'nonDsComponent',
1: 'layoutComponent',
2: 'utilComponent',
3: 'outdatedComponent',
4: 'leafComponent',
5: 'unknownDsComponent',
9: 'rebrandComponent',
}
function getDsComponentType(componentName) {
if (!componentName) return 'nonDsComponent'
if (componentName.startsWith('Rebrand')) {
return 'rebrandComponent'
}
switch (componentName) {
case 'Box':
case 'LayoutFlex':
case 'LayoutFlexItem':
case 'LAYOUT_GRID':
case 'LayoutGrid':
case 'LayoutGridItem':
return 'layoutComponent'
case 'ObserverIntersection':
return 'utilComponent'
case 'Panel':
return 'outdatedComponent'
case 'Avatar':
case 'AvatarWithStatus':
case 'Badge':
case 'Button':
case 'FieldButton':
case 'FieldLayout':
case 'Heading':
case 'Icon':
case 'IconButton':
case 'Link':
case 'Loader':
case 'NumberField':
case 'PasswordField':
case 'PreplyLogo':
case 'SelectField':
case 'Text':
case 'TextField':
case 'TextHighlighted':
case 'TextInline':
case 'TextareaField':
case 'Checkbox':
case 'InputDate':
case 'InputNumber':
case 'InputPassword':
case 'InputText':
case 'InputTime':
case 'Radio':
case 'Select':
case 'Textarea':
case 'SelectFieldLayout':
return 'leafComponent'
default:
return 'unknownDsComponent'
}
}
// Possible optimization:
// - parse the tree depth by depth instead of going through the whole tree at once (since this is hard to split over different frames).The only important thing is that deeper elements are parsed after less deep ones
// - otherwise, I can just use generators
function loopOverDomChildren({
domElement,
childrenToMeasure = [],
isChildOfLeafDsComponent = false,
onComplete,
isRootLoop = true,
}) {
const children = domElement.childNodes
for (let i = 0, n = children.length; i < n; i++) {
const child = children[i]
// Exclude non-visible children
if (
child.nodeType !== Node.ELEMENT_NODE ||
child.getAttribute('data-preply-ds-coverage') !== null || // must stop when encounter other containers. TODO: also add a data-preply-ds-coverage-ignore attribute for external components
globalThis.getComputedStyle(child).display === 'none'
)
continue
//
const dsAttribute = child.getAttribute('data-preply-ds-component')
const dsComponentType = getDsComponentType(dsAttribute)
const isLeafDsComponent = dsComponentType === 'leafComponent'
childrenToMeasure.push({
child,
isChildOfLeafDsComponent,
dsComponentType,
rect: fakeRect,
})
loopOverDomChildren({
domElement: child,
childrenToMeasure,
isChildOfLeafDsComponent: isLeafDsComponent || isChildOfLeafDsComponent,
onComplete,
isRootLoop: false,
})
}
if (isRootLoop) {
onComplete({ children: childrenToMeasure })
}
}
function addBoundingRect({ children, startAt = 0, onComplete }) {
for (let i = startAt, n = children.length; i < n; i++) {
const { child } = children[i]
let rect = child.getBoundingClientRect()
children[i].rect = rect
}
onComplete({ children })
}
function countPixels({
deadline,
children,
startAt = 0,
offset,
arr,
onComplete,
svgRenderer,
pixels = {
nonDsComponent: 0,
rebrandComponent: 0,
layoutComponent: 0,
utilComponent: 0,
outdatedComponent: 0,
leafComponent: 0,
unknownDsComponent: 0,
},
}) {
for (let i = startAt, n = children.length, iterations = 0; i < n; i++, iterations++) {
if (deadline.timeRemaining() <= 0) {
requestIdleCallback((deadline) =>
countPixels({
deadline,
children,
startAt: i,
offset,
pixels,
arr,
onComplete,
svgRenderer,
})
)
return
}
const { rect, dsComponentType, isChildOfLeafDsComponent } = children[i]
const adjustedSsComponentType = isChildOfLeafDsComponent
? 'leafComponent' // children of leaf components are treated as leaf components
: dsComponentType
if (svgRenderer) {
// TODO: identify SSR vs browser
const svgRect = globalThis.document.createElementNS('http://www.w3.org/2000/svg', 'rect')
svgRect.setAttribute('x', rect.left - offset.left)
svgRect.setAttribute('y', rect.top - offset.top)
svgRect.setAttribute('width', rect.width)
svgRect.setAttribute('height', rect.height)
svgRect.setAttribute('fill', 'none')
svgRect.setAttribute('stroke-width', '2')
svgRect.setAttribute('opacity', '1')
svgRect.setAttribute('stroke', colorByComponentType[adjustedSsComponentType])
svgRenderer.appendChild(svgRect)
}
// const green = isDsComponent || isChildOfLeafDsComponent
// svgRect.setAttribute('stroke', green ? '#00FF00' : '#FF0000')
const pixelMarker = pixelMarkerByComponentType[adjustedSsComponentType]
let offsetTop = offset.top
let offsetLeft = offset.left
let rectTop = rect.top
let rectLeft = rect.left
let rectWidth = rect.width
let rectHeight = rect.height
const columnLength = arr[0].length
const rowLength = arr.length
for (
let firstRow = Math.floor(rectTop - offsetTop),
lastRow = Math.floor(rectTop - offsetTop + rectHeight - 1),
column = Math.floor(rectLeft - offsetLeft);
column < rectLeft - offsetLeft + rectWidth - 1 && column < columnLength;
column++
) {
if (column < 0) continue
if (firstRow >= 0 && firstRow < rowLength && column < columnLength) {
if (arr[firstRow][column] !== emptyPixel) {
const componentType = componentTypeByPixelMarker[arr[firstRow][column]]
pixels[componentType]--
}
arr[firstRow][column] = pixelMarker
pixels[adjustedSsComponentType]++
}
if (lastRow >= 0 && lastRow < rowLength && column < columnLength) {
if (arr[lastRow][column] !== emptyPixel) {
const componentType = componentTypeByPixelMarker[arr[lastRow][column]]
pixels[componentType]--
}
arr[lastRow][column] = pixelMarker
pixels[adjustedSsComponentType]++
}
// console.log({ firstRow, lastRow, column })
}
for (
let firstColumn = Math.floor(rectLeft - offsetLeft),
lastColumn = Math.floor(rectLeft - offsetLeft + rectWidth - 1),
row = Math.floor(rectTop - offsetTop);
row < rectTop - offsetTop + rectHeight - 1 && row < rowLength;
row++
) {
if (row < 0) continue
if (firstColumn >= 0 && row < rowLength && firstColumn < columnLength) {
if (arr[row][firstColumn] !== emptyPixel) {
const componentType = componentTypeByPixelMarker[arr[row][firstColumn]]
pixels[componentType]--
}
arr[row][firstColumn] = pixelMarker
pixels[adjustedSsComponentType]++
}
if (lastColumn >= 0 && row < rowLength && lastColumn < columnLength) {
if (arr[row][lastColumn] !== emptyPixel) {
const componentType = componentTypeByPixelMarker[arr[row][lastColumn]]
pixels[componentType]--
}
arr[row][lastColumn] = pixelMarker
pixels[adjustedSsComponentType]++
}
}
}
onComplete({ arr, pixels })
}
// function createProxyArray({ width, height }) {
// const arr = new Array(height).fill().map(() => new Array(width).fill(emptyPixel))
// // This handler intercepts the get operation and logs it.
// let handler = {
// get(target, propKey) {
// // console.log(target, propKey)
// return target[propKey]
// },
// set(obj, prop, value) {
// if (prop === undefined) throw new Error('prop is undefined')
// obj[prop] = value
// // Indicate success
// return true
// },
// }
// // Apply proxy to every single array
// for (let i = 0; i < arr.length; i++) {
// arr[i] = new Proxy(arr[i], handler)
// }
// return arr
// }
// let proxyArray = createProxyArray()
function run({ domElement, onComplete, svgRenderer, meta }) {
const start = Date.now() // performance.now is more precise but takes more time to be executed
let loopOverDomChildrenTime = 0
let addBoundingRectTime = 0
let countPixelsTime = 0
let step = start
requestIdleCallback(() => {
loopOverDomChildren({
domElement,
onComplete: ({ children }) => {
const stepBefore = step
step = Date.now() // performance.now is more precise but takes more time to be executed
loopOverDomChildrenTime = step - stepBefore
// console.info({ loopOverDomChildrenTime })
addBoundingRect({
children,
onComplete: ({ children }) => {
const stepBefore = step
step = Date.now() // performance.now is more precise but takes more time to be executed
addBoundingRectTime = step - stepBefore
// console.info({ addBoundingRectTime })
const elementRect = domElement.getBoundingClientRect()
// const elementRect = { top: 0, left: 0, width: 10, height: 10 }
// children = [
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'nonDsComponent',
// rect: { top:0, left: 0, width: 10, height: 10 },
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'layoutComponent',
// rect: { top: 1, left: 1, width: 8, height: 8 },
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'utilComponent',
// rect: { top: 2, left: 2, width: 6, height: 6 },
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'outdatedComponent',
// rect: { top: 3, left: 3, width: 4, height: 4 },
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'leafComponent',
// rect: { top: 4, left: 4, width: 2, height: 2 },
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'unknownDsComponent',
// rect: { top: 1, left: 1, width: 8, height: 8 },
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'rebrandComponent',
// rect: { top: 1, left: 1, width: 8, height: 8 },
// },
// // 2: 'utilComponent',
// // 3: 'outdatedComponent',
// // 4: 'leafComponent',
// // 5: 'unknownDsComponent',
// // 9: 'rebrandComponent',
// ]
const width = Math.floor(elementRect.width)
const height = Math.floor(elementRect.height)
const arr = new Array(height).fill().map(() => new Array(width).fill(emptyPixel))
// const arr = createProxyArray({ width, height })
// console.log({ arr })
const offset = {
left: elementRect.left,
top: elementRect.top,
}
if (svgRenderer) {
svgRenderer.style.top = offset.top + 'px'
svgRenderer.style.left = offset.left + 'px'
}
requestIdleCallback((deadline) => {
countPixels({
deadline,
children,
offset,
arr,
svgRenderer,
onComplete: ({ arr, pixels }) => {
const stepBefore = step
step = Date.now() // performance.now is more precise but takes more time to be executed
countPixelsTime = step - stepBefore
// console.info({ countPixelsTime })
const totalTime = Date.now() - start // performance.now is more precise but takes more time to be executed - start
// console.log({ greenPixels, redPixels })
// console.log((greenPixels / (greenPixels + redPixels)) * 100, '%')
onComplete({
analyzedDomElementsCount: children.length,
pixels,
duration: {
loopOverDomChildrenTime,
addBoundingRectTime,
countPixelsTime,
totalTime,
},
meta,
arr,
})
// alert(
// `${loopOverDomChildrenTime} - ${addBoundingRectTime} - ${countPixelsTime}`
// )
// console.log((greenPixels / (greenPixels + redPixels)) * 100)
// console.log(total)
// console.log({ iterations })
},
})
})
},
})
},
})
})
// console.log({ elementRect })
// Create an SVG to show the coverage
let svg = {}
// let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
// svg.setAttribute('width', width)
// svg.setAttribute('height', height)
// svg.style.position = 'absolute'
// svg.style.top = offset.top + 'px'
// svg.style.left = offset.left + 'px'
// Create an array to store the coverage pixels
// drawRectangles(offset, domElement, svg, arr)
// Append SVG
// domElement.appendChild(svg)
// return arr
}
function shouldRun() {
const now = Date.now()
// Convert the time to minutes
const totalMinutes = Math.floor(now / 1000 / 60)
// Get the current minute of the hour
const currentMinute = totalMinutes % 60
// By default, let's run the coverage at the end of the hour, when the previous lesson is over and the next lesson is about to start
const defaultMinutes = 58
const runDuringLastHourMinutes =
// Allow setting the minutes externally
globalThis.dsCoverageLastHourMinutes ?? defaultMinutes
return currentMinute > 60 - runDuringLastHourMinutes
}
function queueDsCoverage() {
function tick() {
// TODO: identify SSR vs browser
const isSupposedToBePowerful = globalThis.document.documentElement.clientWidth >= 1280 || true
// For the initial implementation, this allows to
// run loopOverDomChildren and addBoundingRect without interruptions
if (!isSupposedToBePowerful) return
if (!shouldRun()) return
// data-preply-ds-coverage must be unique in page
// TODO: identify SSR vs browser
const elementsToAnalyze = globalThis.document.querySelectorAll('[data-preply-ds-coverage]')
// console.log(`Found ${elementsToAnalyze.length} elements`)
if (elementsToAnalyze.length === 0) return
// No need to re-run the calculation in standard MPA apps like node-ssr (will be needed for Preply Space though)
clearInterval(coverageIntervalId)
const results = {}
const noResults = {}
for (let i = 0, n = elementsToAnalyze.length; i < n; i++) {
const element = elementsToAnalyze[i]
// let svg = undefined
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('width', width)
svg.setAttribute('height', height)
svg.style.position = 'fixed'
svg.style.zIndex = 1000
element.appendChild(svg)
results[element.getAttribute('data-preply-ds-coverage')] = noResults
run({
domElement: element,
svgRenderer: svg,
meta: {
version: '1', // should be aligned to the DS version
href: globalThis.location.href,
},
onComplete: (result) => {
results[element.getAttribute('data-preply-ds-coverage')] = result
if (Object.values(results).some((value) => value === noResults)) return
Object.entries(results).forEach(([key, { pixels, arr }]) => {
console.table(pixels)
const greenPixels =
pixels.layoutComponent +
pixels.utilComponent +
pixels.outdatedComponent +
pixels.leafComponent +
pixels.unknownDsComponent
const redPixels = pixels.nonDsComponent + pixels.rebrandComponent
console.log(
`Coverage: ${((greenPixels / (redPixels + greenPixels)) * 100).toFixed(2)} %`
)
// Transform the array in a string and print it
// let string = ''
// arr.forEach((row) => {
// string += row.join('') + '\n'
// })
// console.log(string)
})
},
})
}
}
const coverageIntervalId = setInterval(tick, 100)
}
// Temp hack
// TODO: identify SSR vs browser
globalThis.document.body.setAttribute('data-preply-ds-coverage', '{TODO:1}')
// document
// .querySelectorAll('[data-preply-ds-component="RebrandStackedImage"]')[0]
// .setAttribute('data-preply-ds-coverage', '{TODO:2}')
queueDsCoverage()
globalThis.dsCoverageLastHourMinutes = 60
})()
type ComponentType =
| 'leafComponent'
| 'utilComponent'
| 'nonDsComponent'
| 'layoutComponent'
| 'rebrandComponent'
| 'outdatedComponent'
| 'unknownDsComponent'
type EmptyPixelMarker = ' '
type NonDsComponentPixelMarker = '_'
type DsComponentPixelMarkers = 'A' | 'U' | 'O' | 'L' | 'K' | 'R'
type ComponentPixelMarkers = NonDsComponentPixelMarker | DsComponentPixelMarkers
type PixelMarkers = EmptyPixelMarker | ComponentPixelMarkers
type PixelCounts = Record<ComponentType, number>
type RgbColor = string
type Rect = Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>
type Milliseconds = number
type Duration = {
totalTime: Milliseconds
countPixelsTime: Milliseconds,
addBoundingRectTime: Milliseconds,
loopOverDomChildrenTime: Milliseconds,
}
type Result = {
done: true,
href: string,
duration: Duration
pixelCounts: PixelCounts,
dsCoverageVersion: string,
viewportPixels: ViewportPixels,
} | {
done: false,
}
type Results = Record<string, Result>
// virtual representation of the pixels of the page
type ViewportPixels = Array<Array<PixelMarkers>>
; (() => {
const fakeRect: Rect = { top: -1, left: -1, width: -1, height: -1 }
const emptyPixel: EmptyPixelMarker = ' ' // useful to print out the array with monospaced fonts
const pixelMarkerByComponentType: Record<ComponentType, PixelMarkers> = {
leafComponent: 'L',
utilComponent: 'U',
nonDsComponent: '_',
layoutComponent: 'A',
rebrandComponent: 'R',
outdatedComponent: 'O',
unknownDsComponent: 'K',
}
const colorByComponentType: Record<ComponentType, RgbColor> = {
leafComponent: '#00FF00',
utilComponent: '#00FF00',
nonDsComponent: '#FF0000',
layoutComponent: '#00FF00',
rebrandComponent: '#0000FF',
outdatedComponent: '#00FF00',
unknownDsComponent: '#00FF00',
}
const componentTypeByPixelMarker: Record<ComponentPixelMarkers, ComponentType> = {
_: 'nonDsComponent',
L: 'leafComponent',
U: 'utilComponent',
A: 'layoutComponent',
R: 'rebrandComponent',
O: 'outdatedComponent',
K: 'unknownDsComponent',
}
// TODO: type componentName with the real names to be prompted in case of future name additions
function getDsComponentType(componentName: string | null): ComponentType {
if (componentName === null) return 'nonDsComponent'
if (componentName.startsWith('Rebrand')) {
return 'rebrandComponent'
}
switch (componentName) {
case 'Box':
case 'LayoutFlex':
case 'LayoutFlexItem':
case 'LAYOUT_GRID':
case 'LayoutGrid':
case 'LayoutGridItem':
return 'layoutComponent'
case 'ObserverIntersection':
return 'utilComponent'
case 'Panel':
return 'outdatedComponent'
case 'Avatar':
case 'AvatarWithStatus':
case 'Badge':
case 'Button':
case 'FieldButton':
case 'FieldLayout':
case 'Heading':
case 'Icon':
case 'IconButton':
case 'Link':
case 'Loader':
case 'NumberField':
case 'PasswordField':
case 'PreplyLogo':
case 'SelectField':
case 'Text':
case 'TextField':
case 'TextHighlighted':
case 'TextInline':
case 'TextareaField':
case 'Checkbox':
case 'InputDate':
case 'InputNumber':
case 'InputPassword':
case 'InputText':
case 'InputTime':
case 'Radio':
case 'Select':
case 'Textarea':
case 'SelectFieldLayout':
return 'leafComponent'
default:
return 'unknownDsComponent'
}
}
type ChildData = {
child: Element
dsComponentType: ComponentType
isChildOfLeafDsComponent: boolean
rect: Rect
}
type LoopOverChildrenParams<META extends Record<string, unknown>> = {
meta: META
domElement: Element
duration: Duration,
onComplete: (params: { childrenData: ChildData[]; meta: META, duration: Duration, }) => void
// Must NOT be passed externally
recursiveParams?: {
isRootLoop: boolean // Must not be passed from the consumer
childrenData: ChildData[] // passed recursively
isChildOfLeafDsComponent: boolean
}
}
// Possible optimization:
// - parse the tree depth by depth instead of going through the whole tree at once (since this is hard to split over different frames).The only important thing is that deeper elements are parsed after less deep ones
// - otherwise, I can just use generators
function loopOverDomChildren<META extends Record<string, unknown> = Record<string, unknown>>(
params: LoopOverChildrenParams<META>
) {
const { domElement, meta, duration, onComplete, recursiveParams: {
isRootLoop,
childrenData,
isChildOfLeafDsComponent,
} = {
isRootLoop: true,
childrenData: [],
isChildOfLeafDsComponent: false,
} } = params
const childNodes = domElement.children
for (let i = 0, n = childNodes.length; i < n; i++) {
const child = childNodes[i]
if (!child) throw new Error(`No child at ${i} (this should be a TS-only protection)`)
if (child.nodeType !== Node.ELEMENT_NODE) continue
// Stop when encounter other containers.
// TODO: also add a data-preply-ds-coverage-ignore attribute for external components
const dataPreplyDsCoverageAttribute = child.getAttribute('data-preply-ds-coverage')
if (dataPreplyDsCoverageAttribute !== null) continue
const isInvisible = globalThis.getComputedStyle(child).display === 'none'
if (isInvisible) continue
const dsAttribute = child.getAttribute('data-preply-ds-component')
const dsComponentType = getDsComponentType(dsAttribute)
const isLeafDsComponent = dsComponentType === 'leafComponent'
childrenData.push({ child, isChildOfLeafDsComponent, dsComponentType, rect: fakeRect })
loopOverDomChildren({
meta,
onComplete,
duration,
domElement: child,
recursiveParams: {
childrenData,
isRootLoop: false,
isChildOfLeafDsComponent: isLeafDsComponent || isChildOfLeafDsComponent,
}
})
}
if (isRootLoop) {
// Will be called only once at the end of the root loop
onComplete({ childrenData, meta, duration })
}
}
type AddBoundingRectParams<META extends Record<string, unknown> = Record<string, unknown>> = {
meta: META
duration: Duration
childrenData: ChildData[]
onComplete: (params: { childrenData: ChildData[]; meta: META, duration: Duration }) => void
// Must NOT be passed externally
recursiveParams?: {
// Ready to be splitted over multiple frames
startAt: number
}
}
function addBoundingRect<META extends Record<string, unknown> = Record<string, unknown>>(params: AddBoundingRectParams<META>) {
const {
meta,
duration,
onComplete,
childrenData,
recursiveParams: { startAt } = { startAt: 0 },
} = params
for (let i = startAt, n = childrenData.length; i < n; i++) {
const item = childrenData[i]
if (!item) throw new Error(`No item at ${i} (this should be a TS-only protection)`)
let rect = item.child.getBoundingClientRect()
item.rect = rect
}
onComplete({ childrenData, meta, duration })
}
type CountPixelsParams<META extends Record<string, unknown>> = {
deadline: IdleDeadline
meta: META
duration: Duration,
childrenData: ChildData[]
viewportPixels: ViewportPixels
offset: { top: number; left: number }
onComplete: (params: { childrenData: ChildData[]; meta: META, pixelCounts: PixelCounts, viewportPixels: ViewportPixels, duration: Duration, offset: { top: number; left: number } }) => void
// Pass an empty SVG to get the rectangles rendered inside it and visualize the elements rects
svgRenderer?: SVGSVGElement | undefined
// Must NOT be passed externally
recursiveParams?: {
// Will be splitted over multiple frames
startAt: number
pixelCounts: PixelCounts
}
}
function countPixels<META extends Record<string, unknown> = Record<string, unknown>>(params: CountPixelsParams<META>) {
const {
meta,
offset,
deadline,
duration,
onComplete,
svgRenderer,
childrenData,
viewportPixels,
recursiveParams: { startAt, pixelCounts } = {
startAt: 0,
pixelCounts: {
leafComponent: 0,
utilComponent: 0,
nonDsComponent: 0,
layoutComponent: 0,
rebrandComponent: 0,
outdatedComponent: 0,
unknownDsComponent: 0,
},
}
} = params
for (let i = startAt, n = childrenData.length, iterations = 0; i < n; i++, iterations++) {
if (deadline.timeRemaining() <= 0) {
log('⏳ Waiting idle')
requestIdleCallback((deadline) =>
countPixels({
meta,
offset,
deadline,
duration,
onComplete,
svgRenderer,
childrenData,
viewportPixels,
recursiveParams: {
startAt: i,
pixelCounts,
},
})
)
return
}
const childData = childrenData[i]
if (!childData) throw new Error(`No childData at ${i} (this should be a TS-only protection)`)
const { rect, dsComponentType, isChildOfLeafDsComponent } = childData
const adjustedSsComponentType = isChildOfLeafDsComponent
? 'leafComponent' // children of leaf components are treated as leaf components too
: dsComponentType
if (svgRenderer) {
// TODO: identify SSR vs browser
const svgRect = globalThis.document.createElementNS('http://www.w3.org/2000/svg', 'rect')
svgRect.setAttribute('x', (rect.left - offset.left).toString())
svgRect.setAttribute('y', (rect.top - offset.top).toString())
svgRect.setAttribute('width', rect.width.toString())
svgRect.setAttribute('height', rect.height.toString())
svgRect.setAttribute('fill', 'none')
svgRect.setAttribute('stroke-width', '2')
svgRect.setAttribute('opacity', '1')
svgRect.setAttribute('stroke', colorByComponentType[adjustedSsComponentType])
svgRenderer.appendChild(svgRect)
}
const pixelMarker = pixelMarkerByComponentType[adjustedSsComponentType]
let offsetTop = offset.top
let offsetLeft = offset.left
let rectTop = rect.top
let rectLeft = rect.left
let rectWidth = rect.width
let rectHeight = rect.height
const firstViewportRow = viewportPixels[0]
if (!firstViewportRow) throw new Error(`No firstViewportRow (this should be a TS-only protection)`)
const columnLength = firstViewportRow.length
const rowLength = viewportPixels.length
// debugger
// "Draw" the rows in viewportPixels, the bidimensional array that repreents the screen
for (
let firstRow = Math.floor(rectTop - offsetTop),
lastRow = Math.floor(rectTop - offsetTop + rectHeight - 1),
column = Math.floor(rectLeft - offsetLeft);
column < rectLeft - offsetLeft + rectWidth - 1 && column < columnLength;
column++
) {
if (column < 0) continue // can happen for elements placed outside the viewport
// "Draw" the top row
if (firstRow >= 0 && firstRow < rowLength && column < columnLength) {
const pixel = viewportPixels[firstRow]?.[column]
if (!!pixel && pixel !== emptyPixel) {
const componentType = componentTypeByPixelMarker[pixel]
pixelCounts[componentType]--
}
const pixelRow = viewportPixels[firstRow]
if (pixelRow) {
pixelRow[column] = pixelMarker
pixelCounts[adjustedSsComponentType]++
}
}
// "Draw" the bottom row
if (lastRow >= 0 && lastRow < rowLength && column < columnLength) {
const pixel = viewportPixels[lastRow]?.[column]
if (!!pixel && pixel !== emptyPixel) {
const componentType = componentTypeByPixelMarker[pixel]
pixelCounts[componentType]--
}
const pixelRow = viewportPixels[lastRow]
if (pixelRow) {
pixelRow[column] = pixelMarker
pixelCounts[adjustedSsComponentType]++
}
}
}
// "Draw" the columns in viewportPixels, the bidimensional array that repreents the screen
for (
let firstColumn = Math.floor(rectLeft - offsetLeft),
lastColumn = Math.floor(rectLeft - offsetLeft + rectWidth - 1),
row = Math.floor(rectTop - offsetTop);
row < rectTop - offsetTop + rectHeight - 1 && row < rowLength;
row++
) {
if (row < 0) continue // can happen for elements placed outside the viewport
// "Draw" the left column
if (firstColumn >= 0 && row < rowLength && firstColumn < columnLength) {
const pixel = viewportPixels[row]?.[firstColumn]
if (!!pixel && pixel !== emptyPixel) {
const componentType = componentTypeByPixelMarker[pixel]
pixelCounts[componentType]--
}
const pixelRow = viewportPixels[row]
if (pixelRow) {
pixelRow[firstColumn] = pixelMarker
pixelCounts[adjustedSsComponentType]++
}
}
// "Draw" the right row
if (lastColumn >= 0 && row < rowLength && lastColumn < columnLength) {
const pixel = viewportPixels[row]?.[lastColumn]
if (!!pixel && pixel !== emptyPixel) {
const componentType = componentTypeByPixelMarker[pixel]
pixelCounts[componentType]--
}
const pixelRow = viewportPixels[row]
if (pixelRow) {
pixelRow[lastColumn] = pixelMarker
pixelCounts[adjustedSsComponentType]++
}
}
}
}
onComplete({ childrenData, pixelCounts, meta, viewportPixels, duration, offset })
}
type RunParams<META extends Record<string, unknown>> = {
meta: META
domElement: Element
onComplete: (params: {
meta: META
duration: Duration
pixelCounts: PixelCounts,
childrenData: ChildData[]
viewportPixels: ViewportPixels,
offset: { top: number; left: number }
}) => void
// Pass an empty SVG to get the rectangles rendered inside it and visualize the elements rects
svgRenderer?: SVGSVGElement | undefined
}
function run<META extends Record<string, unknown> = Record<string, unknown>>({
meta,
domElement,
onComplete,
svgRenderer,
}: RunParams<META>) {
const start = Date.now() // duration.now is more precise but takes more time to be executed
let step = start
let countPixelsTime = 0
let addBoundingRectTime = 0
let loopOverDomChildrenTime = 0
requestIdleCallback(() => {
loopOverDomChildren({
meta,
domElement,
duration: {
totalTime: -1,
countPixelsTime: -1,
addBoundingRectTime: -1,
loopOverDomChildrenTime: -1,
},
onComplete: ({ childrenData, meta, duration }) => {
log('✅ loopOverDomChildren')
const stepBefore = step
step = Date.now() // duration.now is more precise but takes more time to be executed
loopOverDomChildrenTime = step - stepBefore
addBoundingRect({
childrenData,
meta,
duration: { ...duration, loopOverDomChildrenTime },
onComplete: ({ childrenData, meta, duration }) => {
log('✅ addBoundingRect')
const stepBefore = step
step = Date.now() // duration.now is more precise but takes more time to be executed
addBoundingRectTime = step - stepBefore
const elementRect = domElement.getBoundingClientRect()
// Helpful to unit test countPixels
// const elementRect = { top: 0, left: 0, width: 10, height: 10 }
// childrenData = [
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'nonDsComponent',
// rect: { top: 0, left: 0, width: 10, height: 10 },
// // @ts-ignore
// child: undefined
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'layoutComponent',
// rect: { top: 1, left: 1, width: 8, height: 8 },
// // @ts-ignore
// child: undefined
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'utilComponent',
// rect: { top: 2, left: 2, width: 6, height: 6 },
// // @ts-ignore
// child: undefined
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'outdatedComponent',
// rect: { top: 3, left: 3, width: 4, height: 4 },
// // @ts-ignore
// child: undefined
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'leafComponent',
// rect: { top: 4, left: 4, width: 2, height: 2 },
// // @ts-ignore
// child: undefined
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'unknownDsComponent',
// rect: { top: 1, left: 1, width: 8, height: 8 },
// // @ts-ignore
// child: undefined
// },
// {
// isChildOfLeafDsComponent: false,
// dsComponentType: 'rebrandComponent',
// rect: { top: 1, left: 1, width: 8, height: 8 },
// // @ts-ignore
// child: undefined
// },
// ]
const elementWidth = Math.floor(elementRect.width)
const elementHeight = Math.floor(elementRect.height)
/**
* Think of it as a data mirror of the screen: every pixel of the screen has value in the bidimensional array. It starts empty but it will look like this at the end
* [
* [ ,_,_,_,_,_,_,_,_,_,],
* [ ,_, , , , , , , ,_,],
* [ ,_,_,_,_,_,_,_,_,_,],
* [ , , , , , , , , , ,],
* [ , , , , ,L,L,L,L,L,],
* [ , , , , ,L, , , ,L,],
* [ , , , , ,L,L,L,L,L,],
* ]
* where every letter perimeter describes an element perimeter.
* (a real one could be 800 like the real screen)
*/
const viewportPixels: ViewportPixels = new Array(elementHeight).fill([]).map(() =>
new Array(elementWidth).fill(emptyPixel)
)
const offset = { left: elementRect.left, top: elementRect.top }
requestIdleCallback((deadline) => {
countPixels({
meta,
offset,
deadline,
svgRenderer,
childrenData,
viewportPixels,
duration: { ...duration, addBoundingRectTime },
onComplete: ({ meta, pixelCounts, childrenData, viewportPixels, duration, offset }) => {
log('✅ countPixels')
const stepBefore = step
step = Date.now() // duration.now is more precise but takes more time to be executed
countPixelsTime = step - stepBefore
const totalTime = Date.now() - start // duration.now is more precise but takes more time to be executed - start
onComplete({
childrenData,
viewportPixels,
pixelCounts,
meta,
offset,
duration: { ...duration, totalTime, countPixelsTime },
})
},
})
})
},
})
},
})
})
}
function shouldRun() {
const now = Date.now()
// Convert the time to minutes
const totalMinutes = Math.floor(now / 1000 / 60)
// Get the current minute of the hour
const currentMinute = totalMinutes % 60
// By default, let's run the coverage at the end of the hour, when the previous lesson is over and the next lesson is about to start
const defaultMinutes = 1
const runDuringLastHourMinutes =
// Allow setting the minutes externally
// @ts-expect-error the dsCoverageLastHourMinutes global variable sould be typed
globalThis.dsCoverageLastHourMinutes ?? defaultMinutes
return currentMinute > 60 - runDuringLastHourMinutes
}
function queueDsCoverage() {
function tick() {
// Every tot milliseconds, let's check if we can run the coverage
if (!('requestIdleCallback' in globalThis)) {
// Safari does not support requestIdleCallback
clearInterval(coverageIntervalId)
log('No requestIdleCallback')
return
}
const forceCalculation = true
// TODO: identify SSR vs browser
const isSupposedToBePowerful = globalThis.document.documentElement.clientWidth >= 1280
// The initial implementation runs loopOverDomChildren and addBoundingRect without interruptions.
// The advantage is that we get the elements immediately and we canculate the coverage later on
if (!isSupposedToBePowerful && !forceCalculation) {
log('Machine is not supposed to be powerful')
return
}
// Limit when we run it
if (!shouldRun()) {
log('SHould not run now')
return
}
// data-preply-ds-coverage must be unique in page
// TODO: identify SSR vs browser
const elementsToAnalyze = globalThis.document.querySelectorAll('[data-preply-ds-coverage]')
log(`Found ${elementsToAnalyze.length} DS coverage containers`)
if (elementsToAnalyze.length === 0) return
// No need to re-run the calculation in standard MPA apps like node-ssr (will be needed for SPAs like Preply Space, though)
clearInterval(coverageIntervalId)
log(`🎬 Calculation start`)
const results: Results = {}
for (let i = 0, n = elementsToAnalyze.length; i < n; i++) {
const domElement = elementsToAnalyze[i]
if (!domElement) throw new Error(`No element at ${i} (this should be a TS-only protection)`)
// By passing an svg, the element's rect will be added there and we can visualize them by adding the svg to the page
const svgRenderer = undefined
// let svgRenderer = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
// svgRenderer.setAttribute('width', width)
// svgRenderer.setAttribute('height', height)
// svgRenderer.style.position = 'fixed'
// svgRenderer.style.zIndex = 1000
// Set svg top and left to the received offset
// element.appendChild(svgRenderer)
const coverageAttribute = domElement?.getAttribute('data-preply-ds-coverage')
if (!coverageAttribute) throw new Error(`No element or attribute (this should be a TS-only protection)`)
results[coverageAttribute] = { done: false }
run({
domElement,
svgRenderer,
meta: {
dsCoverageVersion: '1', // should be aligned to the DS version
coverageAttribute,
href: globalThis.location.href,
},
onComplete: ({
meta,
duration,
pixelCounts,
childrenData,
viewportPixels,
offset
}) => {
const coverageAttribute = meta.coverageAttribute
const result: Result = {
done: true,
duration,
pixelCounts,
viewportPixels,
href: meta.href,
dsCoverageVersion: meta.dsCoverageVersion,
}
results[coverageAttribute] = result
if (Object.values(results).some((value) => !value.done)) return
log(`🏁 Calculation end`)
console.table(duration)
for (const [key, result] of Object.entries(results)) {
if (!result.done) return
const {
pixelCounts,
viewportPixels
} = result
console.group(key);
const dsPixels = pixelCounts.layoutComponent +
pixelCounts.utilComponent +
pixelCounts.outdatedComponent +
pixelCounts.leafComponent +
pixelCounts.unknownDsComponent
const nonDsPixels = pixelCounts.nonDsComponent + pixelCounts.rebrandComponent
log(`Coverage: ${((dsPixels / (nonDsPixels + dsPixels)) * 100).toFixed(2)} %`)
console.table(pixelCounts)
// Transform the array in a string and print it
// let string = ''
// viewportPixels.forEach((row) => {
// string += row.join('') + '\n'
// })
// console.log(string)
console.groupEnd();
}
},
})
}
}
const coverageIntervalId = setInterval(tick, 1000)
}
// Temp hack
// TODO: identify SSR vs browser
globalThis.document.body.setAttribute('data-preply-ds-coverage', '{TODO:1}')
// document
// .querySelectorAll('[data-preply-ds-component="RebrandStackedImage"]')[0]
// .setAttribute('data-preply-ds-coverage', '{TODO:2}')
queueDsCoverage()
// @ts-expect-error the dsCoverageLastHourMinutes global variable sould be typed
globalThis.dsCoverageLastHourMinutes = 60
function log(...args: unknown[]) {
console.log(
'%c Preply DS coverage ',
// color.background.brand, color.text.primary
'background: #FF7AAC; color: #121117; padding: 2px; border-radius: 2px;',
...args
);
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment