mirror of https://github.com/01-edu/public.git
310 lines
9.4 KiB
JavaScript
310 lines
9.4 KiB
JavaScript
import puppeteer from 'puppeteer'
|
||
import { join as joinPath, dirname, extname } from 'path'
|
||
import { readFile, writeFile } from 'fs/promises'
|
||
import { deepStrictEqual } from 'assert'
|
||
import { fileURLToPath } from 'url'
|
||
import { tmpdir } from 'os'
|
||
import http from 'http'
|
||
import fs from 'fs'
|
||
|
||
global.window = global
|
||
global._fetch = fetch
|
||
global.fetch = url => {
|
||
// this is a fake implementation of fetch for the tester
|
||
const accessBody = async () => { throw Error('body unavailable') }
|
||
return {
|
||
ok: false,
|
||
type: 'basic',
|
||
status: 500,
|
||
statusText: 'Internal Server Error',
|
||
json: accessBody,
|
||
text: accessBody,
|
||
}
|
||
}
|
||
|
||
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 }) =>
|
||
Object.getOwnPropertyNames(prototype)
|
||
.map(key => ({ key, value: prototype[key], src: prototype })))
|
||
.filter(p => typeof p.value === 'function')
|
||
|
||
const eq = (a, b) => {
|
||
const changed = []
|
||
for (const p of props) { !p.src[p.key] && (changed[changed.length] = p) }
|
||
for (const p of changed) { p.src[p.key] = p.value }
|
||
deepStrictEqual(a, b)
|
||
for (const p of changed) { p.src[p.key] = undefined }
|
||
return true
|
||
}
|
||
|
||
const [solutionPath, name] = process.argv.slice(2)
|
||
|
||
const tools = { eq, fail, wait, randStr, between, upperFirst }
|
||
const fatal = (...args) => {
|
||
console.error(...args)
|
||
process.exit(1)
|
||
}
|
||
|
||
solutionPath || fatal('missing solution-path, usage:\nnode test solution-path exercise-name')
|
||
name || fatal('missing exercise, usage:\nnode test solution-path exercise-name')
|
||
|
||
const ifNoEnt = fn => err => {
|
||
if (err.code !== 'ENOENT') throw err
|
||
fn(err)
|
||
}
|
||
|
||
const root = dirname(fileURLToPath(import.meta.url))
|
||
const read = (filename, description) =>
|
||
readFile(filename, 'utf8').catch(
|
||
ifNoEnt(() => fatal(`Missing ${description} for ${name}`)),
|
||
)
|
||
|
||
const modes = { '.js': 'function', '.mjs': 'node', '.json': 'inline' }
|
||
const readTest = filename => readFile(filename, 'utf8')
|
||
.then(test => ({ test, mode: modes[extname(filename)] }))
|
||
|
||
const stackFmt = (err, url) => {
|
||
for (const p of props) { p.src[p.key] = p.value }
|
||
if (err instanceof Error) return err.stack.split(url).join(`${name}.js`)
|
||
throw Error(`Unexpected type thrown: ${typeof err}. usage: throw Error('my message')`)
|
||
}
|
||
|
||
const any = arr =>
|
||
new Promise(async (s, f) => {
|
||
let firstError
|
||
const setError = err => firstError || (firstError = err)
|
||
await Promise.all(arr.map(p => p.then(s, setError)))
|
||
f(firstError)
|
||
})
|
||
|
||
const testNode = async () => {
|
||
const path = `${solutionPath}/${name}.mjs`
|
||
return {
|
||
path,
|
||
url: joinPath(root, `${name}_test.mjs`),
|
||
code: await read(path, 'student solution'),
|
||
}
|
||
}
|
||
|
||
const runInlineTests = async ({ json }) => {
|
||
const restore = new Set()
|
||
const equal = deepStrictEqual
|
||
const saveArguments = (src, key) => {
|
||
const savedArgs = []
|
||
const fn = src[key]
|
||
src[key] = (...args) => {
|
||
savedArgs.push(args)
|
||
return fn(...args)
|
||
}
|
||
|
||
restore.add(() => (src[key] = fn))
|
||
|
||
return savedArgs
|
||
}
|
||
|
||
const logs = []
|
||
console.log = (...args) => logs.push(args)
|
||
const die = (...args) => {
|
||
logs.forEach((logArgs) => console.info(...logArgs))
|
||
fatal(...args)
|
||
}
|
||
|
||
const solution = await loadAndSanitizeSolution()
|
||
for (const { description, code } of JSON.parse(json)) {
|
||
logs.length = 0
|
||
const [provided, tests] = code.includes('// Your code')
|
||
? code.split('// Your code')
|
||
: ['', code]
|
||
|
||
const fullCode = `
|
||
${provided ? '// Provided setup' : ''}
|
||
${provided.trim()}
|
||
|
||
// Your code
|
||
${solution.code.trim()};
|
||
|
||
// The tests
|
||
${tests.trim()};`.trim()
|
||
|
||
try {
|
||
eval(fullCode)
|
||
console.info(`${description}:`, 'PASS')
|
||
} catch (err) {
|
||
console.info(`${description}:`, 'FAIL')
|
||
console.info('\n======= Error ======')
|
||
console.info(' ->', err.message, '\n')
|
||
console.info('\n======= Code =======')
|
||
die(fullCode)
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadAndSanitizeSolution = async () => {
|
||
const path = `${solutionPath}/${name}.js`
|
||
const rawCode = await read(path, "student solution")
|
||
|
||
// this is a very crude and basic removal of comments
|
||
// since checking code is only use to prevent cheating
|
||
// it's not that important if it doesn't work 100% of the time.
|
||
const code = rawCode.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "").trim()
|
||
if (code.includes("import")) fatal("import keyword not allowed")
|
||
return { code, rawCode, path }
|
||
}
|
||
|
||
const runTests = async ({ url, path, code }) => {
|
||
const { setup, tests } = await import(url).catch(err =>
|
||
fatal(`Unable to execute ${name}, error:\n${stackFmt(err, url)}`),
|
||
)
|
||
|
||
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 {
|
||
const waitWithTimeout = Promise.race([
|
||
t(tools),
|
||
new Promise((s, f) => {
|
||
timeout = setTimeout(f, 60000, Error('Time limit reached (1min)'))
|
||
}),
|
||
])
|
||
if (!(await waitWithTimeout) && !isDOM) {
|
||
throw Error('Test failed')
|
||
}
|
||
} catch (err) {
|
||
console.info(`test #${i+1} failed:\n${t.toString()}\n`)
|
||
fatal(stackFmt(err, url))
|
||
} finally {
|
||
clearTimeout(timeout)
|
||
}
|
||
}
|
||
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(`<script type="module">${code}</script>`)
|
||
}))
|
||
.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`)),
|
||
readTest(joinPath(root, `${name}_test.js`)),
|
||
readTest(joinPath(root, `${name}_test.mjs`)),
|
||
]).catch(ifNoEnt((err) => fatal(`Missing test for ${name}`)))
|
||
|
||
if (mode === "node") return runTests(await testNode())
|
||
if (mode === "inline") return runInlineTests({ json: test })
|
||
|
||
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`
|
||
|
||
const url = `${tmpdir()}/${name}.mjs`
|
||
await writeFile(url, combined)
|
||
return runTests({ path, code, url })
|
||
}
|
||
|
||
main().then(
|
||
() => process.exit(0),
|
||
err => fatal(err?.stack || Error('').stack),
|
||
)
|