diff --git a/js/tests/Dockerfile b/js/tests/Dockerfile
index 225688c7d..fa4f12704 100644
--- a/js/tests/Dockerfile
+++ b/js/tests/Dockerfile
@@ -1,4 +1,21 @@
-FROM node:14.16.0-alpine3.12
+FROM alpine:3.16.0
+
+# Installs latest Chromium package.
+RUN apk add --no-cache \
+ chromium \
+ nss \
+ freetype \
+ harfbuzz \
+ ca-certificates \
+ ttf-freefont \
+ nodejs \
+ yarn
+
+# Tell Puppeteer to skip installing Chrome. We'll be using the installed package.
+ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
+ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
+
+RUN yarn add puppeteer@15.3.2
WORKDIR /app
COPY . .
diff --git a/js/tests/action-reaction-dom_test.mjs b/js/tests/action-reaction-dom_test.mjs
new file mode 100644
index 000000000..e18277827
--- /dev/null
+++ b/js/tests/action-reaction-dom_test.mjs
@@ -0,0 +1,45 @@
+export const tests = []
+
+tests.push(async ({ eq, page }) => {
+ // check the initial class name of the eye left
+ const eyeLeft = await page.$eval('#eye-left', (node) => node.className)
+ eq(eyeLeft, 'eye')
+
+ // check that the text of the button says 'close'
+ const buttonText = await page.$eval('button', (node) => node.textContent)
+ eq(buttonText, 'Click to close the left eye')
+})
+
+tests.push(async ({ eq, page }) => {
+ // click the button to close the left eye
+ const button = await page.$('button')
+ button.click()
+
+ // check that the class has been added
+ await page.waitForSelector('#eye-left.eye.eye-closed', { timeout: 150 })
+
+ // check the background color has changed
+ await eq.$('#eye-left.eye.eye-closed', {
+ style: { backgroundColor: 'black' },
+ })
+
+ // check that the text of the button changed to 'open'
+ await eq.$('button', { textContent: 'Click to open the left eye' })
+})
+
+tests.push(async ({ eq, page }) => {
+ // click the button a second time to open the left eye
+ const button = await page.$('button')
+ button.click()
+
+ // check that the class has been removed
+ await page.waitForSelector('#eye-left.eye:not(.eye-closed)', { timeout: 150 })
+
+ // check the background color has changed
+ await eq.$('#eye-left.eye:not(.eye-closed)', {
+ style: { backgroundColor: 'red' },
+ })
+
+ // check that the text of the button changed to 'close'
+ await eq.$('button', { textContent: 'Click to close the left eye' })
+})
diff --git a/js/tests/bring-it-to-life-dom_test.mjs b/js/tests/bring-it-to-life-dom_test.mjs
new file mode 100644
index 000000000..a9e25ffb2
--- /dev/null
+++ b/js/tests/bring-it-to-life-dom_test.mjs
@@ -0,0 +1,43 @@
+export const tests = []
+
+tests.push(async ({ eq, page }) => {
+ // check the JS script has been linked
+ await eq.$('script', { type: 'module' })
+
+ // check the JS script has a valid src
+ const source = await page.$eval(
+ 'script',
+ (node) => node.src.includes('.js') && node.src,
+ )
+ if (!source.length) throw Error('missing script src')
+})
+
+tests.push(async ({ eq, page }) => {
+ // check the class 'eye-closed' has been added in the CSS
+ eq.css('.eye-closed', {
+ height: '4px',
+ padding: '0px 5px',
+ borderRadius: '10px',
+ })
+})
+
+tests.push(async ({ eq, page }) => {
+ // check the class of left eye before the JS is loaded
+ await page.setJavaScriptEnabled(false)
+ await page.reload()
+ await eq.$('p#eye-left', { className: 'eye' })
+})
+
+tests.push(async ({ eq, page }) => {
+ // check the class of left eye has been updated after the JS is loaded
+ await page.setJavaScriptEnabled(true)
+ await page.reload()
+ await eq.$('p#eye-left', { className: 'eye eye-closed' })
+
+ // check the background color of left eye has changed after the JS is loaded
+ const eyeLeftBg = await page.$eval(
+ '#eye-left',
+ (node) => node.style.backgroundColor,
+ )
+ eq(eyeLeftBg, 'black')
+})
diff --git a/js/tests/build-brick-and-break-dom_test.mjs b/js/tests/build-brick-and-break-dom_test.mjs
new file mode 100644
index 000000000..658cda3fb
--- /dev/null
+++ b/js/tests/build-brick-and-break-dom_test.mjs
@@ -0,0 +1,109 @@
+export const tests = []
+
+export const setup = async ({ page }) => ({
+ getBricksIds: async () =>
+ await page.$$eval('div', (nodes) =>
+ nodes.filter((node) => node.id.includes('brick')).map((n) => n.id),
+ ),
+})
+
+const between = (expected, min, max) => expected >= min && expected <= max
+
+tests.push(async ({ page, eq }) => {
+ // check that the brick divs are built at a regular interval of 100ms
+ // the average of the divs built every 100ms must be close to 10
+ let repeat = 0
+ let buildSteps = []
+
+ while (repeat < 3) {
+ const divs = await page.$$eval('div', (nodes) => nodes.length)
+ console.log(`step ${repeat + 1}: ${divs} bricks`)
+ buildSteps.push(divs)
+ await page.waitForTimeout(1000)
+ repeat++
+ }
+
+ const diff1 = buildSteps[1] - buildSteps[0]
+ const diff2 = buildSteps[2] - buildSteps[1]
+ const average = Math.round((diff1 + diff2) / 2)
+
+ if (average < 9) {
+ console.log('average too low --> new bricks built / sec:', average)
+ } else if (average > 11) {
+ console.log('average too high --> new bricks built / sec:', average)
+ } else {
+ console.log('good average of new bricks built / sec')
+ }
+
+ eq(between(average, 9, 11), between(10, 9, 11))
+})
+
+const allBricksIds = [...Array(54).keys()].map((i) => `brick-${i + 1}`)
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that all the bricks are here and have the correct id
+ await page.waitForTimeout(3000)
+ const bricksIds = await ctx.getBricksIds()
+ eq(bricksIds, allBricksIds)
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the middle column bricks have the `foundation` attribute to `true`
+ const expectedIds = allBricksIds.filter(
+ (b) => b.replace('brick-', '') % 3 === 2,
+ )
+ const middleBricksIds = await page.$$eval('div', (nodes) =>
+ nodes
+ .filter((node) => node.id.includes('brick') && node.dataset.foundation)
+ .map((n) => n.id),
+ )
+ eq(middleBricksIds, expectedIds)
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the bricks to repair have the right repaired attribute
+ const hammer = await page.$('#hammer')
+ await hammer.click()
+
+ const expectedRepairedIds = await page.$eval('body', (body) => {
+ const getIdInt = (str) => str.replace('brick-', '')
+ return body.dataset.reparations
+ .split(',')
+ .sort((a, b) => getIdInt(b) - getIdInt(a))
+ .map((id) => {
+ const isMiddleBrick = getIdInt(id) % 3 === 2
+ const status = isMiddleBrick ? 'in progress' : 'repaired'
+ return `${id}_${status}`
+ })
+ })
+
+ const repairedIds = await page.$$eval('div', (nodes) => {
+ const getIdInt = (str) => str.replace('brick-', '')
+ return nodes
+ .filter(
+ (node) =>
+ node.dataset.repaired === 'true' ||
+ node.dataset.repaired === 'in progress',
+ )
+ .sort((a, b) => getIdInt(b.id) - getIdInt(a.id))
+ .map(({ id }) => {
+ const isMiddleBrick = getIdInt(id) % 3 === 2
+ const status = isMiddleBrick ? 'in progress' : 'repaired'
+ return `${id}_${status}`
+ })
+ })
+
+ eq(repairedIds, expectedRepairedIds)
+})
+
+tests.push(async ({ page, eq, getBricksIds }) => {
+ // check that the last brick is removed on each dynamite click
+ const dynamite = await page.$('#dynamite')
+
+ for (const i of allBricksIds.keys()) {
+ await dynamite.click()
+ const { length } = allBricksIds
+ const expectedRemainingBricks = allBricksIds.slice(0, length - (i + 1))
+ eq(await getBricksIds(), expectedRemainingBricks)
+ }
+})
diff --git a/js/tests/class-that-dom_test.mjs b/js/tests/class-that-dom_test.mjs
new file mode 100644
index 000000000..f84a44592
--- /dev/null
+++ b/js/tests/class-that-dom_test.mjs
@@ -0,0 +1,36 @@
+export const tests = []
+
+tests.push(async ({ page, eq }) => {
+ // check the class 'eye' has been declared properly in the CSS
+ eq.css('.eye', {
+ width: '60px',
+ height: '60px',
+ backgroundColor: 'red',
+ borderRadius: '50%',
+ })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the class 'arm' has been declared properly in the CSS
+ eq.css('.arm', { backgroundColor: 'aquamarine' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the class 'leg' has been declared properly in the CSS
+ eq.css('.leg', { backgroundColor: 'dodgerblue' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the class 'body-member' has been declared properly in the CSS
+ eq.css('.body-member', { width: '50px', margin: '30px' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the targetted elements have the correct class names
+ await eq.$('p#eye-left', { className: 'eye' })
+ await eq.$('p#eye-right', { className: 'eye' })
+ await eq.$('div#arm-left', { className: 'arm body-member' })
+ await eq.$('div#arm-right', { className: 'arm body-member' })
+ await eq.$('div#leg-left', { className: 'leg body-member' })
+ await eq.$('div#leg-right', { className: 'leg body-member' })
+})
diff --git a/js/tests/fifty-shades-of-cold-dom_test.mjs b/js/tests/fifty-shades-of-cold-dom_test.mjs
new file mode 100644
index 000000000..a946885d2
--- /dev/null
+++ b/js/tests/fifty-shades-of-cold-dom_test.mjs
@@ -0,0 +1,71 @@
+import { colors } from '../subjects/fifty-shades-of-cold/fifty-shades-of-cold.data.js'
+
+colors.sort()
+
+const cold = ['aqua', 'blue', 'turquoise', 'green', 'purple', 'cyan', 'navy']
+
+const isCold = color => cold.some(coldColor => color.includes(coldColor))
+const toClass = (a, b) => `.${a} { background: ${b} }`
+const toDiv = (a, b) => `
${b}
`
+
+export const tests = []
+
+tests.push(async ({ page, eq }) => {
+ // check that the css is properly generated
+
+ const style = await page.$$eval('style', nodes => nodes[1].innerHTML)
+ const classes = style
+ .split('}')
+ .map(s => s.replace(/(\.|{|:|;|\s+)/g, ''))
+ .filter(Boolean)
+ .sort()
+
+ for (const [i, c] of colors.entries()) {
+ if (!classes[i]) throw Error(`Not enough class (expected: ${c})`)
+ const [a, b] = classes[i].split('background')
+ eq(toClass(a, b), toClass(c, c))
+ }
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the divs are properly generated
+
+ const values = await page.$$eval('div', nodes =>
+ nodes.map(n => [n.className, n.textContent]),
+ )
+ const divs = values.map(v => toDiv(...v)).sort()
+
+ let skipped = 0
+ for (const [i, c] of colors.entries()) {
+ if (isCold(c)) {
+ if (!values[i - skipped]) throw Error(`Not enough div (expected: ${c})`)
+ eq(divs[i - skipped], toDiv(c, c))
+ continue
+ }
+ skipped++
+ if (divs.includes(toDiv(c, c))) {
+ throw Error(`div ${toDiv(c, c)} is not very cold`)
+ }
+ }
+})
+
+tests.push(async ({ page, eq }) => {
+ // test that clicking update the color accordingly
+
+ const coldColors = colors.filter(isCold)
+ for (const c of coldColors) {
+ await page.$$eval(
+ 'div',
+ (nodes, c) => nodes.find(n => n.textContent === c).click(),
+ c,
+ )
+
+ const count = await page.$$eval(
+ 'div',
+ (nodes, c) => nodes.filter(n => n.className === c).length,
+ c,
+ )
+
+ eq(count, coldColors.length)
+ }
+})
diff --git a/js/tests/first-words-dom_test.mjs b/js/tests/first-words-dom_test.mjs
new file mode 100644
index 000000000..c0895bc06
--- /dev/null
+++ b/js/tests/first-words-dom_test.mjs
@@ -0,0 +1,46 @@
+export const tests = []
+
+tests.push(async ({ eq, page }) => {
+ // check the class words has been added in the CSS
+ await eq.css('.words', { textAlign: 'center', fontFamily: 'sans-serif' })
+})
+
+tests.push(async ({ eq, page }) => {
+ // check the torso element is initially empty
+ const isEmpty = await page.$eval('#torso', (node) => !node.children.length)
+ eq(isEmpty, true)
+})
+
+tests.push(async ({ eq, page }) => {
+ // click on the button
+ const button = await page.$('button#speak-button')
+ await button.click()
+
+ // check a new text element is added in the torso
+ const torsoChildren = await page.$eval('#torso', (node) =>
+ [...node.children].map((child) => ({
+ tag: child.tagName,
+ text: child.textContent,
+ class: child.className,
+ })),
+ )
+ eq(torsoChildren, [textNode])
+})
+
+tests.push(async ({ eq, page }) => {
+ // click a second time on the button
+ const button = await page.$('button#speak-button')
+ await button.click()
+
+ // check a second new text element is added in the torso
+ const torsoChildren = await page.$eval('#torso', (node) =>
+ [...node.children].map((child) => ({
+ tag: child.tagName,
+ text: child.textContent,
+ class: child.className,
+ })),
+ )
+ eq(torsoChildren, [textNode, textNode])
+})
+
+const textNode = { tag: 'DIV', text: 'Hello there!', class: 'words' }
diff --git a/js/tests/get-them-all-dom_test.mjs b/js/tests/get-them-all-dom_test.mjs
new file mode 100644
index 000000000..5071e57d9
--- /dev/null
+++ b/js/tests/get-them-all-dom_test.mjs
@@ -0,0 +1,127 @@
+import { people } from './subjects/get-them-all/get-them-all.data.js'
+
+const getIds = predicate =>
+ people
+ .filter(predicate)
+ .map(e => e.id)
+ .sort((a, b) => a.localeCompare(b))
+
+const architects = getIds(p => p.tag === 'a')
+const notArchitects = getIds(p => p.tag !== 'a')
+
+const classical = getIds(p => p.classe === 'classical')
+const notClassical = getIds(p => p.tag === 'a' && p.classe !== 'classical')
+
+const active = getIds(p => p.classe === 'classical' && p.active)
+const notActive = getIds(
+ p => p.tag === 'a' && p.classe === 'classical' && p.active === false,
+)
+
+const bonanno = people.find(p => p.id === 'BonannoPisano').id
+const notBonanno = getIds(
+ p =>
+ p.tag === 'a' &&
+ p.classe === 'classical' &&
+ p.active &&
+ p.id !== 'BonannoPisano',
+)
+
+export const tests = []
+
+tests.push(async ({ eq, page }) => {
+ // get architects
+ const btnArchitect = await page.$(`#btnArchitect`)
+ btnArchitect.click()
+ await page.waitForTimeout(150)
+
+ const selected = await page.$$eval('a', nodes =>
+ nodes
+ .filter(node => node.textContent === 'Architect')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+
+ eq(selected, architects)
+
+ const eliminated = await page.$$eval('span', nodes =>
+ nodes
+ .filter(node => node.style.opacity === '0.2')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+
+ eq(eliminated, notArchitects)
+})
+
+tests.push(async ({ page, eq }) => {
+ // get classical
+ const btnClassical = await page.$(`#btnClassical`)
+ btnClassical.click()
+ await page.waitForTimeout(150)
+
+ const selected = await page.$$eval('.classical', nodes =>
+ nodes
+ .filter(node => node.textContent === 'Classical')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+
+ eq(selected, classical)
+
+ const eliminated = await page.$$eval('a:not(.classical)', nodes =>
+ nodes
+ .filter(node => node.style.opacity === '0.2')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+
+ eq(eliminated, notClassical)
+})
+
+tests.push(async ({ page, eq }) => {
+ // check active
+ const btnActive = await page.$(`#btnActive`)
+ btnActive.click()
+ await page.waitForTimeout(150)
+
+ const selected = await page.$$eval('.classical.active', nodes =>
+ nodes
+ .filter(node => node.textContent === 'Active')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+ eq(selected, active)
+
+ const eliminated = await page.$$eval('.classical:not(.active)', nodes =>
+ nodes
+ .filter(node => node.style.opacity === '0.2')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+
+ eq(eliminated, notActive)
+})
+
+tests.push(async ({ page, eq }) => {
+ // get bonanno
+ const btnBonanno = await page.$(`#btnBonanno`)
+ btnBonanno.click()
+ await page.waitForTimeout(150)
+
+ const selected = await page.$eval('#BonannoPisano', node => {
+ if (node.textContent === 'Bonanno Pisano') return node.id
+ })
+
+ eq(`bonanno: ${selected}`, `bonanno: ${bonanno}`)
+
+ const eliminated = await page.$$eval(
+ 'a.classical.active:not(#BonannoPisano)',
+ nodes =>
+ nodes
+ .filter(node => node.style.opacity === '0.2')
+ .map(node => node.id)
+ .sort((a, b) => a.localeCompare(b)),
+ )
+
+ eq(eliminated, notBonanno)
+})
diff --git a/js/tests/gossip-grid-dom_test.mjs b/js/tests/gossip-grid-dom_test.mjs
new file mode 100644
index 000000000..5d6639c12
--- /dev/null
+++ b/js/tests/gossip-grid-dom_test.mjs
@@ -0,0 +1,123 @@
+import { gossips } from './subjects/gossip-grid/gossip-grid.data.js'
+
+export const tests = []
+
+tests.push(async ({ page, eq }) => {
+ // test that the grid is properly generated
+
+ const content = await page.$$eval('.gossip', nodes => nodes.map(n => n.textContent))
+
+ eq(content, ['Share gossip!', ...gossips])
+})
+
+
+tests.push(async ({ page, eq }) => {
+ // test that you can add a gossip
+
+ const rand = Math.random().toString(36).slice(2)
+ await page.type('textarea', `coucou ${rand}`)
+ await page.click('.gossip button')
+ const content = await page.$eval('div.gossip', n => n.textContent)
+
+ eq(content, `coucou ${rand}`)
+})
+
+
+const getStyle = (nodes, key) => nodes.map(n => n.style[key])
+tests.push(async ({ page, eq }) => {
+ // test that you can change the width to the min
+
+ const min = await page.$eval('#width', n => {
+ n.value = n.min
+ n.dispatchEvent(new Event('input'))
+ return Number(n.min)
+ })
+
+ eq(min, 200)
+
+ const values = await page.$$eval('div.gossip', getStyle, 'width')
+
+ eq(Array(gossips.length + 1).fill(`${min}px`), values)
+})
+
+tests.push(async ({ page, eq }) => {
+ // test that you can change the width to the max
+
+ const max = await page.$eval('#width', n => {
+ n.value = n.max
+ n.dispatchEvent(new Event('input'))
+ return Number(n.max)
+ })
+
+ eq(max, 800)
+
+ const values = await page.$$eval('div.gossip', getStyle, 'width')
+
+ eq(Array(gossips.length + 1).fill(`${max}px`), values)
+})
+
+tests.push(async ({ page, eq }) => {
+ // test that you can change the font-size to the min
+
+ const min = await page.$eval('#fontSize', n => {
+ n.value = n.min
+ n.dispatchEvent(new Event('input'))
+ return Number(n.min)
+ })
+
+ eq(min, 20)
+
+ const values = await page.$$eval('div.gossip', getStyle, 'fontSize')
+
+ eq(Array(gossips.length + 1).fill(`${min}px`), values)
+})
+
+tests.push(async ({ page, eq }) => {
+ // test that you can change the font-size to the max
+
+ const max = await page.$eval('#fontSize', n => {
+ n.value = n.max
+ n.dispatchEvent(new Event('input'))
+ return Number(n.max)
+ })
+
+ eq(max, 40)
+
+ const values = await page.$$eval('div.gossip', getStyle, 'fontSize')
+
+ eq(Array(gossips.length + 1).fill(`${max}px`), values)
+})
+
+const getBackground = (nodes, key) => nodes.map(n => n.style.background || n.style.backgroundColor)
+tests.push(async ({ page, eq, rgbToHsl }) => {
+ // test that you can change the background to the darkest
+
+ const min = await page.$eval('#background', n => {
+ n.value = n.min
+ n.dispatchEvent(new Event('input'))
+ return Number(n.min)
+ })
+
+ eq(min, 20)
+
+ const values = await page.$$eval('div.gossip', getBackground)
+ const lightness = values.map(rgbToHsl).map(([h,s,l]) => l)
+ eq(Array(gossips.length + 1).fill(min), lightness)
+})
+
+tests.push(async ({ page, eq, rgbToHsl }) => {
+ // test that you can change the background to the darkest
+
+ const max = await page.$eval('#background', n => {
+ n.value = n.max
+ n.dispatchEvent(new Event('input'))
+ return Number(n.max)
+ })
+
+ eq(max, 75)
+
+ const values = await page.$$eval('div.gossip', getBackground)
+ const lightness = values.map(rgbToHsl).map(([h,s,l]) => Math.round(l))
+
+ eq(Array(gossips.length + 1).fill(max), lightness)
+})
diff --git a/js/tests/harder-bigger-bolder-stronger-dom_test.mjs b/js/tests/harder-bigger-bolder-stronger-dom_test.mjs
new file mode 100644
index 000000000..266d40f1b
--- /dev/null
+++ b/js/tests/harder-bigger-bolder-stronger-dom_test.mjs
@@ -0,0 +1,67 @@
+export const tests = []
+
+export const setup = async ({ page }) => ({
+ content: await page.$$eval('div', nodes => nodes.map(n => ({
+ text: n.textContent,
+ size: Number((n.style.fontSize || '').slice(0, -2)),
+ weight: Number(n.style.fontWeight),
+ })))
+})
+
+tests.push(({ eq, ctx }) => {
+ // should contain 120 items
+ eq(ctx.content.length, 120)
+})
+
+tests.push(({ eq, ctx }) => {
+ // ctx.content should only be one letter long
+ eq(ctx.content.reduce((total, { text }) => total + text.length, 0), 120)
+})
+
+tests.push(({ eq, ctx }) => {
+ // we expect random to yield at least 10 different letters
+ eq(new Set(ctx.content).size > 10, true)
+})
+
+tests.push(({ eq, ctx }) => {
+ // only letters from 'A' to 'Z'
+ eq(ctx.content.every(({ text }) => text >= 'A' && text <= 'Z'), true)
+})
+
+tests.push(({ eq, ctx }) => {
+ // letter size should grow
+
+ // first should be 11
+ eq(ctx.content[0].size, 11)
+
+ // last should be 120
+ eq(ctx.content[119].size, 130)
+
+ // each letter should be bigger than the previous
+ let prev = 0
+ for (const { size } of ctx.content) {
+ if (prev >= size) {
+ throw Error('Letters should grow')
+ }
+ }
+})
+
+tests.push(({ eq, ctx }) => {
+ // letter weight should increase in thirds
+ const third = n => ({ weight }) => weight === n
+
+ // first third should be 300
+ eq(ctx.content[0].weight, 300)
+ eq(ctx.content[39].weight, 300)
+ eq(ctx.content.slice(0, 40).every(third(300)), true)
+
+ // second third should be 400
+ eq(ctx.content[40].weight, 400)
+ eq(ctx.content[79].weight, 400)
+ eq(ctx.content.slice(40, 80).every(third(400)), true)
+
+ // last third should be 600
+ eq(ctx.content[80].weight, 600)
+ eq(ctx.content[119].weight, 600)
+ eq(ctx.content.slice(80).every(third(600)), true)
+})
diff --git a/js/tests/hello-there_test.js b/js/tests/hello-there_test.js
deleted file mode 100644
index fce22c8e4..000000000
--- a/js/tests/hello-there_test.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { readFileSync as read } from 'fs'
-
-// /*/ // ⚡
-export const tests = [
- ({ eq, path }) => // code must use console.log
- read(path, 'utf8').trim().includes('console.log'),
-
- async ({ eq, code }) => {
- // console.log must have been called with the right value
- const log = console.log.bind(console)
- const args = []
- console.log = (..._args) => {
- args.push(..._args)
- log(..._args)
- }
-
- const b64 = Buffer.from(code).toString("base64")
- const url = `data:text/javascript;base64,${b64}`
- await import(url)
- console.log = log
- return eq(args.join(' ').trim(), 'Hello There')
- },
-]
diff --git a/js/tests/keycodes-symphony-dom_test.mjs b/js/tests/keycodes-symphony-dom_test.mjs
new file mode 100644
index 000000000..36b2abe28
--- /dev/null
+++ b/js/tests/keycodes-symphony-dom_test.mjs
@@ -0,0 +1,55 @@
+export const tests = []
+
+export const setup = async ({ page }) => ({
+ getNotes: async () =>
+ await page.$$eval('.note', (nodes) => {
+ return nodes.map((note) => note.textContent)
+ }),
+})
+
+const characters = `didyouhandlethekeydowneventcorrectly`
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that a note is created and matches the right letter when a key is pressed
+ for (const [i, character] of characters.split('').entries()) {
+ await page.keyboard.down(character)
+ const typed = characters.slice(0, i + 1).split('')
+ eq(await ctx.getNotes(), typed)
+ }
+})
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that the last note is removed when Backspace key is pressed
+ let step = 1
+ while (step < 10) {
+ await page.keyboard.down('Backspace')
+ const typed = characters.slice(0, characters.length - step).split('')
+ eq(await ctx.getNotes(), typed)
+ step++
+ }
+})
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that all the notes are cleared when Escape key is pressed
+ await page.keyboard.down('Escape')
+ const cleared = (await ctx.getNotes()).length === 0
+ eq(await cleared, true)
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that notes have different background colors
+ const test = 'abcdefghijklmnopqrstuvwxyz'
+ let step = 0
+ while (step < test.length) {
+ await page.keyboard.down(test[step])
+ step++
+ }
+
+ const getNotesBg = await page.$$eval('.note', (nodes) => {
+ return nodes.map((note) => note.style.backgroundColor)
+ })
+
+ const colors = [...new Set(getNotesBg)]
+ const allDifferent = colors.length === test.length
+ eq(allDifferent, true)
+})
diff --git a/js/tests/mouse-trap-dom_test.mjs b/js/tests/mouse-trap-dom_test.mjs
new file mode 100644
index 000000000..9588fb44b
--- /dev/null
+++ b/js/tests/mouse-trap-dom_test.mjs
@@ -0,0 +1,122 @@
+export const tests = []
+
+export const setup = async ({ page }) => ({
+ getCirclesPos: () =>
+ page.$$eval('.circle', nodes => {
+ const circleRadius = 25
+ const formatPos = pos => Number(pos.replace('px', '')) + circleRadius
+ return nodes.map(node => [
+ formatPos(node.style.left),
+ formatPos(node.style.top),
+ ])
+ }),
+})
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that a circle is created on click at the mouse position
+ const { width, height } = await page.evaluate(() => ({
+ width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight,
+ }))
+
+ const clicks = [...Array(10).keys()].map(e => [random(width), random(height)])
+
+ for (const [i, click] of clicks.entries()) {
+ const [posX, posY] = click
+ await page.mouse.click(posX, posY)
+ const currentCircle = (await ctx.getCirclesPos())[i]
+ eq(currentCircle, click)
+ }
+})
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that the last created circle moves along the mouse
+ let move = 0
+ while (move < 100) {
+ move++
+ const x = move
+ const y = move * 2
+ await page.mouse.move(x, y)
+ const circles = await ctx.getCirclesPos()
+ const currentCirclePos = circles[circles.length - 1]
+ eq(currentCirclePos, [x, y])
+ }
+})
+
+tests.push(async ({ page, eq, ctx }) => {
+ // check that a circle is trapped and purple when inside the box
+ const box = await page.$eval('.box', box => ({
+ top: box.getBoundingClientRect().top,
+ right: box.getBoundingClientRect().right,
+ left: box.getBoundingClientRect().left,
+ bottom: box.getBoundingClientRect().bottom,
+ }))
+
+ await page.mouse.click(200, 200)
+
+ let move = 200
+ let hasEntered = false
+
+ while (move < 500) {
+ const x = move + 50
+ const y = move
+ await page.mouse.move(x, y)
+
+ const circles = await ctx.getCirclesPos()
+ const currentCircle = circles[circles.length - 1]
+
+ const circleRadius = 25
+
+ const bg = await page.$$eval(
+ '.circle',
+ nodes => nodes[nodes.length - 1].style.background,
+ )
+
+ const insideX = x > box.left + circleRadius && x < box.right - circleRadius
+ const insideY = y > box.top + circleRadius && y < box.bottom - circleRadius
+ const isInside = insideX && insideY
+
+ // check that the background is set to the right color
+ if (isInside) {
+ hasEntered = true
+ eq(bg, 'var(--purple)')
+ } else {
+ eq(bg, hasEntered ? 'var(--purple)' : 'white')
+ }
+
+ // check that the mouse is trapped inside the box
+ if (hasEntered) {
+ if (insideY) {
+ eq(currentCircle[1], y)
+ } else {
+ const maxY =
+ currentCircle[1] === box.top + circleRadius + 1 ||
+ currentCircle[1] === box.top + circleRadius ||
+ currentCircle[1] === box.bottom - circleRadius ||
+ currentCircle[1] === box.bottom - circleRadius - 1
+ eq(maxY, true)
+ }
+ if (insideX) {
+ eq(currentCircle[0], x)
+ } else {
+ const maxX =
+ currentCircle[0] === box.left + circleRadius ||
+ currentCircle[0] === box.left + circleRadius + 1 ||
+ currentCircle[0] === box.right - circleRadius ||
+ currentCircle[0] === box.right - circleRadius - 1
+ eq(maxX, true)
+ }
+ }
+ move++
+ }
+})
+
+const random = (min, max) => {
+ if (!max) {
+ max = min
+ min = 0
+ }
+ min = Math.ceil(min)
+ max = Math.floor(max)
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
diff --git a/js/tests/nesting-organs-dom_test.mjs b/js/tests/nesting-organs-dom_test.mjs
new file mode 100644
index 000000000..c92a9ecb7
--- /dev/null
+++ b/js/tests/nesting-organs-dom_test.mjs
@@ -0,0 +1,90 @@
+export const tests = []
+
+tests.push(async ({ page, eq }) => {
+ // check that the HTML structure is correct & elements are nested properly
+ const elements = await page.$$eval('body', (nodes) => {
+ const toNode = (el) => {
+ const node = {}
+ node.tag = el.tagName.toLowerCase()
+ node.id = el.id
+ if (el.children.length) {
+ node.children = [...el.children].map(toNode)
+ }
+ return node
+ }
+ return [...nodes[0].children].map(toNode)
+ })
+ eq(expectedStructure, elements)
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the section selector style has been updated properly
+ eq.css('section', {
+ display: 'flex',
+ justifyContent: 'center',
+ })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check if the provided CSS has been correctly copy pasted
+ eq.css('div, p', {
+ border: '1px solid black',
+ padding: '10px',
+ margin: '0px',
+ borderRadius: '30px',
+ })
+
+ eq.css('#face', { alignItems: 'center' })
+
+ eq.css('#eyes', {
+ display: 'flex',
+ backgroundColor: 'yellow',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ borderRadius: '50px',
+ width: '200px',
+ })
+
+ eq.css('#torso', {
+ width: '200px',
+ backgroundColor: 'violet',
+ })
+})
+
+const expectedStructure = [
+ {
+ tag: 'section',
+
+ id: 'face',
+ children: [
+ {
+ tag: 'div',
+
+ id: 'eyes',
+ children: [
+ { tag: 'p', id: 'eye-left' },
+ { tag: 'p', id: 'eye-right' },
+ ],
+ },
+ ],
+ },
+ {
+ tag: 'section',
+
+ id: 'upper-body',
+ children: [
+ { tag: 'div', id: 'arm-left' },
+ { tag: 'div', id: 'torso' },
+ { tag: 'div', id: 'arm-right' },
+ ],
+ },
+ {
+ tag: 'section',
+
+ id: 'lower-body',
+ children: [
+ { tag: 'div', id: 'leg-left' },
+ { tag: 'div', id: 'leg-right' },
+ ],
+ },
+]
diff --git a/js/tests/pick-and-click-dom_test.mjs b/js/tests/pick-and-click-dom_test.mjs
new file mode 100644
index 000000000..be25d2d3d
--- /dev/null
+++ b/js/tests/pick-and-click-dom_test.mjs
@@ -0,0 +1,110 @@
+export const tests = []
+
+const between = (expected, min, max) => expected >= min && expected <= max
+
+export const setup = async ({ page, rgbToHsl }) => ({
+ bodyBgRgb: async () =>
+ rgbToHsl(await page.$eval('body', (body) => body.style.background)),
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the background color is changing on mouse move
+ // by simulating 20 moves, so there must be 20 different background colors
+ let move = 50
+ let bgs = []
+ while (move < 250) {
+ move += 10
+ const x = move
+ const y = move * 2
+ await page.mouse.move(x, y)
+ const bodyBg = await page.$eval('body', (body) => body.style.background)
+ const validColor = bodyBg.includes('rgb')
+ if (!validColor) continue
+ bgs.push(bodyBg)
+ }
+ const differentBgs = [...new Set(bgs)].length
+ eq(differentBgs, 20)
+})
+
+const near = (a, b) => a < b + 2.5 && a > b - 2.5
+const numVal = (n) => n.textContent.replace(/[^0-9,]/g, '')
+const generateCoords = (random) => [
+ [random(125, 500), random(125, 400)],
+ [random(125, 500), random(125, 400)],
+ [random(125, 500), random(125, 400)],
+]
+
+tests.push(async ({ page, eq, bodyBgRgb, random }) => {
+ // check that the hsl value displayed matches the current background color
+ for (const move of generateCoords(random)) {
+ await page.mouse.move(...move)
+ const a = await bodyBgRgb()
+ const b = (await page.$eval('.hsl', numVal)).split(',')
+ if (a.every((v, i) => near(v, Number(b[i])))) continue
+ throw Error(`hsl(${a.map(Math.round)}) to far from hsl(${b})`)
+ }
+})
+
+tests.push(async ({ page, eq, bodyBgRgb, random }) => {
+ // check that the hue value displayed matches the current background color
+ for (const move of generateCoords(random)) {
+ await page.mouse.move(...move)
+ const [h] = await bodyBgRgb()
+ const hue = await page.$eval('.hue', numVal)
+
+ if (!near(h, Number(hue))) {
+ console.log({ h, hue, c: near(h, Number(hue)) })
+ throw Error(`hue ${Math.round(h)} to far from ${hue}`)
+ }
+ }
+})
+
+tests.push(async ({ page, eq, bodyBgRgb, random }) => {
+ // check that the luminosity value displayed matches the current background color
+ for (const move of generateCoords(random)) {
+ await page.mouse.move(...move)
+ const [, , l] = await bodyBgRgb()
+ const lum = await page.$eval('.luminosity', numVal)
+
+ if (!near(l, Number(lum))) {
+ throw Error(`luminosity ${Math.round(l)} to far from ${lum}`)
+ }
+ }
+})
+
+tests.push(async ({ page, eq, bodyBgRgb, random }) => {
+ // check that the hsl value is copied in the clipboard on click
+ // Override readText if writeText is used due to a puppeteer bug
+ await page.evaluate(() => {
+ window.navigator.clipboard.writeText = async (text) => {
+ window.navigator.clipboard.readText = async () => text
+ }
+ })
+ for (const move of generateCoords(random)) {
+ await page.mouse.click(...move)
+ const clipboard = await page.evaluate(() =>
+ window.navigator.clipboard.readText()
+ )
+ const hslValue = await page.$eval('.hsl', (hsl) => hsl.textContent)
+ eq(hslValue, clipboard)
+ }
+})
+
+tests.push(async ({ page, eq, bodyBgRgb, random }) => {
+ // check that each svg axis is following the mouse
+ const [[mouseX, mouseY]] = generateCoords(random)
+ await page.mouse.move(mouseX, mouseY)
+ const axisX = await page.$eval('#axisX', (line) => [
+ line.getAttribute('x1'),
+ line.getAttribute('x2'),
+ ])
+ const axisY = await page.$eval('#axisY', (line) => [
+ line.getAttribute('y1'),
+ line.getAttribute('y2'),
+ ])
+
+ const checkAxisCoords = (coords) => Number([...new Set(coords)].join())
+
+ eq(checkAxisCoords(axisX), mouseX)
+ eq(checkAxisCoords(axisY), mouseY)
+})
diff --git a/js/tests/pimp-my-style-dom_test.mjs b/js/tests/pimp-my-style-dom_test.mjs
new file mode 100644
index 000000000..ffd17da66
--- /dev/null
+++ b/js/tests/pimp-my-style-dom_test.mjs
@@ -0,0 +1,36 @@
+import { styles } from './subjects/pimp-my-style/pimp-my-style.data.js'
+
+export const tests = []
+
+const formatClass = (limit, unpimp) =>
+ ['button', ...styles.slice(0, limit), unpimp && 'unpimp'].filter(Boolean)
+
+const max = styles.length - 1
+
+export const setup = async ({ page }) => {
+ const btn = await page.$('.button')
+
+ return {
+ btn,
+ getClass: async () =>
+ (await (await btn.getProperty('className')).jsonValue()).split(' '),
+ }
+}
+
+tests.push(async ({ page, eq, btn, getClass }) => {
+ // pimp
+ for (const i of styles.keys()) {
+ console.log('pimp click', i + 1)
+ await btn.click()
+ eq(formatClass(i + 1, i === max), await getClass())
+ }
+})
+
+tests.push(async ({ page, eq, btn, getClass }) => {
+ // unpimp !
+ for (const i of styles.keys()) {
+ console.log('unpimp click', i + 1)
+ await btn.click()
+ eq(formatClass(max - i, i !== max), await getClass())
+ }
+})
diff --git a/js/tests/select-and-style-dom_test.mjs b/js/tests/select-and-style-dom_test.mjs
new file mode 100644
index 000000000..a2198073c
--- /dev/null
+++ b/js/tests/select-and-style-dom_test.mjs
@@ -0,0 +1,49 @@
+
+export const tests = []
+
+// this test is commented out because it will not work for editor mode
+// tests.push(async ({ eq }) => {
+// // check the CSS stylesheet is linked in the head tag
+//
+// await eq.$('head link', {
+// rel: 'stylesheet',
+// href: 'http://localhost:9898/select-and-style/select-and-style.css',
+// })
+// })
+
+tests.push(async ({ eq }) => {
+ // check the universal selector has been declared properly
+
+ await eq.css('*', {
+ margin: '0px',
+ opacity: '0.85',
+ boxSizing: 'border-box',
+ })
+})
+
+
+tests.push(async ({ eq }) => {
+ // check that the body was styled
+
+ await eq.css('body', { height: '100vh' })
+})
+
+
+tests.push(async ({ eq }) => {
+ // check that sections elements are styled
+
+ await eq.css('section', {
+ padding: '20px',
+ width: '100%',
+ height: 'calc(33.3333%)',
+ })
+})
+
+
+tests.push(async ({ eq }) => {
+ // check that the individual sections are styled
+
+ await eq.css('#face', { backgroundColor: 'cyan' })
+ await eq.css('#upper-body', { backgroundColor: 'blueviolet' })
+ await eq.css('#lower-body', { backgroundColor: 'lightsalmon' })
+})
diff --git a/js/tests/skeleton-dom_test.mjs b/js/tests/skeleton-dom_test.mjs
new file mode 100644
index 000000000..832ee0393
--- /dev/null
+++ b/js/tests/skeleton-dom_test.mjs
@@ -0,0 +1,25 @@
+export const tests = []
+
+tests.push(async ({ page, eq }) => {
+ // check that the title tag is present & is set with some text
+ const title = await page.$eval('title', (node) => node.textContent)
+ if (!title.length) throw Error('missing title')
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the face
+
+ return eq.$('section:nth-child(1)', { textContent: 'face' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the upper-body
+
+ return eq.$('section:nth-child(2)', { textContent: 'upper-body' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the lower-body, my favorite part
+
+ return eq.$('section:nth-child(3)', { textContent: 'lower-body' })
+})
diff --git a/js/tests/test.mjs b/js/tests/test.mjs
index d4ddf774a..85ef56469 100644
--- a/js/tests/test.mjs
+++ b/js/tests/test.mjs
@@ -1,8 +1,10 @@
+import puppeteer from 'puppeteer'
import { join as joinPath, dirname, extname } from 'path'
-import { fileURLToPath } from 'url'
+import { readFile, writeFile } from 'fs/promises'
import { deepStrictEqual } from 'assert'
-import * as fs from 'fs'
-const { readFile, writeFile } = fs.promises
+import { fileURLToPath } from 'url'
+import http from 'http'
+import fs from 'fs'
global.window = global
global.fetch = url => {
@@ -21,6 +23,12 @@ global.fetch = url => {
const wait = delay => new Promise(s => setTimeout(s, delay))
const fail = fn => { try { fn() } catch (err) { return true } }
+const upperFirst = (str) => str[0].toUpperCase() + str.slice(1)
+const randStr = (n = 7) => Math.random().toString(36).slice(2, n)
+const between = (min, max) => {
+ max || (max = min, min = 0)
+ return Math.floor(Math.random() * (max - min) + min)
+}
const props = [String,Array]
.flatMap(({ prototype }) =>
@@ -38,9 +46,16 @@ const eq = (a, b) => {
}
const [solutionPath, name] = process.argv.slice(2)
+
+const tools = { eq, fail, wait, randStr, between, upperFirst }
+const cleanup = (exitCode = 0) => {
+ if (!tools.browser) process.exit(exitCode)
+ tools.server.close()
+ return tools.browser.close().finally(() => process.exit(exitCode))
+}
const fatal = (...args) => {
console.error(...args)
- process.exit(1)
+ return cleanup(1)
}
solutionPath || fatal('missing solution-path, usage:\nnode test solution-path exercise-name')
@@ -75,7 +90,7 @@ const any = arr =>
f(firstError)
})
-const testNode = async ({ name }) => {
+const testNode = async () => {
const path = `${solutionPath}/${name}.mjs`
return {
path,
@@ -84,7 +99,7 @@ const testNode = async ({ name }) => {
}
}
-const runInlineTests = async ({ json, name }) => {
+const runInlineTests = async ({ json }) => {
const restore = new Set()
const equal = deepStrictEqual
const saveArguments = (src, key) => {
@@ -103,12 +118,11 @@ const runInlineTests = async ({ json, name }) => {
const logs = []
console.log = (...args) => logs.push(args)
const die = (...args) => {
- logs.forEach((args) => console.info(...args))
- console.error(...args)
- process.exit(1)
+ logs.forEach((logArgs) => console.info(...logArgs))
+ return fatal(...args)
}
- const solution = await loadAndSanitizeSolution(name)
+ const solution = await loadAndSanitizeSolution()
for (const { description, code } of JSON.parse(json)) {
logs.length = 0
const [provided, tests] = code.includes('// Your code')
@@ -130,14 +144,15 @@ ${tests.trim()}`.trim()
console.info(`${description}:`, 'PASS')
} catch (err) {
console.info(`${description}:`, 'FAIL')
- console.info(`\n======= Code ======= \n${fullCode}`)
console.info('\n======= Error ======')
- die(' ->', err.message)
+ console.info(' ->', err.message, '\n')
+ console.info('\n======= Code =======')
+ die(fullCode)
}
}
}
-const loadAndSanitizeSolution = async name => {
+const loadAndSanitizeSolution = async () => {
const path = `${solutionPath}/${name}.js`
const rawCode = await read(path, "student solution")
@@ -151,18 +166,15 @@ const loadAndSanitizeSolution = async name => {
const runTests = async ({ url, path, code }) => {
const { setup, tests } = await import(url).catch(err =>
- fatal(`Unable to execute ${name} solution, error:\n${stackFmt(err, url)}`),
+ fatal(`Unable to execute ${name}, error:\n${stackFmt(err, url)}`),
)
- const randStr = (n = 7) => Math.random().toString(36).slice(2, n)
- const between = (min, max) => {
- max || (max = min, min = 0)
- return Math.floor(Math.random() * (max - min) + min)
- }
- const upperFirst = (str) => str[0].toUpperCase() + str.slice(1)
-
- const tools = { eq, fail, wait, code, path, randStr, between, upperFirst }
+ Object.assign(tools, { code, path })
tools.ctx = (await (setup && setup(tools))) || {}
+ const isDOM = name.endsWith('-dom')
+ if (isDOM) {
+ Object.assign(tools, await prepareForDOM({ code }))
+ }
let timeout
for (const [i, t] of tests.entries()) {
try {
@@ -172,19 +184,108 @@ const runTests = async ({ url, path, code }) => {
timeout = setTimeout(f, 60000, Error('Time limit reached (1min)'))
}),
])
- if (!(await waitWithTimeout)) {
+ if (!(await waitWithTimeout) && !isDOM) {
throw Error('Test failed')
}
} catch (err) {
- console.log(`test #${i+1} failed:\n${t.toString()}\n\nError:`)
- fatal(stackFmt(err, url))
+ console.info(`test #${i+1} failed:\n${t.toString()}\n`)
+ return fatal(stackFmt(err, url))
} finally {
clearTimeout(timeout)
}
}
- console.log(`${name} passed (${tests.length} tests)`)
+ cleanup(0)
+ console.info(`${name} passed (${tests.length} tests)`)
}
+// add puppeteer tests as JS language:
+const PORT = 9898
+const config = {
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+
+ // This will write shared memory files into /tmp instead of /dev/shm,
+ // because Docker’s default for /dev/shm is 64MB
+ '--disable-dev-shm-usage',
+ ],
+ headless: !process.env.DEBUG_PUPPETTEER,
+}
+
+// LEGACY random, use between instead (only used by dom exercise, to be replaced)
+const random = (min, max = min) => {
+ max === min && (min = 0)
+ min = Math.ceil(min)
+ return Math.floor(Math.random() * (Math.floor(max) - min + 1)) + min
+}
+
+const rgbToHsl = rgbStr => {
+ const [r, g, b] = rgbStr.slice(4, -1).split(',').map(Number)
+ const max = Math.max(r, g, b)
+ const min = Math.min(r, g, b)
+ const l = (max + min) / ((0xff * 2) / 100)
+
+ if (max === min) return [0, 0, l]
+
+ const d = max - min
+ const s = (d / (l > 50 ? 0xff * 2 - max - min : max + min)) * 100
+ if (max === r) return [((g - b) / d + (g < b && 6)) * 60, s, l]
+ return max === g
+ ? [((b - r) / d + 2) * 60, s, l]
+ : [((r - g) / d + 4) * 60, s, l]
+}
+
+const prepareForDOM = ({ code }, server) => new Promise((s, f) => (server = http
+ .createServer(({ url, method }, response) => {
+ console.info(method + ' ' + url)
+ // Loading either the `index.html` or the js code (student solution)
+ response.setHeader('Content-Type', 'text/html')
+ return response.end(``)
+ }))
+ .listen(PORT, async listenErr => {
+ if (listenErr) return f(listenErr)
+ try {
+ const browser = await puppeteer.launch(config)
+ const [page] = await browser.pages()
+ await page.goto(`http://localhost:${PORT}/index.html`)
+ deepStrictEqual.$ = async (selector, props) => {
+ const keys = Object.keys(props)
+ const extractProps = (node, props) => {
+ const fromProps = (a, b) => Object.fromEntries(Object.keys(b).map(k => [
+ k,
+ typeof b[k] === 'object' ? fromProps(a[k], b[k]) : a[k],
+ ]))
+ return fromProps(node, props)
+ }
+ const domProps = await page.$eval(selector, extractProps, props)
+ return deepStrictEqual(props, domProps)
+ }
+
+ deepStrictEqual.css = async (selector, props) => {
+ const cssProps = await page.evaluate((selector, props) => {
+ const styles = Object.fromEntries([...document.styleSheets]
+ .flatMap(({ cssRules }) => [...cssRules].map(r => [r.selectorText, r.style])))
+
+ if (!styles[selector]) {
+ throw Error(`css ${selector} did not match any declarations`)
+ }
+
+ return Object.fromEntries(Object.keys(props).map(k => [k, styles[selector][k]]))
+ }, selector, props)
+
+ return deepStrictEqual(props, cssProps)
+ }
+
+ browser
+ .defaultBrowserContext()
+ .overridePermissions(`http://localhost:${PORT}`, ['clipboard-read'])
+
+ s({ page, browser, random, rgbToHsl, eq: deepStrictEqual, server })
+ } catch (err) {
+ f(err)
+ }
+ }))
+
const main = async () => {
const { test, mode } = await any([
readTest(joinPath(root, `${name}.json`)),
@@ -192,19 +293,22 @@ const main = async () => {
readTest(joinPath(root, `${name}_test.mjs`)),
]).catch(ifNoEnt((err) => fatal(`Missing test for ${name}`)))
- if (mode === "node") return runTests(await testNode({ test, name }))
- if (mode === "inline") return runInlineTests({ json: test, name })
+ if (mode === "node") return runTests(await testNode())
+ if (mode === "inline") return runInlineTests({ json: test })
- const { rawCode, code, path } = await loadAndSanitizeSolution(name)
+ const { rawCode, code, path } = await loadAndSanitizeSolution()
const parts = test.split("// /*/ // ⚡")
const [inject, testCode] = parts.length < 2 ? ["", test] : parts
const combined = `${inject.trim()}\n${rawCode
.replace(inject.trim(), "")
.trim()}\n${testCode.trim()}\n`
+ // write to file and read file instead ?
const b64 = Buffer.from(combined).toString("base64")
const url = `data:text/javascript;base64,${b64}`
return runTests({ path, code, url })
}
-main().catch(err => fatal(err.stack))
+main().catch(err => {
+ fatal(err?.stack || Error('').stack)
+})
diff --git a/js/tests/the-calling-dom_test.mjs b/js/tests/the-calling-dom_test.mjs
new file mode 100644
index 000000000..e6aaea82e
--- /dev/null
+++ b/js/tests/the-calling-dom_test.mjs
@@ -0,0 +1,19 @@
+export const tests = []
+
+tests.push(async ({ page, eq }) => {
+ // check the face
+
+ return eq.$('section#face', { textContent: '' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the upper-body
+
+ return eq.$('section#upper-body', { textContent: '' })
+})
+
+tests.push(async ({ page, eq }) => {
+ // check the lower-body, my favorite part
+
+ return eq.$('section#lower-body', { textContent: '' })
+})
diff --git a/js/tests/where-do-we-go-dom_test.mjs b/js/tests/where-do-we-go-dom_test.mjs
new file mode 100644
index 000000000..ec28cca12
--- /dev/null
+++ b/js/tests/where-do-we-go-dom_test.mjs
@@ -0,0 +1,174 @@
+import { places } from './subjects/where-do-we-go/where-do-we-go.data.js'
+
+export const tests = []
+
+const random = (min, max) => {
+ if (!max) {
+ max = min
+ min = 0
+ }
+ min = Math.ceil(min)
+ max = Math.floor(max)
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
+
+const getDegree = coordinates => {
+ const north = coordinates.includes('N')
+ const degree = coordinates.split("'")[0].replace('°', '.')
+ return north ? degree : -degree
+}
+
+export const setup = async ({ page }) => {
+ return {
+ getDirection: async () =>
+ await page.$eval('.direction', direction => direction.textContent),
+ }
+}
+
+const sortedPlaces = places.sort(
+ (a, b) => getDegree(b.coordinates) - getDegree(a.coordinates),
+)
+
+const dataNames = sortedPlaces.map(({ name }) =>
+ name
+ .split(',')[0]
+ .toLowerCase()
+ .split(' ')
+ .join('-'),
+)
+
+tests.push(async ({ page, eq }) => {
+ const { width, height } = await page.evaluate(() => ({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }))
+
+ const sections = await page.$$eval('section', sections =>
+ sections.map(section => {
+ return [
+ section.getBoundingClientRect().width,
+ section.getBoundingClientRect().height,
+ ]
+ }),
+ )
+
+ console.log(`Must contain ${places.length} places`)
+ // check that the correct amount of sections has been generated
+ eq(sections.length, places.length)
+ // check that all the sections are fullscreen-size
+ eq([...new Set(...sections)], [width, height])
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the sections have been generated with the correponding images as background,
+ // and sorted in the right order (from the Northest to the Southest)
+ const imageNames = await page.$$eval('section', sections =>
+ sections.map(section => {
+ const test = section.style.background.split('.jpg')[0].split('/')
+ return test[test.length - 1]
+ }),
+ )
+
+ console.log(`Must be sorted from North to South`)
+ console.log(`Must have the right images in background`)
+ eq(imageNames, dataNames)
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the location indicator is updating according to the image displayed
+ let step = 1
+ while (step < 6) {
+ await page.evaluate(() => {
+ window.scrollBy(0, window.innerHeight + 200)
+ })
+
+ await page.waitForTimeout(150)
+
+ const location = await page.$eval('.location', location => [
+ ...location.textContent.split('\n'),
+ location.style.color,
+ ])
+
+ const currentLocationIndex = await page.evaluate(() =>
+ Math.round(window.scrollY / window.innerHeight),
+ )
+ const currentLocation = sortedPlaces[currentLocationIndex]
+ const { name, coordinates, color } = currentLocation
+ const expectedLocation = [name, coordinates, color]
+
+ // check that the location indicator and the displayed location contents are matching
+ console.log(`Scroll #${step}: displaying ${location[0]}`)
+ eq(location, expectedLocation)
+ step++
+ }
+})
+
+tests.push(async ({ page, eq, getDirection }) => {
+ // check that the compass is pointing 'S' when scrolling down
+ await page.evaluate(() => {
+ window.scrollBy(0, window.innerHeight)
+ })
+
+ await page.waitForTimeout(100)
+
+ const direction = (await getDirection()).includes('S')
+ ? 'S'
+ : await getDirection()
+
+ console.log('Scroll down: pointing', direction)
+ eq(direction, 'S')
+})
+
+tests.push(async ({ page, eq, getDirection }) => {
+ // check that the compass is pointing 'N' when scrolling up
+ await page.evaluate(() => {
+ window.scrollBy(0, -100)
+ })
+
+ await page.waitForTimeout(100)
+
+ const direction = (await getDirection()).includes('N')
+ ? 'N'
+ : await getDirection()
+
+ console.log('Scroll up: pointing', direction)
+ eq(direction, 'N')
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the location target attribute is set to '_blank' to open a new tab
+ const locationTarget = await page.$eval('.location', ({ target }) => target)
+ console.log(
+ `Location tag target attribute ${
+ locationTarget === '_blank' ? '' : 'not '
+ }set to open a new tab`,
+ )
+ eq(locationTarget, '_blank')
+})
+
+tests.push(async ({ page, eq }) => {
+ // check that the location href is valid
+ const location = await page.$eval('.location', ({ href, textContent }) => ({
+ href,
+ textContent,
+ }))
+ const isValidUrl = location.href.includes('google.com/maps')
+ const coords = location.textContent.split('\n')[1]
+ const convertedUrl = location.href
+ .split('%C2%B0')
+ .join('°')
+ .split('%22')
+ .join('"')
+ .split('%20')
+ .join(' ')
+ const isValidCoordinates = convertedUrl.includes(coords)
+
+ console.log('URL', location.href, isValidUrl ? 'valid' : 'invalid')
+ eq(isValidUrl, true)
+ console.log(
+ `Matching coordinates ${coords} ${
+ isValidCoordinates ? '' : 'not '
+ }found in URL`,
+ )
+ eq(isValidCoordinates, true)
+})
diff --git a/subjects/action-reaction-dom/README.md b/subjects/action-reaction-dom/README.md
new file mode 100644
index 000000000..1dffd1e1e
--- /dev/null
+++ b/subjects/action-reaction-dom/README.md
@@ -0,0 +1,81 @@
+## Action - reaction!
+
+### Resources
+
+We provide you with some content to get started smoothly, check it out!
+
+- Video [querySelector](https://www.youtube.com/watch?v=m34qd7aGMBo&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=12)
+- Video [DOM JS - Add an event listener to an element](https://www.youtube.com/watch?v=ydRv338Fl8Y)
+- Video [DOM JS - Set an element's properties](https://www.youtube.com/watch?v=4O6zSVR0ufw&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=14)
+- Video [DOM JS - classList: toggle, replace & contains](https://www.youtube.com/watch?v=amEBcoTYw0s&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=20)
+- Video [DOM JS - Set an element's inline style](https://www.youtube.com/watch?v=pxlYKvju1z8&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=15)
+- [Memo DOM JS](https://github.com/nan-academy/js-training/blob/gh-pages/examples/dom.js)
+
+### Instructions
+
+OK, you have now connected HTML, CSS and JS altogether ; big day! Excited? Exhausted?
+Well so far, you've only scratched the surface... Let's go deeper into the power of JS! You're going to add some interaction ; the webpage will react when a user action will happen, called an [event](https://developer.mozilla.org/en-US/docs/Web/Events) (a click, a key pressed, a mouse move, etc.).
+
+Let's put a button on the top right corner of the page, that will toggle (close or open) the left eye when clicked.
+Add it in the HTML structure:
+
+```html
+
+```
+
+Add this CSS style:
+
+```css
+button {
+ z-index: 1;
+ position: fixed;
+ top: 30px;
+ right: 30px;
+ padding: 20px;
+}
+```
+
+In the JS file, get the HTML button element with [`querySelector`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector), and [add an event listener](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) on [`click` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#javascript), triggering a function that will:
+
+- change the [text content](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent) of the button: if the eye is open, write "Click to close the left eye", if the eye is closed, write "Click to open the left eye"
+- [toggle](https://css-tricks.com/snippets/javascript/the-classlist-api/) the class `eye-closed` in the `classList` of the `eye-left` HTML element
+- change the background color of the `eye-left`: if the eye is open, to "red", if the eye is closed, to "black"
+
+### Code examples
+
+Add an event listener on click on a button that triggers a function:
+
+```js
+// events allow you to react to user inputs
+// (any action with the mouse, keyboard, etc.)
+// it's the foundation of the interactivity of your website
+// each event is linked to an element or the window
+
+// for this example we will attach a click event to a button
+// first we select the button HTML element
+const button = document.querySelector('button')
+
+// we need to create a function
+// that will be executed when the event is triggered
+// let's call it `handleClick`
+const handleClick = (event) => {
+ // do semething when the button has been clicked
+}
+
+// register the event:
+button.addEventListener('click', handleClick)
+// here we ask the button to call our `handleClick` function
+// on the 'click' event, so every time it's clicked
+```
+
+### Expected output
+
+[This](https://youtu.be/Wkar5SmswDo) is what you should see in the browser.
+
+### Notions
+
+- [`querySelector`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector)
+- [Text content of a HTML element](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent)
+- [`addEventListener`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) / [`click` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#javascript)
+- [`classList` / `toggle`](https://css-tricks.com/snippets/javascript/the-classlist-api/)
+- [Setting style with JS](https://developer.mozilla.org/en-US/docs/Web/API/ElementCSSInlineStyle/style#setting_styles)
diff --git a/subjects/bring-it-to-life-dom/README.md b/subjects/bring-it-to-life-dom/README.md
new file mode 100644
index 000000000..f3f9a0e20
--- /dev/null
+++ b/subjects/bring-it-to-life-dom/README.md
@@ -0,0 +1,50 @@
+## Bring it to life
+
+### Resources
+
+We provide you with some content to get started smoothly, check it out!
+
+- Video [DOM JS - getElementById](https://www.youtube.com/watch?v=34kAR8yBtDM&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=8)
+- Video [DOM JS - Set an element's inline style](https://www.youtube.com/watch?v=pxlYKvju1z8&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=15)
+- Video [DOM JS - classList: add & remove](https://www.youtube.com/watch?v=uQEM-3_4vPA&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=17)
+- [Memo DOM JS](https://github.com/nan-academy/js-training/blob/gh-pages/examples/dom.js)
+
+### Instructions
+
+Still there? Well done! But hold on, here begins the serious part... In order to control your creation, you're going to plug its brain: JavaScript.
+
+You're going to close the left eye of your entity.
+To do so, you have to target the `eye-left` HTML element by its `id` thanks to the [`getElementById`](https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById) method. Then, [set the `style`](https://developer.mozilla.org/en-US/docs/Web/API/ElementCSSInlineStyle/style#setting_styles) of your `eye-left` to set its background color to "black". We also need to modify its shape ; for that we are going to add a new class to it.
+First, define this new class in your style block:
+
+```
+.eye-closed {
+ height: 4px;
+ padding: 0 5px;
+ border-radius: 10px;
+}
+```
+
+In the JS code, add the freshly-created class `eye-closed` to the [`classList`](https://css-tricks.com/snippets/javascript/the-classlist-api/) of your `eye-left` element.
+
+Reload the page - it's alive! Your JS brain has control and orders your HTML/CSS body to close one eye.
+
+### Code examples
+
+Get a HTML element by its `id` & set its inline style:
+
+```js
+const myDiv = document.getElementById('my-div')
+myDiv.style.color = 'green'
+```
+
+### Expected output
+
+This is what you should see in the browser:
+![](https://github.com/01-edu/public/raw/master/subjects/bring-it-to-life/bring-it-to-life.png)
+
+### Notions
+
+- [`getElementById`](https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById)
+- [`classList` / `add()`](https://css-tricks.com/snippets/javascript/the-classlist-api/)
+- [Setting style with JS](https://developer.mozilla.org/en-US/docs/Web/API/ElementCSSInlineStyle/style#setting_styles)
diff --git a/subjects/bring-it-to-life-dom/bring-it-to-life.png b/subjects/bring-it-to-life-dom/bring-it-to-life.png
new file mode 100644
index 000000000..d418d20a4
Binary files /dev/null and b/subjects/bring-it-to-life-dom/bring-it-to-life.png differ
diff --git a/subjects/build-brick-and-break-dom/README.md b/subjects/build-brick-and-break-dom/README.md
new file mode 100644
index 000000000..d90b84842
--- /dev/null
+++ b/subjects/build-brick-and-break-dom/README.md
@@ -0,0 +1,44 @@
+## Build brick and break
+
+### Instructions
+
+Today, your mission is to build a 3-column brick tower, maintain it and finally break it!
+
+- Create a function `build` which will create and display the given amount of bricks passed as argument:
+
+ - each brick has to be created as a `div` and added to the page at a regular interval of 100ms,
+ - each brick will receive a unique `id` property, like following:
+
+ ```html
+
+ ```
+
+ - each brick in the middle column has to be set with the custom data attribute `foundation` receiving the value `true`
+
+- Each one of the two emojis in the top-right corner fires a function on click:
+
+ - 🔨 triggers the function `repair`: write the body of that function, which receives any number of `ids`, and for each `id`, retrieves the HTML element and set a custom attribute `repaired` set to `in progress` if it is a brick situated in the middle column, and `true` if not
+ - 🧨 triggers the function `destroy`: write the body of that function, which removes the current last brick in the tower
+
+### Notions
+
+- [`createElement()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement)
+- [`append()`](https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/append)
+- [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)
+- [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval) / [`clearInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/clearInterval)
+- [`hasAttribute()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/hasAttribute)
+- [dataset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/dataset) / [`data-*`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*)
+- [`remove()`](https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove)
+
+### Files
+
+You only need to create & submit the JS file `build-brick-and-break.js` ; we're providing you the following file to download (click right and save link) & test locally:
+
+- the HTML file [build-brick-and-break.html](./build-brick-and-break.html) to open in the browser, which includes:
+
+ - the JS script running some code, and which will also allow to run yours
+ - some CSS pre-styled classes: feel free to use those as they are, or modify them
+
+### Expected result
+
+You can see an example of the expected result [here](https://youtu.be/OjSP_7u9CZ4)
diff --git a/subjects/build-brick-and-break-dom/build-brick-and-break.html b/subjects/build-brick-and-break-dom/build-brick-and-break.html
new file mode 100644
index 000000000..c8d6bceca
--- /dev/null
+++ b/subjects/build-brick-and-break-dom/build-brick-and-break.html
@@ -0,0 +1,108 @@
+
+
+
+ Build brick and break
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/subjects/class-that-dom/README.md b/subjects/class-that-dom/README.md
new file mode 100644
index 000000000..8679b4dcd
--- /dev/null
+++ b/subjects/class-that-dom/README.md
@@ -0,0 +1,57 @@
+## Class that!
+
+### Resources
+
+We provide you with some content to get started smoothly, check it out!
+
+- Video [CSS - Set & style with CSS class](https://www.youtube.com/watch?v=-U397k4VloU&list=PLHyAJ_GrRtf979iZZ1N3qYMfsPj9PCCrF&index=6)
+
+### Instructions
+
+Alright, your being is almost done, some elements still need a bit more shaping and then we'll make it come to life!
+If you look at your page, you can observe that some elements come by pair: the eyes, the arms & the legs. It is the same organ, one on the left and one on the right ; they have exactly the same shape, so for practicity & to avoid to repeat twice the same style, we're not going to use their `id` to style them, but a [`class`](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors) ; contrary to an `id`, a `class` can be attributed to several different elements with common rulesets, and so the style defined for that class will apply to all the HTML elements that have it.
+
+Create the 3 following classes, setting them with the given rulesets, & attribute them to the corresponding HTML elements:
+
+- class `eye`:
+ - `width` of 60 pixels
+ - `height` of 60 pixels
+ - `background-color` "red"
+ - `border-radius` of 50%
+ - attributed to `eye-left` & `eye-right`
+- class `arm`:
+ - `background-color` "aquamarine"
+ - attributed to `arm-left` & `arm-right`
+- class `leg`:
+ - `background-color` "dodgerblue"
+ - attributed to `leg-left` & `leg-right`
+
+Note that you can attribute several classes to a same element ; create the class `body-member`, which set the `width` to 50 pixels and the `margin` to 30 pixels, and add it to the `class` attribute of those elements: `arm-left`, `arm-right`, `leg-left` & `leg-right`.
+
+### Code examples
+
+Declare a class `my-first-class` and style it with a `color` to `"blue"` and a `background-color` to `"pink"`:
+
+```css
+.my-first-class {
+ color: blue;
+ background-color: pink;
+}
+```
+
+Apply classes to HTML elements:
+
+```html
+
+
+
+```
+
+### Expected output
+
+This is what you should see in the browser:
+![](https://github.com/01-edu/public/raw/master/subjects/class-that/class-that.png)
+
+### Notions
+
+- [CSS class](https://developer.mozilla.org/en-US/docs/Web/CSS/Class_selectors)
diff --git a/subjects/class-that-dom/class-that.png b/subjects/class-that-dom/class-that.png
new file mode 100644
index 000000000..9d7f4d1df
Binary files /dev/null and b/subjects/class-that-dom/class-that.png differ
diff --git a/subjects/fifty-shades-of-cold-dom/README.md b/subjects/fifty-shades-of-cold-dom/README.md
new file mode 100644
index 000000000..9ad8d2e84
--- /dev/null
+++ b/subjects/fifty-shades-of-cold-dom/README.md
@@ -0,0 +1,46 @@
+## Fifty shades of cold
+
+### Instructions
+
+You've been asked to freshen a webpage atmosphere by displaying shades of cold colors.
+
+Check the `colors` array provided in the data file below.
+
+- Write the `generateClasses` function which creates a `
+
+
+
+
+