Improve doctests

This commit is contained in:
Pieter Vander Vennet 2022-03-25 15:47:14 +01:00
parent 9f0dd17419
commit e1c62582cb
21 changed files with 1019 additions and 1929 deletions

138
README.md
View file

@ -1,124 +1,30 @@
# doctest-ts: doctests for TypeScript
# doctest-ts-improved: doctests for TypeScript
Say you have a file src/hasFoo.ts with a function like hasFoo:
Easy doctests for typescript modules, including private methods and extra imports:
```typescript
function hasFoo(s: string): boolean {
return null != s.match(/foo/i)
```
export default class SomeClass {
/**
* Gets the field doubled
* @example xyz
*
* import OtherClass from "./OtherClass";
*
* // Should equal 42
* SomeClass.get() // => 42
*
* SomeClass.get() + 1 // => 43
*
* new OtherClass().doSomething(new SomeClass()) // => 5
*/
private static get() : number{
// a comment
// @ts-ignore
return 42
}
}
```
You can now make documentation and unit tests for this function in one go:
```typescript
/** Does this string contain foo, ignoring case?
hasFoo('___foo__') // => true
hasFoo(' fOO ') // => true
hasFoo('Foo.') // => true
hasFoo('bar') // => false
hasFoo('fo') // => false
hasFoo('oo') // => false
*/
function hasFoo(s: string): boolean {
return null != s.match(/foo/i)
}
```
Since the function is not exported we can only test this by either editing or copying the entire file and gluing on tests at the end.
This library goes for the second approach: making a copy of the file with the translated tests at the end. Run it like so:
```sh
$ doctest-ts src/hasFoo.ts
Writing src/hasFoo.doctest.ts
```
The contents of `src/hasFoo.doctest.ts` is the original file prepended to the doctests rewritten as unit tests.
```typescript
/** Does this string contain foo, ignoring case?
hasFoo('___foo__') // => true
hasFoo(' fOO ') // => true
hasFoo('Foo.') // => true
hasFoo('bar') // => false
hasFoo('fo') // => false
hasFoo('oo') // => false
*/
function hasFoo(s: string): boolean {
return null != s.match(/foo/i)
}
import * as __test from "tape"
__test("hasFoo", t => {t.deepEqual(hasFoo("___foo__"), true, "true")
t.deepEqual(hasFoo(" fOO "), true, "true")
t.deepEqual(hasFoo("Foo."), true, "true")
t.deepEqual(hasFoo("bar"), false, "false")
t.deepEqual(hasFoo("fo"), false, "false")
t.deepEqual(hasFoo("oo"), false, "false")
;t.end()})
```
This can now be run with the tape runner or ts-node:
```
$ ts-node src/hasFoo.doctest.ts | tap-diff
hasFoo
✔ true
✔ true
✔ true
✔ false
✔ false
✔ false
Done in 0.37s.
passed: 6 failed: 0 of 6 tests (171ms)
All of 6 tests passed!
```
There are four different outputs available:
* tape
* AVA
* jest
* mocha (using chai)
Pull requests for other test runners are welcome.
## Watching file changes
We can tell `doctest-ts` to watch for file changes and report which files it has written.
It tries to be a good unix citizen and thus writes the files it has created on stdout (and some info on stderr).
This makes it possible to run test runners on each line on stdout like so:
```sh
ts-node src/main.ts --watch src/hasFo.ts |
while read file; do echo running tape on $file; ts-node $file | tap-diff; done
```
Let's say we remove the ignore case `i` flag from the regex in `hasFoo`. We get this output (automatically):
```
Writing src/hasFoo.doctest.ts
running tape on src/hasFoo.doctest.ts
hasFoo
✔ true
✖ true at Test.t (src/hasFoo.doctest.ts:18:3)
[-false-][+true+]
✖ true at Test.t (src/hasFoo.doctest.ts:19:3)
[-false-][+true+]
✔ false
✔ false
✔ false
passed: 4 failed: 2 of 6 tests (264ms)
2 of 6 tests failed.
```
# License
MIT

9
examples/OtherClass.ts Normal file
View file

@ -0,0 +1,9 @@
import SomeClass from "./someClass";
export default class OtherClass {
public doSomething(c: SomeClass){
return c.xyz()
}
}

View file

@ -1,24 +0,0 @@
export default class Example0 {
/**
* Gets the field doubled
* @example xyz
*
* // Should equal 42
* Example0.get() // => 42
*
* Example0.get() + 1 // => 43
*/
private static get(){
// a comment
// @ts-ignore
return 42
}
public testMore(){
return ({} as any) ?.xyz?.abc ?? 0
}
}

27
examples/someClass.ts Normal file
View file

@ -0,0 +1,27 @@
export default class SomeClass {
/**
* Gets the field doubled
* @example xyz
*
* import OtherClass from "./OtherClass";
*
* // Should equal 42
* SomeClass.get() // => 42
*
* SomeClass.get() + 1 // => 43
*
* new OtherClass().doSomething(new SomeClass()) // => 5
*/
private static get() : number{
// a comment
// @ts-ignore
return 42
}
public xyz(){
return 5
}
}

View file

@ -1,17 +1,16 @@
{
"$schema": "http://json.schemastore.org/package",
"name": "doctest-ts",
"version": "0.6.0",
"description": "doctest support for typescript",
"name": "doctest-ts-improved",
"version": "0.7.0",
"description": "doctest support for typescript with Mocha",
"main": "src/main.ts",
"bin": {
"doctest-ts": "dist/src/main.js"
"doctest-ts-improved": "dist/src/main.js"
},
"scripts": {
"build": "tsc && chmod 755 dist/src/main.js",
"test": "ts-node src/main.ts --tape src/*ts test/*ts && ts-node node_modules/.bin/tape test/*.ts src/*doctest*.ts | tap-diff",
"doctest:watch": "ts-node src/main.ts --tape --watch {src,test}/*.ts | while read file; do echo tape $file; ts-node $file | tap-diff; done",
"debug": "ts-node src/main.ts --mocha examples/example0.ts",
"test": "ts-node src/main.ts examples/ && ts-node src/main.ts src/ && mocha --require ts-node/register examples/*.ts src/*.ts",
"prettier": "rm -v -f {src,test}/*doctest.ts && prettier --list-different --write src/*ts* test/*ts*"
},
"repository": {
@ -30,14 +29,16 @@
},
"homepage": "https://github.com/danr/doctest-ts#readme",
"dependencies": {
"chokidar": "^2.0.1",
"@types/chai": "^4.3.0",
"chai": "^4.3.6",
"global": "^4.3.2",
"minimist": "^1.2.0",
"mocha": "^9.2.2",
"typescript": "^4.6.2"
},
"devDependencies": {
"@types/chokidar": "^1.7.5",
"@types/minimist": "^1.2.0",
"@types/mocha": "^9.1.0",
"@types/node": "^9.4.6",
"@types/tape": "^4.2.31",
"faucet": "^0.0.1",

97
src/ExtractComments.ts Normal file
View file

@ -0,0 +1,97 @@
import * as ts from "typescript";
import {JSDocComment} from "typescript";
import {SyntaxKind} from "typescript/lib/tsserverlibrary";
export interface Context {
filepath: string,
linenumber?: number,
classname?: string,
functionname?: string,
testname?: string
}
/**
* Responsible for extracting the testfiles from the .ts files
*
*/
export default class ExtractComments {
private readonly results: { comment: string, context: Context }[] = []
private readonly code: string;
constructor(filepath: string, code: string) {
this.code = code;
const ast = ts.createSourceFile('_.ts', code, ts.ScriptTarget.Latest)
this.traverse(ast, {filepath: filepath})
}
public getComments(): { comment: string, context: Context }[] {
return this.results
}
private getLineNumber(pos: number) {
let line = 0;
for (let i = 0; i < pos; i++) {
if (this.code[i] === "\n") {
line++
}
}
return line;
}
private registerComment(context: Context, comment: string | ts.NodeArray<JSDocComment> | undefined, position: number) {
if (comment === undefined) {
return
}
context = {...context, linenumber: this.getLineNumber(position)}
if (typeof comment === "string") {
this.results.push({comment: comment || '', context});
} else {
comment.forEach(jsDocComment => {
this.results.push({comment: jsDocComment.text || '', context});
})
}
}
private traverse(node: ts.Node, context: Context) {
if (SyntaxKind.ClassDeclaration === node.kind) {
context = {...context, classname: (node as any)["name"].escapedText};
}
const jsdocs = (node as any).jsDoc || []
if (jsdocs.length > 0) {
let declName = undefined
try {
declName = (node as any).name.escapedText
} catch (e) {
try {
const decls = (node as any).declarationList.declarations
if (decls.length == 1) {
declName = decls[0].name.escapedText || null
}
} catch (e) {
declName = ts.isConstructorDeclaration(node) ? 'constructor' : undefined
}
}
context = {...context, functionname: declName}
jsdocs.forEach((doc: ts.JSDoc) => {
this.registerComment(context, doc.comment, doc.pos)
// A part of the comment might be in the tags; we simply add those too and figure out later if they contain doctests
const tags = doc.tags;
if (tags !== undefined) {
tags.forEach(tag => {
this.registerComment(context, tag.comment, tag.pos)
});
}
})
}
ts.forEachChild(node, n => this.traverse(n, context))
}
}

131
src/TestCreator.ts Normal file
View file

@ -0,0 +1,131 @@
import UnitTest from "./UnitTest";
import * as ts from "typescript";
import ExtractComments from "./ExtractComments";
import * as fs from 'fs'
import * as path from 'path'
/**
* Responsible for creating a '.doctest.ts'-file
*/
export default class TestCreator {
private _originalFilepath: string;
constructor(originalFilepath: string) {
this._originalFilepath = originalFilepath;
if (originalFilepath.includes('doctest')) {
throw "Not creating a doctest for a file which already is a doctest"
}
}
private static exposePrivates(s: string): string {
const ast = ts.createSourceFile('_.ts', s, ts.ScriptTarget.Latest) as ts.SourceFile
const transformer = <T extends ts.Node>(context: ts.TransformationContext) =>
(rootNode: T) => {
function visit(node: ts.Node): ts.Node {
if (node.kind === ts.SyntaxKind.PrivateKeyword) {
return ts.createModifier(ts.SyntaxKind.PublicKeyword)
}
return ts.visitEachChild(node, visit, context);
}
return ts.visitNode(rootNode, visit);
};
const transformed = ts.transform(ast, [transformer]).transformed[0]
const pwoc = ts.createPrinter({removeComments: false})
return pwoc.printNode(ts.EmitHint.Unspecified, transformed, ast)
}
private static testCode(tests: UnitTest[]): string[] {
const code: string[] = []
const exportedTests = new Set<UnitTest>()
function show(s: string) {
return JSON.stringify(s)
}
function emit(test: UnitTest, indent: string = "") {
if (exportedTests.has(test)) {
return;
}
const testCode = "\n" + test.generateCode()
code.push(testCode.replace(/\n/g, "\n" + indent))
exportedTests.add(test)
}
function emitAllForFunction(functionname: string | undefined, indent: string) {
tests.filter(t => t.context.functionname === functionname).forEach(c => emit(c, " " + indent))
}
function emitAllForClass(classname: string | undefined, indent: string) {
const forClass: UnitTest[] = tests.filter(t => t.context.classname === classname)
for (const test of forClass) {
if (exportedTests.has(test)) {
continue
}
if (test.context.functionname !== undefined) {
code.push(indent+"describe(" + show(test.context.functionname) + ", () => {")
emitAllForFunction(test.context.functionname, " " + indent)
code.push(indent+"})")
}
}
emitAllForFunction(undefined, indent)
}
for (const test of tests) {
if (exportedTests.has(test)) {
continue
}
if (test.context.classname !== undefined) {
code.push("describe(" + show(test.context.classname) + ", () => {")
emitAllForClass(test.context.classname, " ")
code.push("})")
}
}
emitAllForClass(undefined, "")
return code
}
/**
* Creates a new file with the doctests.
*
* Returns the number of found tests
*/
public createTest(): number {
const file = this._originalFilepath
const {base, ext, ...u} = path.parse(file)
const buffer = fs.readFileSync(file, {encoding: 'utf8'})
const comments = new ExtractComments(file, buffer).getComments()
const tests: UnitTest[] = UnitTest.FromComments(comments)
const outfile = path.format({...u, ext: '.doctest' + ext})
if (tests.length == 0) {
return 0
}
const code = []
const imports = new Set<string>()
for (const test of tests) {
test.getImports().forEach(i => imports.add(i))
}
// Add imports needed by the tests
code.push(...Array.from(imports))
// Adds the original code where the private keywords are removed
code.push(TestCreator.exposePrivates(buffer))
// At last, we add all the doctests
code.push(...TestCreator.testCode(tests))
fs.writeFileSync(outfile, code.join("\n"))
return tests.length
}
}

17
src/TestSuite.ts Normal file
View file

@ -0,0 +1,17 @@
export default interface TestDescription {
/**
* The name of the module under test
*/
moduleName?: string;
/**
* The name of the function under testing
*/
functionName?: string
/**
* The name of the singular test
*/
testName?: string
}

150
src/UnitTest.ts Normal file
View file

@ -0,0 +1,150 @@
import {Context} from "./ExtractComments";
import * as ts from "typescript";
type Script = (Statement | Equality)[]
interface Equality {
tag: '=='
lhs: string
rhs: string
}
interface Statement {
tag: 'Statement'
stmt: string
}
class ScriptExtraction {
/**
* ScriptExtraction.is_doctest('// => true') // => true
* ScriptExtraction.is_doctest('// true') // => false
*/
public static is_doctest(s: string): boolean {
return s.match(/\/\/[ \t]*=>/) != null
}
/**
* Extracts the expected value
*
* const m = ScriptExtraction.doctest_rhs('// => true') || []
* m[1] // => ' true'
*/
public static doctest_rhs(s: string) {
return s.match(/^\s*\/\/[ \t]*=>([^\n]*)/m);
}
public static extractImports(docstring: string){
return docstring.split("\n").filter(s => s.startsWith("import "));
}
public static extractScripts(docstring: string): { script: Script, name?: string, line: number }[] {
const out = [] as { script: Script, name?: string, line: number }[]
let line = 0;
for (const s of docstring.split(/\n\n+/m)) {
const p: number = line;
line += s.split(/\r\n|\r|\n/).length
if (!ScriptExtraction.is_doctest(s)) {
continue;
}
const script = ScriptExtraction.extractScript(s)
let name = undefined
const match = s.match(/^[ \t]*\/\/([^\n]*)/)
if (match !== null) {
name = match[1].trim()
}
out.push({script, name, line: p})
}
return out
}
/**
* ScriptExtraction.extractScript('s') // => [{tag: 'Statement', stmt: 's;'}]
* ScriptExtraction.extractScript('e // => 1') // => [{tag: '==', lhs: 'e', rhs: '1'}]
* ScriptExtraction.extractScript('s; e // => 1') // => [{tag: 'Statement', stmt: 's;'}, {tag: '==', lhs: 'e', rhs: '1'}]
*/
private static extractScript(s: string): Script {
const pwoc = ts.createPrinter({removeComments: true})
const ast = ts.createSourceFile('_.ts', s, ts.ScriptTarget.Latest)
return ast.statements.map((stmt, i): Statement | Equality => {
if (ts.isExpressionStatement(stmt)) {
const next = ast.statements[i + 1] // zip with next
const [a, z] = next ? [next.pos, next.end] : [stmt.end, ast.end]
const after = ast.text.slice(a, z)
const m = ScriptExtraction.doctest_rhs(after)
if (m && m[1]) {
const lhs = pwoc.printNode(ts.EmitHint.Expression, stmt.expression, ast)
const rhs = m[1].trim()
return {tag: '==', lhs, rhs}
}
}
return {tag: 'Statement', stmt: pwoc.printNode(ts.EmitHint.Unspecified, stmt, ast)}
})
}
}
/**
* Represents a single unit test somewhere in a file.
*/
export default class UnitTest {
public body: Script;
public context: Context;
private _extraImports: string[];
private constructor(body: Script, context: Context, extraImports: string[]) {
this.body = body;
this.context = context;
this._extraImports = extraImports;
}
public static FromComment(comment: string, context: Context): UnitTest[] {
const imports = ScriptExtraction.extractImports(comment)
return ScriptExtraction.extractScripts(comment).map(({script, line, name}, i) =>
new UnitTest(script, {
...context,
linenumber: (context.linenumber ?? 0) + line,
testname: name ?? 'doctest ' + i
},imports))
}
public static FromComments(comms: { comment: string, context: Context }[]): UnitTest[] {
const result: UnitTest[] = []
for (const comm of comms) {
result.push(...UnitTest.FromComment(comm.comment, comm.context))
}
return result
}
public getImports(): string[] {
return [...this._extraImports, 'import "mocha"', 'import {expect as __expect} from "chai"']
}
/**
* Generates the mocha test code for this unit test.
* Will only construct the 'it('should ....') { __expect(x).deep.eq(y) } part
*/
public generateCode() {
const script = this.body
.map(s => {
if (s.tag == 'Statement') {
return s.stmt
} else {
return `__expect(${s.lhs}, "failed at ${this.context.functionname} (${this.context.filepath}:${this.context.linenumber}:1)").to.deep.equal(${s.rhs})`
}
})
.map(x => '\n ' + x)
.join('')
return `it(${this.getName()}, () => {${script}\n})`
}
private getName() {
return JSON.stringify(this.context.testname ?? this.context.functionname)
}
}

View file

@ -1,276 +0,0 @@
import * as ts from 'typescript'
import {JSDocComment} from 'typescript'
import * as fs from 'fs'
import * as path from 'path'
////////////////////////////////////////////////////////////
// Types
export interface Equality {
tag: '=='
lhs: string
rhs: string
}
export interface Statement {
tag: 'Statement'
stmt: string
}
export type Script = (Statement | Equality)[]
export type Context = string | null
export interface Comment {
comment: string
context: Context
}
////////////////////////////////////////////////////////////
// Extracting docstrings from program
export function Comments(s: string): Comment[] {
const out: Comment[] = []
function registerComment(context: string | null,comment: string | ts.NodeArray<JSDocComment> | undefined){
if(comment === undefined){
return
}
if(typeof comment === "string"){
out.push({comment: comment || '', context});
}else{
comment.forEach(jsDocComment => {
out.push({comment: jsDocComment.text || '', context});
})
}
}
function traverse(node: ts.Node) {
const jsdocs = (node as any).jsDoc || []
if (jsdocs.length > 0) {
let context: string | null = null
try {
context = (node as any).name.escapedText || null
} catch (e) {
try {
const decls = (node as any).declarationList.declarations
if (decls.length == 1) {
context = decls[0].name.escapedText || null
}
} catch (e) {
context = ts.isConstructorDeclaration(node) ? 'constructor' : null
}
}
jsdocs.forEach((doc: ts.JSDoc) => {
registerComment(context, doc.comment)
// A part of the comment might be in the tags; we simply add those too and figure out later if they contain doctests
const tags = doc.tags;
if(tags !== undefined){
tags.forEach(tag => {
registerComment(context, tag.comment)
});
}
})
}
ts.forEachChild(node, traverse)
}
const ast = ts.createSourceFile('_.ts', s, ts.ScriptTarget.Latest)
traverse(ast)
return out
}
////////////////////////////////////////////////////////////
// Extracting test scripts from docstrings
/**
is_doctest('// => true') // => true
is_doctest('// true') // => false
*/
const is_doctest = (s: string) => s.match(/\/\/[ \t]*=>/) != null
/**
const m = doctest_rhs('// => true') || []
m[1] // => ' true'
*/
const doctest_rhs = (s: string) => s.match(/^\s*\/\/[ \t]*=>([^\n]*)/m)
/**
extractScript('s') // => [{tag: 'Statement', stmt: 's;'}]
extractScript('e // => 1') // => [{tag: '==', lhs: 'e', rhs: '1'}]
extractScript('s; e // => 1') // => [{tag: 'Statement', stmt: 's;'}, {tag: '==', lhs: 'e', rhs: '1'}]
*/
export function extractScript(s: string): Script {
const pwoc = ts.createPrinter({removeComments: true})
const ast = ts.createSourceFile('_.ts', s, ts.ScriptTarget.Latest)
return ast.statements.map((stmt, i): Statement | Equality => {
if (ts.isExpressionStatement(stmt)) {
const next = ast.statements[i + 1] // zip with next
const [a, z] = next ? [next.pos, next.end] : [stmt.end, ast.end]
const after = ast.text.slice(a, z)
const m = doctest_rhs(after)
if (m && m[1]) {
const lhs = pwoc.printNode(ts.EmitHint.Expression, stmt.expression, ast)
const rhs = m[1].trim()
return {tag: '==', lhs, rhs}
}
}
return {tag: 'Statement', stmt: pwoc.printNode(ts.EmitHint.Unspecified, stmt, ast)}
})
}
export function extractScripts(docstring: string): {script: Script, name?: string}[] {
const out = [] as {script: Script, name?: string}[]
docstring.split(/\n\n+/m).forEach(s => {
if (is_doctest(s)) {
const script = extractScript(s)
let name = undefined
const match = s.match(/^[ \t]*\/\/([^\n]*)/)
if(match !== null)
{
name = match[1].trim()
}
out.push({script, name})
}
})
return out
}
////////////////////////////////////////////////////////////
// Showing test scripts
export interface ShowScript {
showImports: string
showScript(script: Script, c: Context, name?: string): string
}
/** show("hello") // => '"hello"' */
export function show(s: any) {
return JSON.stringify(s)
}
export function showContext(c: Context) {
return show(c || 'doctest')
}
function tapeOrAVA(script: Script, c: Context, name: string | undefined, before_end = (t: string) => '') {
const t = `t`
const body = script
.map(s => {
if (s.tag == 'Statement') {
return s.stmt
} else {
return `${t}.deepEqual(${s.lhs}, ${s.rhs}, ${show(s.rhs)})`
}
})
.map(x => '\n ' + x)
.join('')
return `
__test(${showContext(c)}, ${t} => {
${body}
${before_end(t)}
})`
}
const mochaOrJest = (deepEqual: string): typeof tapeOrAVA => (script, c, name) => {
const body = script
.map(s => {
if (s.tag == 'Statement') {
return s.stmt
} else {
return `__expect(${s.lhs}).${deepEqual}(${s.rhs})`
}
})
.map(x => '\n ' + x)
.join('')
return `
describe(${showContext(c)}, () => {
it(${show(name) || showContext(c)}, () => {${body}})
})
`
}
export const showScriptInstances: Record<string, ShowScript> = {
ava: {
showImports: 'import {test as __test} from "ava"',
showScript: tapeOrAVA,
},
tape: {
showImports: 'import * as __test from "tape"',
showScript: (s, c, name) => tapeOrAVA(s, c, name, t => `\n;${t}.end()`),
},
mocha: {
showImports: 'import "mocha"\nimport {expect as __expect} from "chai"',
showScript: mochaOrJest(`to.deep.equal`),
},
jest: {
showImports: 'import "jest"\nconst __expect: jest.Expect = expect',
showScript: mochaOrJest(`toEqual`),
},
}
function exposePrivates(s: string): string {
const ast = ts.createSourceFile('_.ts', s, ts.ScriptTarget.Latest) as ts.SourceFile
const transformer = <T extends ts.Node>(context: ts.TransformationContext) =>
(rootNode: T) => {
function visit(node: ts.Node): ts.Node {
if (node.kind === ts.SyntaxKind.PrivateKeyword) {
return ts.createModifier(ts.SyntaxKind.PublicKeyword)
}
return ts.visitEachChild(node, visit, context);
}
return ts.visitNode(rootNode, visit);
};
const transformed = ts.transform(ast, [transformer]).transformed[0]
const pwoc = ts.createPrinter({ removeComments: false})
return pwoc.printNode(ts.EmitHint.Unspecified, transformed, ast)
}
export function instrument(d: ShowScript, file: string, mode?: 'watch'): void {
const {base, ext, ...u} = path.parse(file)
if (base.includes('doctest')) {
return
}
const buffer = fs.readFileSync(file, {encoding: 'utf8'})
const withoutPrivates = exposePrivates(buffer)
const tests = Doctests(d, buffer)
const outfile = path.format({...u, ext: '.doctest' + ext})
if (tests.length == 0) {
console.error('No doctests found in', file)
} else {
console.error('Writing', outfile)
if (mode == 'watch') {
console.log(outfile)
}
fs.writeFileSync(outfile, withoutPrivates + '\n' + d.showImports + '\n' + tests.join('\n'))
}
}
function Doctests(d: ShowScript, buffer: string): string[] {
const out: string[] = []
for (const c of Comments(buffer)) {
for (const script of extractScripts(c.comment)) {
out.push(d.showScript(script.script, c.context, script.name))
}
}
return out
}

View file

@ -1,49 +1,52 @@
#!/usr/bin/env node
import * as chokidar from 'chokidar'
import * as minimist from 'minimist'
import {instrument, showScriptInstances} from './internal'
function main() {
const outputs = Object.keys(showScriptInstances)
const flags = outputs.map(f => '--' + f)
const boolean = ['watch'].concat(outputs)
const opts = minimist(process.argv.slice(2), {boolean})
let output: string | null = null
let error: string | null = null
outputs.forEach(k => {
if (opts[k]) {
if (output != null) {
error = `Cannot output both ${output} and ${k}`
}
output = k
import {lstatSync, readdirSync} from "fs";
import TestCreator from "./TestCreator";
function readDirRecSync(path: string, maxDepth = 999): string[] {
const result = []
if (maxDepth <= 0) {
return []
}
})
if (output == null) {
error = `Choose an output from ${flags.join(' ')}`
}
const files = opts._
if (files.length == 0 || output == null) {
console.error(
`
Error: ${error || `No files specified!`}
Usage:
${flags.join('|')} [-w|--watch] files globs...
Your options were:`,
opts,
`
From:`,
process.argv
)
return
}
const d = showScriptInstances[output]
files.forEach(file => instrument(d, file))
if (opts.w == true || opts.watch == true) {
const watcher = chokidar.watch(files, {ignored: '*.doctest.*'})
watcher.on('change', file => global.setTimeout(() => instrument(d, file, 'watch'), 25))
}
for (const entry of readdirSync(path)) {
const fullEntry = path + "/" + entry
const stats = lstatSync(fullEntry)
if (stats.isDirectory()) {
// Subdirectory
// @ts-ignore
result.push(...ScriptUtils.readDirRecSync(fullEntry, maxDepth - 1))
} else {
result.push(fullEntry)
}
}
return result;
}
main()
function main(){
const args = process.argv
console.log(args.join(","))
const directory = args[2].replace(/\/$/, "")
if(directory === "--require"){
console.error("Probably running the testsuite, detects '--require' as second argument. Quitting now")
return;
}
if(directory === undefined){
console.log("Usage: doctest-ts-improved <directory under test>. This will automatically scan recursively for '.ts'-files, excluding 'node_modules' '*.doctest.ts'-files")
}
const files = readDirRecSync(directory).filter(p => !p.startsWith("./node_modules") && !p.endsWith(".doctest.ts"))
const noTests : string[] = []
for (const file of files) {
const generated = new TestCreator(file).createTest()
if(generated === 0){
noTests.push(file)
}else{
console.log("Generated tests for "+file+" ("+generated+" tests found)")
}
}
if(noTests.length > 0){
console.log("No tests found in: "+noTests.join(", "))
}
}
main()

View file

@ -1,13 +0,0 @@
/** Does this string contain foo, ignoring case?
hasFoo('___foo__') // => true
hasFoo(' fOO ') // => true
hasFoo('Foo.') // => true
hasFoo('bar') // => false
hasFoo('fo') // => false
hasFoo('oo') // => false
*/
function hasFoo(s: string): boolean {
return null != s.match(/foo/i)
}

View file

@ -1,152 +0,0 @@
import * as internal from '../src/internal'
import * as test from 'tape'
test('tests', t => {
t.plan(1)
t.deepEqual(
internal.extractScripts(`*
foo // => 1
`),
[{ script: [{tag: '==', lhs: `foo`, rhs: `1`}], name: undefined }]
)
})
test('tests', t => {
t.plan(1)
t.deepEqual(
internal.extractScripts(`*
a
b // => 1 + 2 + 3
c // => 1
d
*/`).map(s => s.script),
[
[
{tag: 'Statement', stmt: 'a;'},
{tag: '==', lhs: 'b', rhs: '1 + 2 + 3'},
{tag: '==', lhs: 'c', rhs: '1'},
{tag: 'Statement', stmt: 'd;'},
],
]
)
})
const c = (comment: string, context: string | null) => ({comment, context})
test('modules and namespace', t => {
t.plan(1)
const cs = internal.Comments(`
/** m */
namespace m {}
/** ns */
namespace ns {}
`)
t.deepEqual(cs, [c('m', 'm'), c('ns', 'ns')])
})
test('const', t => {
t.plan(1)
const cs = internal.Comments(`
/** u */
const u = 1
`)
t.deepEqual(cs, [c('u', 'u')])
})
test('const object', t => {
t.plan(1)
const cs = internal.Comments(`
/** k */
const k = {
/** a */
a: 1,
/** b */
b(x: string) { return x+x }
}
`)
t.deepEqual(cs, [c('k', 'k'), c('a', 'a'), c('b', 'b')])
})
test('object deconstruction', t => {
t.plan(1)
const cs = internal.Comments(`
/** hello */
const {u, v} = {u: 1, v: 2}
`)
t.deepEqual(cs, [c('hello', null)])
})
test('function', t => {
t.plan(1)
const cs = internal.Comments(`
/** v */
function v(s: string): number {
return s.length + 1
}
`)
t.deepEqual(cs, [c('v', 'v')])
})
test('class', t => {
t.plan(1)
const cs = internal.Comments(`
/** C */
class C<A> {
/** constructor */
constructor() {}
/** m */
m(s: Array<number>): Array<string> {
}
/** p */
p: Array<number>
}
`)
t.deepEqual(cs, [c('C', 'C'), c('constructor', 'constructor'), c('m', 'm'), c('p', 'p')])
})
test('interface', t => {
t.plan(1)
const cs = internal.Comments(`
/** I */
interface I<A> {
/** i */
i: A,
/** j */
j(a: A): string
}
`)
t.deepEqual(cs, [c('I', 'I'), c('i', 'i'), c('j', 'j')])
})
test('type', t => {
t.plan(1)
const cs = internal.Comments(`
/** T */
type T = number
`)
t.deepEqual(cs, [c('T', 'T')])
})
test('anywhere', t => {
t.plan(1)
const cs = internal.Comments(`
const $ = () => {
/** test1 */
const w = 1
/** test2 */
function f(x) {
return x * x
}
/** test3 */
return f(f(w))
}
`)
t.deepEqual(cs, [c('test1', 'w'), c('test2', 'f'), c('test3', null)])
})

View file

@ -1,21 +1,23 @@
{
"compilerOptions": {
"outDir": "./dist",
"sourceMap": true,
"noImplicitAny": true,
"strict": true,
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["es6"],
"noEmitOnError": true,
"allowUnreachableCode": true
},
"include": [
"src",
"test"
"compilerOptions": {
"outDir": "./dist",
"sourceMap": true,
"noImplicitAny": true,
"strict": true,
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"lib": [
"es6"
],
"parcelTsPluginOptions": {
"transpileOnly": false
}
"noEmitOnError": true,
"allowUnreachableCode": true
},
"include": [
"src",
"test"
],
"parcelTsPluginOptions": {
"transpileOnly": false
}
}

View file

@ -1,7 +0,0 @@
class X {
J: {
u: number
} = {
u: 1
}
}

View file

@ -1,11 +0,0 @@
export const j = 1
export module A {
export const c = 1
export const s = 1
export interface J {
u: number
}
}
export namespace N {
export const d = 1
}

View file

@ -1,89 +0,0 @@
/** I */
export interface I {
/** k */
k: number
}
/** H */
interface H {
/** k */
k: number
}
/** C */
export class C {
/** f */
f = 1
/** g */
g: number
/** new */
constructor(x: number) { this.g = x }
/** s */
static s(y: number): I { return {k: y} }
/** m */
m(z: number): I { return {k: z} }
}
/** T */
type T = C
/** c */
const c = new C(1)
/** M */
export module M {
/** MI */
export interface MI {
/** M k */
k: number
}
/** MC */
export class MC {
/** M f */
f = 1
/** M g */
g: number
/** M new */
constructor(x: number) { this.g = x }
/** M s */
static s(y: number): MI { return {k: y} }
/** M m */
m(z: number): MI { return {k: z} }
/** M p */
private p(z: number): MI { return {k: z} }
}
type MT = MC
export const c = new MC(1)
}
/** HM */
module HM {
/** HMI */
interface HMI {
/** HM k */
k: number
}
/** HMC */
class HMC {
/** HM f */
f = 1
/** HM g */
g: number
/** HM new */
constructor(x: number) { this.g = x }
/** HM s */
static s(y: number): HMI { return {k: y} }
/** HM m */
m(z: number): HMI { return {k: z} }
}
type HMT = HMC
const c = new HMC(1)
}

View file

@ -1,23 +0,0 @@
/** The awesome iface
1 // => 1
This is not indentend
*/
export interface B {
b: number
}
export class C {
/**
1 // => 1
much.to.test()
yes()
more stuff*/
static boo() { return 1 }
}

View file

@ -1,417 +0,0 @@
import * as babylon from 'babylon'
import * as babel from 'babel-types'
import generate from 'babel-generator'
import * as fs from 'fs'
import * as util from 'util'
util.inspect.defaultOptions.depth = 5
util.inspect.defaultOptions.colors = true
const pp = (x: any) => (console.dir(x), console.log())
const opts: babylon.BabylonOptions = {plugins: [
'estree' ,
'jsx' ,
'flow' ,
'classConstructorCall' ,
'doExpressions' ,
'objectRestSpread' ,
'decorators' ,
'classProperties' ,
'exportExtensions' ,
'asyncGenerators' ,
'functionBind' ,
'functionSent' ,
'dynamicImport']}
const is_doctest = (s: string) => s.match(/\/\/[ \t]*=>/) != null
const doctest_rhs = (s: string) => s.match(/^\s*[ \t]*=>((.|\n)*)$/m)
interface Equality {
tag: '==',
lhs: string,
rhs: string
}
interface Statement {
tag: 'Statement'
stmt: string,
}
type Script = (Statement | Equality)[]
export function test(s: string): Script {
const lin = (ast: babel.Node) => generate(ast ,{comments: false, compact: true}).code
const ast = babylon.parse(s, opts)
return ast.program.body.map((stmt): Statement | Equality => {
const comment = (stmt.trailingComments || [{value: ''}])[0].value
const rhs = doctest_rhs(comment)
if (babel.isExpressionStatement(stmt) && rhs) {
const rhs = babylon.parseExpression(comment.replace(/^\s*=>/, ''))
return {
tag: '==',
lhs: lin(stmt.expression),
rhs: lin(rhs),
}
} else {
return {tag: 'Statement', stmt: lin(stmt)}
}
})
}
export function tests(docstring: string): Script[] {
const out = [] as Script[]
docstring.split(/\n\n+/m).forEach(s => {
if (is_doctest(s)) {
out.push(test(s))
}
})
return out
}
export interface Comment {
comment: string,
context: string | null
}
export function Comments(s: string): Comment[] {
const out: Comment[] = []
function add_comment(c: babel.Comment, context: string | null) {
out.push({comment: c.value, context})
}
const ast = babylon.parse(s, opts)
traverse(ast, node => {
let context: null | string = null
/*
if (babel.isDeclaration(node)) {
util.inspect.defaultOptions.depth = 1
pp({declaration: node})
}
if (babel.isMethod(node)) {
util.inspect.defaultOptions.depth = 1
pp({method: node})
}
if (isObject(node) && 'type' in node && node.type == 'MethodDefinition') {
util.inspect.defaultOptions.depth = 2
pp({methodDefn: node})
}
if (isObject(node) && 'type' in node && node.type == 'ObjectProperty') {
util.inspect.defaultOptions.depth = 2
pp({objProp: node})
}
if (isObject(node) && 'type' in node && node.type == 'ObjectMethod') {
util.inspect.defaultOptions.depth = 2
pp({objMethod: node})
}
if (isObject(node) && 'type' in node && node.type == 'ObjectExpression') {
util.inspect.defaultOptions.depth = 5
pp({objExpr: node})
}
if (isObject(node) && 'type' in node && node.type == 'Property') {
util.inspect.defaultOptions.depth = 5
pp({property: node})
}
*/
// context = node as any
function has_key(x: any): x is {key: babel.Identifier} {
return isObject(x) && 'key' in x && babel.isIdentifier((x as any).key)
}
function has_id(x: any): x is {id: babel.Identifier} {
return isObject(x) && 'id' in x && babel.isIdentifier((x as any).id)
}
if (babel.isVariableDeclaration(node)) {
const ds = node.declarations
if (ds.length == 1) {
const d = ds[0]
if (has_id(d)) {
context = d.id.name
}
}
} else if (has_id(node)) {
context = node.id.name
} else if (has_key(node)) {
context = node.key.name
}
if (isObject(node)) {
function add_comments(s: string) {
if (s in node && Array.isArray(node[s])) {
(node[s] as any[]).forEach(c => {
if ('type' in c) {
if (c.type == 'CommentBlock' || c.type == 'CommentLine') {
add_comment(c, context)
if (context == null) {
// pp({c, node})
}
}
}
})
}
}
if (isObject(node)) {
add_comments('leadingComments')
add_comments('innerComments')
// add_comments('trailingComments')
}
}
})
return out
}
function isObject(x: any): x is object {
return x !== null && typeof x === 'object' && !Array.isArray(x)
}
function traverse(x: any, f: (x: any) => void): void {
f(x)
if (Array.isArray(x)) {
x.map(y => traverse(y, f))
}
if (isObject(x)) {
for (const k in x) {
traverse((x as any)[k], f)
}
}
}
false && pp(Comments(`/** test */ function f(x: string): number { return 1 }
class Apa {
/** something something */
/** attached to nothing! */
/** returns important stuff
j(5) // => 6
j(9) // => 10
*/
j(x: number) {
/** important stuff */
return x + 1
}
}
interface B {
/** x docstring */
x: 1
}
/** u docstring */
const u = {
/** ux docstring */
ux: 1
}
`))
function script(filename: string, s: string): string[] {
return []
/*
const pwoc = ts.createPrinter({removeComments: true})
const f = ts.createSourceFile('_doctest_' + filename, s, ts.ScriptTarget.ES5, true, ts.ScriptKind.TS)
const out =
f.statements.map(
(now, i) => {
if (ts.isExpressionStatement(now)) {
const next = f.statements[i+1] // zip with next
const [a, z] = next ? [next.pos, next.end] : [now.end, f.end]
const after = f.text.slice(a, z)
const m = doctest_rhs(after)
if (m && m[1]) {
const lhs = pwoc.printNode(ts.EmitHint.Expression, now.expression, f)
const rhs = m[1].trim()
return 't.deepEqual(' + lhs + ', ' + rhs + ', ' + JSON.stringify(rhs) + ')'
}
}
return pwoc.printNode(ts.EmitHint.Unspecified, now, f)
})
return out
*/
}
/*
const filename = 'unk.ts'
r.comments.map(d => {
d.value.split(/\n\n+/m).map(s => {
let tests = 0
if (is_doctest(s)) {
// todo: typecheck s now
const name = 'unk'
console.log(
'test(' + JSON.stringify(name + ' ' + ++tests) + ', t => {',
...script(filename, s).map(l => ' ' + l),
'})',
''
)
}
})
})
pp(r.program)
pp(r.comments)
*/
/*
function script(filename: string, s: string): string[] {
const pwoc = ts.createPrinter({removeComments: true})
const f = ts.createSourceFile('_doctest_' + filename, s, ts.ScriptTarget.ES5, true, ts.ScriptKind.TS)
const out =
f.statements.map(
(now, i) => {
if (ts.isExpressionStatement(now)) {
const next = f.statements[i+1] // zip with next
const [a, z] = next ? [next.pos, next.end] : [now.end, f.end]
const after = f.text.slice(a, z)
const m = doctest_rhs(after)
if (m && m[1]) {
const lhs = pwoc.printNode(ts.EmitHint.Expression, now.expression, f)
const rhs = m[1].trim()
return 't.deepEqual(' + lhs + ', ' + rhs + ', ' + JSON.stringify(rhs) + ')'
}
}
return pwoc.printNode(ts.EmitHint.Unspecified, now, f)
})
return out
}
function test_script_one(filename: string, d: Def): string[] {
const out = [] as string[]
let tests = 0
d.doc.split(/\n\n+/m).map(s => {
if (is_doctest(s)) {
// todo: typecheck s now
out.push(
'test(' + JSON.stringify(d.name + ' ' + ++tests) + ', t => {',
...script(filename, s).map(l => ' ' + l),
'})',
''
)
}
})
return out
}
function test_script(top: Top) {
return ["import {test} from 'ava'"].concat(...top.map(
({filename, defs}) => walk(defs, (d) => test_script_one(filename, d)))
)
}
function prettyKind(kind: string) {
return kind.replace('Declaration', '').toLowerCase()
}
function toc_one(def: Def, i: number): string[] {
if (def.exported || i > 0) {
return [
replicate(i, ' ').join('') +
'* ' +
(def.children.length == 0 ? '' : (prettyKind(def.kind) + ' ')) +
def.name
]
} else {
return []
}
}
function toc(top: Top): string[] {
return flatten(top.map(({defs}) => walk(defs, toc_one)))
}
function doc_one(def: Def, i: number): string[] {
const out = [] as string[]
if (def.exported || i > 0) {
let indent = ''
if (def.children.length == 0) {
//const method = (def.kind == 'MethodDeclaration') ? 'method ' : ''
out.push('* ' + '**' + def.name + '**: `' + def.type + '`')
indent = ' '
} else {
out.push('### ' + prettyKind(def.kind) + ' ' + def.name)
}
def.doc.split(/\n\n+/).forEach(s => {
out.push('')
if (is_doctest(s)) {
out.push(indent + '```typescript')
}
const lines = s.split('\n')
lines.forEach(line => out.push(indent + line))
if (is_doctest(s)) {
out.push(indent + '```')
}
})
}
return out
}
function doc(top: Top) {
return flatten(top.map(({defs}) => walk(defs, doc_one)))
}
const filenames = [] as string[]
const argv = process.argv.slice(2)
const outputs = [] as ((top: Top) => string[])[]
{
let program: ts.Program
let verbose = false
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg == '-t' || arg == '--test-script') {
outputs.push(test_script)
} else if (arg == '-d' || arg == '--doc') {
outputs.push(doc)
} else if (arg == '--toc' || arg == '--toc') {
outputs.push(toc)
} else if (arg == '-i' || arg == '--include') {
outputs.push(_top => [fs.readFileSync(argv[++i]).toString()])
} else if (arg == '-s' || arg == '--string') {
outputs.push(_top => [argv[++i]])
} else {
filenames.push(arg)
}
}
if (outputs.length == 0) {
console.log(`typescript-doctests <args>
Each entry in <args> may be:
[-t|--test-script] // write tape test script on stdout
[-d|--doc] // write markdown api documentation on stdout
[--toc] // write markdown table of contents on stdout
[-i|--include] FILENAME // write the contents of a file on stdout
[-s|--string] STRING // write a string literally on stdout
FILENAME // typescript files to look for docstrings in
Example usages:
typescript-doctests src/*.ts -s 'import * as App from "../src/App"' -t > test/App.doctest.ts
typescript-doctests src/*.ts -i Header.md --toc --doc -i Footer.md > README.md
`)
process.exit(1)
} else {
program = ts.createProgram(filenames, {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS
})
const top = generateDocumentation(program, filenames)
outputs.forEach(m => m(top).forEach(line => console.log(line)))
}
}
*/

View file

@ -1,46 +0,0 @@
import * as ts from 'typescript'
const pwoc = ts.createPrinter({removeComments: true})
function script(s: string): string {
const f = ts.createSourceFile('test.ts', s, ts.ScriptTarget.ES5, true, ts.ScriptKind.TS)
const out =
f.statements.map(
(now, i) => {
if (ts.isExpressionStatement(now)) {
const next = f.statements[i+1] // zip with next
const [a, z] = next ? [next.pos, next.end] : [now.end, f.end]
const after = f.text.slice(a, z)
const m = after.match(/^\s*\/\/[ \t]*=>([^\n]*)/m)
if (m && m[1]) {
const lhs = pwoc.printNode(ts.EmitHint.Expression, now.expression, f)
const rhs = m[1].trim()
return 'assert.deepEqual(' + lhs + ', ' + rhs + ', ' + JSON.stringify(rhs) + ')'
}
}
return pwoc.printNode(ts.EmitHint.Unspecified, now, f)
})
return out.join('\n')
}
const s = `
const a = 1
a
// one more
// => 1
let b = 2
a + 1 // => 2
// that's all
a + b
// that's all
// => 3
function apa(bepa) {
return 1
}
a++
b++
// hehe // => 5
a // => 4
`
console.log(script(s))

1173
yarn.lock

File diff suppressed because it is too large Load diff