Skip to content
Snippets Groups Projects
Commit bd33a179 authored by Dan Allen's avatar Dan Allen
Browse files

refactor test harness

* invoke Mocha using the main interface
* structures tests using Mocha contexts
* add a flight of tests that use the proposed ASG
parent e40e74f8
No related branches found
No related tags found
No related merge requests found
Pipeline #16558 waiting for manual action
Showing
with 833 additions and 790 deletions
import { exec } from 'node:child_process'
export default class AdapterManager {
constructor (adapter) {
this.adapter = adapter
}
post (data) {
return new Promise((resolve, reject) => {
const cp = exec(this.adapter, (err, stdout, _stderr) => {
if (err) return reject(err)
resolve(JSON.parse(stdout))
})
cp.stdin.on('error', (err) => undefined).end(data) // eslint-disable-line node/handle-callback-err
})
}
start () {
}
stop () {
}
}
import { fileURLToPath } from 'node:url'
import ospath from 'node:path'
export function toModuleContext ({ url }) {
const __filename = fileURLToPath(url)
const __dirname = ospath.dirname(__filename)
return { __dirname, __filename }
}
import runner from './runner.js'
import run from './runner.js'
const adapter = process.env.ASCIIDOC_TCK_ADAPTER
if (adapter) {
;(async () => {
try {
const result = await runner(adapter)
process.exit(result.failures)
} catch (e) {
console.error('Something wrong happened!', e)
}
})()
;(async () =>
run(adapter).then(
(result) => process.exit(result.failures),
(err) => console.error('Something went wrong!', err) || process.exit(1)
)
)()
} else {
console.error('No tests run.')
console.error('You must specify the path to a TCK adapter using the ASCIIDOC_TCK_ADAPTER environment variable.')
process.exit(1)
}
import { exec as execProcess } from 'node:child_process'
import fsp from 'node:fs/promises'
import AdapterManager from './adapter-manager.js'
import loadTests from './test-loader.js'
import Mocha from './test-framework.js'
import ospath from 'node:path'
import { fileURLToPath } from 'node:url'
import Mocha from 'mocha'
import chai from 'chai'
import glob from 'glob'
import { toModuleContext } from './helpers.js'
const __dirname = ospath.dirname(fileURLToPath(import.meta.url))
const testsDir = ospath.join(__dirname, '..', '..', 'tests')
const PACKAGE_DIR = ospath.join(toModuleContext(import.meta).__dirname, '../..')
async function getFiles () {
return new Promise((resolve, reject) => {
glob(ospath.join(testsDir, '**/*-input.adoc'), async function (err, files) {
if (err) {
reject(err)
} else {
resolve(files)
}
})
export default async (adapter, options = {}) => {
const mocha = new Mocha({ timeout: 5000, ...options })
// TODO start and stop adapter in global hooks
const adapterManager = new AdapterManager(adapter)
Object.assign(mocha.suite.ctx, {
async parse (input) {
return adapterManager.post(input)
},
tests: await loadTests(ospath.join(PACKAGE_DIR, 'tests')),
})
}
async function exec (adapter, content) {
return new Promise((resolve, reject) => {
try {
const childProcess = execProcess(adapter, (error, stdout, stderr) => {
if (error) {
reject(error)
return
}
resolve({ stdout, stderr })
})
childProcess.stdin.on('error', (err) => {
reject(err)
})
write(childProcess.stdin, content, () => {
childProcess.stdin.end()
})
} catch (e) {
reject(e)
}
})
}
function write (stream, data, cb) {
if (!stream.write(data)) {
stream.once('drain', cb)
} else {
process.nextTick(cb)
}
}
export default async function (adapter, options) {
const defaultOptions = {
timeout: 60 * 1000,
}
if (options === undefined) {
options = {}
}
options = { ...defaultOptions, ...options }
const files = await getFiles()
const mocha = new Mocha(options)
const Test = Mocha.Test
const Suite = Mocha.Suite
const suites = {}
for (const file of files) {
const relative = ospath.relative(testsDir, file)
const suiteName = ospath.dirname(relative)
let suite
if (suiteName in suites) {
suite = suites[suiteName]
} else {
suite = Suite.create(mocha.suite, suiteName)
suites[suiteName] = suite
}
const testName = ospath.basename(file).replace(/-input\.adoc$/, '')
suite.addTest(
new Test(testName, async () => {
try {
const input = await fsp.readFile(file, 'utf8')
const expectedFile = ospath.join(testsDir, suiteName, testName + '-output.json')
const expectedOutput = await fsp.readFile(expectedFile, 'utf8')
const childProcess = await exec(adapter, input)
const actualOutput = JSON.parse(childProcess.stdout)
chai.expect(actualOutput).to.deep.equal(JSON.parse(expectedOutput))
} catch (e) {
chai.expect.fail(e.message)
}
})
)
}
mocha.files = [
ospath.join(PACKAGE_DIR, 'harness/lib/suites/block-test.js'),
ospath.join(PACKAGE_DIR, 'harness/lib/suites/inline-test.js'),
]
await mocha.loadFilesAsync()
return new Promise((resolve) => {
const runner = mocha.run((result) => {
resolve({
failures: result,
stats: runner.stats,
})
})
const runner = mocha.run((failures) => resolve({ failures, stats: runner.stats }))
})
}
/* eslint-env mocha */
import { expect, makeTests, stringifyASG } from '../test-framework.js'
describe('block', function () {
const { parse, tests } = this.parent.ctx
const blockTests = tests.find((it) => it.type === 'context' && it.name === 'block').entries
makeTests(blockTests, async ({ input, inputPath, expected, expectedWithoutLocations }) => {
const actual = await parse(input)
if (expected == null) {
// Q: can we write data to expect file automatically?
// TODO only output expected if environment variable is set
console.log(stringifyASG(actual))
} else {
const msg = `actual output does not match expected output for ${inputPath}`
// TODO wrap this in expectAsg helper
expect(actual, msg).to.eql('location' in actual ? expected : expectedWithoutLocations)
}
})
})
/* eslint-env mocha */
import { expect, makeTests, stringifyASG } from '../test-framework.js'
describe('inline', function () {
const { parse, tests } = this.parent.ctx
const inlineTests = tests.find((it) => it.type === 'context' && it.name === 'inline').entries
makeTests(inlineTests, async ({ input, inputPath, expected, expectedWithoutLocations }) => {
const actualRoot = await parse(input)
expect(actualRoot).to.have.property('blocks')
expect(actualRoot.blocks).to.have.lengthOf(1)
expect(actualRoot.blocks[0]).to.have.property('inlines')
const actual = actualRoot.blocks[0].inlines
if (expected == null) {
// Q: can we write data to expected file automatically?
// TODO only output expected if environment variable is set
console.log(stringifyASG(actual))
} else {
const msg = `actual output does not match expected output for ${inputPath}`
// TODO wrap this in expectAsg helper
expect(actual, msg).to.eql(!actual.length || 'location' in actual[0] ? expected : expectedWithoutLocations)
}
})
})
export { expect } from 'chai'
export function makeTests (tests, testBlock) {
/* eslint-env mocha */
for (const test of tests) {
test.type === 'context'
? describe(test.name, () => makeTests(test.entries, testBlock))
: (it[test.condition] || it)(test.name, () => testBlock(test.data))
}
}
export function stringifyASG (asg) {
const locations = []
return JSON
.stringify(asg, (key, val) => key === 'location' ? locations.push(val) - 1 : val, 2)
.replace(/("location": )(\d+)/g, (_, key, idx) => {
return key + JSON.stringify(locations[Number(idx)], null, 2).replace(/\n */g, ' ')
})
}
export { default } from 'mocha'
import fsp from 'node:fs/promises'
import ospath from 'node:path'
import yaml from 'js-yaml'
export default async function loadTests (dir = process.cwd(), base = process.cwd()) {
const entries = []
if (!ospath.isAbsolute(dir)) dir = ospath.resolve(dir)
for await (const dirent of await fsp.opendir(dir)) {
const name = dirent.name
if (dirent.isDirectory()) {
const childEntries = await loadTests(ospath.join(dir, name), base)
if (childEntries.length) entries.push({ type: 'context', name, entries: childEntries })
} else if (name.endsWith('-input.adoc')) {
const basename = name.slice(0, name.length - 11)
const inputPath = ospath.join(dir, name)
const outputPath = ospath.join(dir, basename + '-output.json')
const configPath = ospath.join(dir, basename + '-config.yml')
entries.push(
await Promise.all([
fsp.readFile(inputPath, 'utf8'),
fsp.readFile(outputPath).then(
(data) => [JSON.parse(data), JSON.parse(data, (key, val) => key === 'location' ? undefined : val)],
() => []
),
fsp.readFile(configPath).then(yaml.load, () => Object.create(Object.prototype)),
]).then(([input, [expected, expectedWithoutLocations], config]) => {
if (config.trim_trailing_whitespace) {
input = input.trimEnd()
} else if (config.ensure_trailing_newline) {
if (input[input.length - 1] !== '\n') input += '\n'
} else if (input[input.length - 1] === '\n') {
input = input.slice(0, input.length - 1)
}
return {
type: 'test',
name: config.name || basename.replace(/-/g, ' '),
condition: config.only ? 'only' : config.skip ? 'skip' : undefined,
data: {
basename,
inputPath: ospath.relative(base, inputPath),
outputPath: ospath.relative(base, outputPath),
input,
expected,
expectedWithoutLocations,
},
}
})
)
}
}
return entries.sort((a, b) => {
if (a.type !== b.type) return a.type === 'test' ? -1 : 1
return a.name.localeCompare(b.name)
})
}
......@@ -5,14 +5,12 @@ import runner from '#runner'
describe('runner()', () => {
it('should fail when using an non existing adapter', async () => {
const result = await runner('non-existing-adapter', {
reporter: function () {
/* noop */
},
})
expect(result.failures).to.eq(2)
expect(result.stats.suites).to.eq(1)
expect(result.stats.tests).to.eq(2)
expect(result.stats.failures).to.eq(2)
const noop = function () {
}
const result = await runner('non-existing-adapter', { reporter: noop })
expect(result.failures).to.eq(4)
expect(result.stats.failures).to.eq(result.failures)
expect(result.stats.suites).to.eq(6)
expect(result.stats.tests).to.eq(result.failures)
})
})
This diff is collapsed.
......@@ -58,9 +58,8 @@
},
"dependencies": {
"chai": "~4.3",
"glob": "~8.0",
"mocha": "~10.0",
"yargs": "~17.6"
"js-yaml": "~4.1",
"mocha": "~10.0"
},
"devDependencies": {
"@asciidoctor/core": "2.2.6",
......
= Document Title
:icons: font
:toc:
{
"name": "document",
"type": "block",
"attributes": { "icons": "font", "toc": "" },
"header": {
"title": {
"inlines": [{
"name": "text",
"type": "string",
"value": "Document Title",
"location": { "start": { "line": 1, "column": 3 }, "end": { "line": 1, "column": 16 } }
}]
}
},
"blocks": [],
"location": { "start": { "line": 1,"column": 1 }, "end": { "line": 3, "column": 5 } }
}
:name: Guillaume
Hello {name}!
{
"body": [
{
"type": "Paragraph",
"lines": [
"Hello Guillaume!"
]
}
]
}
Paragraphs don't require any special markup in AsciiDoc.
A paragraph is just one or more lines of consecutive text.
To begin a new paragraph, separate it by at least one empty line from the previous paragraph or block.
{
"body": [
{
"type": "Paragraph",
"lines": [
"Paragraphs don't require any special markup in AsciiDoc. A paragraph is just one or more lines of consecutive text."
]
},
{
"type": "Paragraph",
"lines": [
"To begin a new paragraph, separate it by at least one empty line from the previous paragraph or block."
]
}
]
}
A paragraph that consists of a single line.
{
"name": "document",
"type": "block",
"blocks": [
{
"name": "paragraph",
"type": "block",
"inlines": [
{
"name": "text",
"type": "string",
"value": "A paragraph that consists of a single line.",
"location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 43 } }
}
],
"location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 43 } }
}
],
"location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 43 } }
}
name: body offset from title by empty line
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment