unexpectedly 0.0.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,41 +4,76 @@ Write a bunch of unit tests in a simple file.
4
4
 
5
5
  ## Install
6
6
 
7
- ```
7
+ ```sh
8
8
  npm install -g unexpectedly
9
9
  ```
10
10
 
11
11
  ## Usage
12
12
 
13
13
  ```
14
- unexpectedly [file|directory] [jsPattern]
15
- file: a file that contains tests
16
- directory (default: "."): a directory containing ".tests" files
17
- jsPattern (default: "../$1.js"): find the JS file relative to
18
- the .tests file, replacing $1 with the basename of the test file
14
+ Usage: unexpectedly [options] [...file]
15
+
16
+ Arguments:
17
+ ...file Files or directories to test (default:
18
+ "./test/")
19
+
20
+ Options:
21
+ -V, --version output the version number
22
+ -d,--defaultScript <replacement> Find the script from the file name.
23
+ Replace `$<base>` with the basename of the
24
+ file. (default: "../$<base>.js")
25
+ -f,--function <functionName> Use this function for testing in the
26
+ associated script (default: "test")
27
+ -h, --help display help for command
19
28
  ```
20
29
 
21
30
  ## Example
22
31
 
23
- foo.tests:
32
+ This is the easiest case, when the function that you want to test is already
33
+ exposed as the default export or exported as a function named `test`:
34
+
35
+ tests/bar.tests:
24
36
  ```
25
- #! inline: (a, b) => a + b
26
- 5 2 3
27
- 8 7 1
37
+ # "8" is the expected result, "4" and "2" are the inputs to the tested function
38
+ 8 4 2
28
39
  ```
29
40
 
30
- run:
41
+ bar.js:
42
+ ```js
43
+ // The default or `test` export from the associated .js file is the
44
+ // tested function by default
45
+ export default mult(a, b) {
46
+ return a * b;
47
+ }
48
+
49
+ export function baz(a) {
50
+ return a * 6;
51
+ }
52
+ ```
53
+
54
+ To test the `baz` function, we needed to write a little wrapper code:
55
+
56
+ tests/baz.tests:
31
57
  ```
32
- unexpectedly foo.tests
58
+ #! inline: `export {baz as test} from '../foo.js'`
59
+ 6 1 # Expect 6 to result from `baz("1")`
60
+ 12 2
61
+ ```
62
+
63
+ run:
64
+ ```sh
65
+ unexpectedly tests/
33
66
  ```
34
67
 
35
68
  ## API
36
69
 
37
70
  ```js
38
- const suite = require('unexpectedly')
71
+ import {suite} from 'unexpectedly';
39
72
 
40
- // returns a Promise
41
- suite(directoryOrFile, jsPattern).catch(console.error)
73
+ await suite(directoryOrFile, {
74
+ defaultScript: '../$<base>.js',
75
+ function: 'test',
76
+ }).catch(console.error)
42
77
  ```
43
78
 
44
79
  ## Test language
@@ -57,11 +92,12 @@ test"
57
92
  ```
58
93
 
59
94
  Aggressive type coercion is used, including if the test JavaScript
60
- returns an non-string the result string is parsed as JSON before
95
+ returns an non-string the result string is parsed as JavaScript before
61
96
  comparison.
62
97
 
63
98
  Set file-wide globals with lines that start with `#!`, separating
64
- the name and the value with a colon:
99
+ the name and the value with a colon. These globals will be made available
100
+ to your test script as global variables:
65
101
 
66
102
  ```
67
103
  #! name: value
@@ -74,6 +110,11 @@ Special globals include:
74
110
  instead of the default
75
111
  - "timeout": If any test in the file takes longer than this (in ms),
76
112
  it fails. Default: 2000
113
+ - "peggy": Treat the input as a compiled peggy grammar; look for a "parse"
114
+ function in the input script, provide better errors, and use the value
115
+ of the peggy global (if any) as the startRule.
116
+
117
+ Environment variables can be set for the test code with `#!! name: value`.
77
118
 
78
119
  ## JavaScript to test
79
120
 
@@ -81,19 +122,24 @@ You can specify JavaScript to run for each test in one of three ways:
81
122
 
82
123
  - Use the mapping from test file name to a JavaScript file. By default, this
83
124
  takes the basename of the .tests file and looks up one directory for a .js
84
- file of the same basename. So `tests/foo.tests` looks for `foo.js`. If needed
85
- you can replace the mapping with something else, that uses `%1` as a placeholder
86
- for the basename.
125
+ file of the same basename. So `tests/foo.tests` looks for `foo.js`. If
126
+ needed you can replace the mapping with something else, that uses `$<base>`
127
+ as a placeholder for the basename.
87
128
  - Specify a global in your .tests file with the name `script`. That is the name
88
129
  of the file, relative to the directory that contains the .tests file, to look
89
130
  for JavaScript.
90
131
  - Specify a global in your .tests file with the name `inline`. That's just
91
132
  JavaScript to run per-line.
92
133
 
93
- For each of the above, make sure `module.exports` is a function, or that you
94
- export a function named `test`. For inline code, if you don't export anything,
95
- `module.exports=` will be added to the beginning of your code, so that you
96
- can just put in a simple function.
134
+ In es6 module projects, for each of the above, make sure `export default` is a
135
+ function, or that you export a function named `test`. For inline code, if you
136
+ don't export anything, `export default` will be added to the beginning of your
137
+ code, so that you can just put in a simple function.
138
+
139
+ In commonjs projects, for each of the above, make sure `module.exports` is a
140
+ function, or that you export a function named `test`. For inline code, if you
141
+ don't export anything, `module.exports=` will be added to the beginning of
142
+ your code, so that you can just put in a simple function.
97
143
 
98
144
  Special globals available in all forms of the JavaScript above include:
99
145
 
@@ -101,3 +147,6 @@ Special globals available in all forms of the JavaScript above include:
101
147
  - `__expected` is the string form of the expected result
102
148
  - `__line` is the line number where the test starts
103
149
  - `__column` is the column where the test text starts
150
+
151
+ [![Tests](https://github.com/hildjj/unexpectedly/actions/workflows/node.js.yml/badge.svg)](https://github.com/hildjj/unexpectedly/actions/workflows/node.js.yml)
152
+ [![codecov](https://codecov.io/gh/hildjj/unexpectedly/graph/badge.svg?token=H8EXAJRBU1)](https://codecov.io/gh/hildjj/unexpectedly)
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env -S node --experimental-vm-modules --no-warnings
2
+ /* eslint-disable no-console */
3
+
4
+ import {Command, Option} from 'commander';
5
+ import fs from 'node:fs/promises';
6
+ import {suite} from '../index.js';
7
+
8
+ let failures = 0;
9
+
10
+ async function main() {
11
+ const pkg = JSON.parse(await fs.readFile(
12
+ new URL('../package.json', import.meta.url),
13
+ 'utf-8'
14
+ ));
15
+
16
+ const program = new Command();
17
+ const opts = program
18
+ .version(pkg.version)
19
+ .argument('[...file]', 'Files or directories to test', './test/')
20
+ .option('-d,--defaultScript <replacement>', 'Find the script from the file name. Replace `$<base>` with the basename of the file.', '../$<base>.js')
21
+ .option('-f,--function <functionName>', 'Use this function for testing in the associated script', 'test')
22
+ .addOption(new Option('-s,--silent18', 'Silently skip mjs tests on node 18.').hideHelp())
23
+ .parse()
24
+ .opts();
25
+
26
+ const {args} = program;
27
+ if (args.length === 0) {
28
+ args.push('./test/');
29
+ }
30
+
31
+ for (const arg of args) {
32
+ // eslint-disable-next-line require-atomic-updates
33
+ failures += await suite(arg, opts);
34
+ }
35
+ }
36
+
37
+ await main().catch(er => {
38
+ console.error(er);
39
+ process.exit(1);
40
+ });
41
+
42
+ if (failures) {
43
+ console.error(`Total of ${failures} failure${failures === 1 ? '' : 's'}`);
44
+ process.exit(2);
45
+ }
package/index.js CHANGED
@@ -1,115 +1,173 @@
1
- 'use strict'
1
+ import {Runner, SKIPPED} from './runner.js';
2
+ import {hexlify, mapObj} from './utils.js';
3
+ import Mocha from 'mocha';
4
+ import assert from 'node:assert';
5
+ import fs from 'node:fs/promises';
6
+ import {parse} from './testFile.peg.js';
7
+ import path from 'node:path';
2
8
 
3
- const assert = require('assert')
4
- const fs = require('fs')
5
- const util = require('util')
6
- const path = require('path')
7
- const Mocha = require('mocha') // TODO: just use one of the reporters
8
- const parser = require('./testFile.peg')
9
- const Runner = require('./runner')
10
- const { mapObj } = require('./utils')
9
+ const EXCEPTION = Symbol('EXCEPTION');
11
10
 
12
- const EXCEPTION = Symbol('EXCEPTION')
13
-
14
- // TODO: once the warning goes away
15
- // const fsp = require('fs').promises
16
- const fsp = mapObj(['readdir', 'readFile', 'stat'],
17
- s => util.promisify(fs[s]))
18
-
19
- function coerce (actual, expected) {
11
+ async function coerce(actual, pt, runner) {
12
+ const {expected} = pt;
20
13
  if (typeof actual === 'string') {
21
- return [actual, expected]
14
+ return [actual, expected];
15
+ }
16
+ if (actual === SKIPPED) {
17
+ return [SKIPPED, SKIPPED];
22
18
  }
23
19
  if (actual && (typeof actual === 'object') && actual.constructor) {
24
- const name = actual.constructor.name
20
+ if (/^0x/.test(expected)) {
21
+ const {name} = actual.constructor;
25
22
 
26
- switch (name) {
27
- case 'Buffer':
28
- return ['0x' + actual.toString('hex'), expected]
29
- // note: other Typed Array's won't work for now because vm2 doens't
30
- // support them
23
+ switch (name) {
24
+ case 'Buffer':
25
+ return [`0x${actual.toString('hex')}`, expected];
26
+ case 'Float32Array':
27
+ case 'Float64Array':
28
+ case 'Int16Array':
29
+ case 'Int32Array':
30
+ case 'Int8Array':
31
+ case 'Uint16Array':
32
+ case 'Uint32Array':
33
+ case 'Uint8Array':
34
+ case 'Uint8ClampedArray':
35
+ return [`0x${hexlify(actual)}`, expected];
36
+ }
31
37
  }
32
38
  }
33
- return [actual, JSON.parse(expected)]
39
+
40
+ const exp = await runner.parse(expected, {
41
+ __line: pt.line,
42
+ __column: pt.column,
43
+ });
44
+ const def = Object.hasOwn(exp, 'default') ? exp.default : exp;
45
+ return [actual, def];
34
46
  }
35
47
 
36
48
  /**
37
- * Run a test suite from a file or directory
49
+ * @typedef {object} SuiteOptions
50
+ * @property {string} [defaultScript='../$<base>.js'] Find the script from the
51
+ * file name. Replace `$<base>` with the basename of the file. For
52
+ * example, "foo.test" will use "../foo.js" by default.
53
+ * @property {string} [function='test'] Use this function for testing in the
54
+ * associated script.
55
+ */
56
+
57
+ /**
58
+ * Run a test suite from a file or directory.
38
59
  *
39
- * @param {string} [target='.'] The file or directory to read. If a diretory,
40
- * reads all files ending in `.test`
41
- * @param {string} [defaultScript='../$1.js'] find the script from the file
42
- * name. Replace $1 with the basename of the file. For example, "foo.test"
43
- * will use "../foo.js" by default
44
- * @returns Promise fulfills on success
60
+ * @param {string} [target='.'] The file or directory to read. If a
61
+ * directory, reads all files ending in `.tests`.
62
+ * @param {SuiteOptions} [options={}] Options for processing the suite.
63
+ * @returns {Promise<number>} Promise fulfills on completion, with number of
64
+ * test failures.
45
65
  */
46
- async function suite (target = '.', defaultScript = '../$1.js') {
47
- let dir = path.resolve(target)
48
- let files = [dir]
66
+ export async function suite(target = '.', options = {}) {
67
+ options = {
68
+ defaultScript: '../$<base>.js',
69
+ function: 'test',
70
+ ...options,
71
+ };
72
+ let dir = path.resolve(target);
73
+ let files = [dir];
74
+ const mocha = new Mocha();
75
+ mocha.suite.title = dir;
49
76
 
50
- const stats = await fsp.stat(target)
77
+ const stats = await fs.stat(dir);
51
78
  if (stats.isDirectory()) {
52
- files = (await fsp.readdir(target))
53
- .filter(f => f.endsWith('.tests'))
54
- .map(f => path.join(dir, f))
79
+ files = (await fs.readdir(target))
80
+ .filter(f => /\.tests?$/.test(f))
81
+ .map(f => path.join(dir, f));
55
82
  } else if (stats.isFile()) {
56
- const p = path.parse(target)
57
- dir = p.dir
83
+ // eslint-disable-next-line require-atomic-updates
84
+ ({dir} = path.parse(target));
85
+ } else {
86
+ throw new Error(`Unknown file type: "${target}"`);
58
87
  }
59
- const mocha = new Mocha()
60
88
 
61
89
  for (const f of files) {
62
- const suite = Mocha.Suite.create(mocha.suite, f)
63
- const contents = await fsp.readFile(f, 'utf8')
64
- const parsed = parser.parse(contents, { EXCEPTION })
90
+ const msuite = Mocha.Suite.create(mocha.suite, f);
91
+ const contents = await fs.readFile(f, 'utf8');
65
92
 
66
- const opts = {}
67
- if (parsed.vars.inline) {
68
- opts.filename = f
69
- opts.text = parsed.vars.inline.value
70
- if (opts.text.indexOf('exports') === -1) {
71
- opts.text = 'module.exports= ' + opts.text // most common case
93
+ let parsed = null;
94
+ try {
95
+ parsed = parse(contents, {EXCEPTION, grammarSource: f});
96
+ } catch (er) {
97
+ if (typeof er.format === 'function') {
98
+ er.message = er.format([{
99
+ source: f,
100
+ text: contents,
101
+ }]);
102
+ delete er.expected;
103
+ delete er.location;
72
104
  }
73
- opts.lineOffset = parsed.vars.inline.line
74
- opts.columnOffset = parsed.vars.inline.column
75
- } else {
76
- opts.filename = parsed.vars.script
77
- ? path.resolve(dir, parsed.vars.script.value)
78
- : path.resolve(dir, f.replace(/([^./]*)\.tests?$/, defaultScript))
79
- }
80
- if (parsed.vars.timeout) {
81
- suite.timeout(parseInt(parsed.vars.timeout.value))
105
+ throw er;
82
106
  }
83
- opts.sandbox = mapObj(
84
- Object.entries(parsed.vars),
85
- ([key, value]) => [key, value.value])
86
107
 
87
- let runner = new Runner(opts)
108
+ for (const pt of parsed) {
109
+ const opts = {
110
+ testFunction: options.function,
111
+ };
112
+ let text = '';
113
+ if (pt.vars.inline) {
114
+ opts.filename = f;
115
+ text = pt.vars.inline.value;
116
+ opts.lineOffset = pt.vars.inline.line - 1;
117
+ opts.columnOffset = pt.vars.inline.column;
118
+ } else {
119
+ opts.filename = pt.vars.script ?
120
+ path.resolve(dir, pt.vars.script.value) :
121
+ path.resolve(dir,
122
+ f.replace(/(?<base>[^\\/]*)\.tests?$/, options.defaultScript));
123
+ text = await fs.readFile(opts.filename, 'utf8');
124
+ }
125
+ if (pt.vars.timeout) {
126
+ msuite.timeout(parseInt(pt.vars.timeout.value, 10));
127
+ }
128
+ opts.context = mapObj(
129
+ Object.entries(pt.vars).filter(([k, v]) => !v.env),
130
+ ([key, value]) => [key, value.value]
131
+ );
132
+ opts.env = mapObj(
133
+ Object.entries(pt.vars).filter(([k, v]) => v.env),
134
+ ([key, value]) => [key, value.value]
135
+ );
136
+ opts.silent18 = Boolean(options.silent18);
137
+
138
+ const runner = new Runner(opts);
88
139
 
89
- for (const pt of parsed.tests) {
90
- const t = new Mocha.Test(`line ${pt.line}`, async () => {
91
- let actual = null
140
+ const firstLine = (pt.expected === EXCEPTION) ? `! ${pt.inputs[0]}` : pt.expected.split(/\n/)[0];
141
+ const t = new Mocha.Test(`line ${pt.line}: ${firstLine || '""'}`, async() => {
142
+ let actual = null;
92
143
  try {
93
- actual = await runner.run({
144
+ actual = await runner.run(text, {
94
145
  __expected: pt.expected,
95
146
  __line: pt.line,
96
- __column: pt.column
97
- }, ...pt.inputs)
147
+ __column: pt.column,
148
+ __offset: pt.offset,
149
+ }, ...pt.inputs);
150
+ if (actual === SKIPPED) {
151
+ t.skip('Node 20.8 required');
152
+ return;
153
+ }
98
154
  } catch (e) {
99
- assert.strictEqual(pt.expected, EXCEPTION, e)
100
- return
155
+ if (pt.expected !== EXCEPTION) {
156
+ throw e;
157
+ }
158
+ return;
101
159
  }
102
- assert.notStrictEqual(pt.expected, EXCEPTION)
103
- assert.deepStrictEqual.apply(null, coerce(actual, pt.expected))
104
- })
105
- suite.addTest(t)
160
+ assert.notStrictEqual(pt.expected, EXCEPTION);
161
+ assert.deepEqual.apply(null, await coerce(actual, pt, runner));
162
+ });
163
+
164
+ msuite.addTest(t);
106
165
  }
107
166
  }
108
167
 
109
168
  return new Promise((resolve, reject) => {
110
- mocha.run(resolve)
111
- })
169
+ mocha.run(resolve);
170
+ });
112
171
  }
113
172
 
114
- suite.EXCEPTION = EXCEPTION
115
- module.exports = suite
173
+ suite.EXCEPTION = EXCEPTION;
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "unexpectedly",
3
- "version": "0.0.7",
3
+ "version": "2.0.0",
4
4
  "description": "Keep a bunch of test cases in a file, with their expected results",
5
5
  "main": "index.js",
6
+ "type": "module",
6
7
  "scripts": {
7
- "test": "bin/unexpectedly test",
8
- "pretest": "npm run grammar",
9
- "grammar": "peggy -o testFile.peg.js testFile.pegjs",
10
- "lint": "eslint *.js test/*.js bin/*",
11
- "release": "npm version patch && git push --follow-tags && npm publish"
8
+ "build": "peggy -o testFile.peg.js --format es testFile.peggy",
9
+ "pretest": "npm run build",
10
+ "test": "c8 npm run runtests",
11
+ "runtests": "mocha && bin/unexpectedly.js -s test test/pkg_mjs/test test/pkg_cjs/test",
12
+ "lint": "eslint --ext js,cjs,mjs . bin/*",
13
+ "ci": "npm run lint && npm run test",
14
+ "clean": "rimraf coverage testFile.peg.js"
12
15
  },
13
16
  "bin": {
14
- "unexpectedly": "bin/unexpectedly"
17
+ "unexpectedly": "bin/unexpectedly.js"
15
18
  },
16
19
  "keywords": [
17
20
  "test",
@@ -23,25 +26,28 @@
23
26
  "harness"
24
27
  ],
25
28
  "author": "Joe Hildebrand <joe-github@cursive.net>",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "http://github.com/hildjj/unexpectedly.git"
32
+ },
26
33
  "license": "MPL-2.0",
34
+ "dependencies": {
35
+ "@peggyjs/from-mem": "1.3.0",
36
+ "commander": "12.0.0",
37
+ "mocha": "^10.3.0",
38
+ "semver": "7.6.0"
39
+ },
27
40
  "devDependencies": {
28
- "eslint": "^7.24.0",
29
- "eslint-config-standard": "^16.0.2",
30
- "eslint-plugin-import": "^2.22.1",
31
- "eslint-plugin-node": "^11.1.0",
32
- "eslint-plugin-promise": "^5.1.0",
33
- "eslint-plugin-standard": "^4.1.0",
34
- "peggy": "^1.0.0"
41
+ "@cto.af/eslint-config": "3.1.0",
42
+ "c8": "9.1.0",
43
+ "eslint": "^8.57.0",
44
+ "eslint-plugin-jsdoc": "48.2.0",
45
+ "peggy": "^4.0.2",
46
+ "rimraf": "5.0.5",
47
+ "zx": "7.2.3"
35
48
  },
49
+ "packageManager": "pnpm@8.15.4",
36
50
  "engines": {
37
- "node": ">=10"
38
- },
39
- "dependencies": {
40
- "mocha": "^8.3.2",
41
- "vm2": "^3.9.3"
42
- },
43
- "repository": {
44
- "type": "git",
45
- "url": "http://github.com/hildjj/unexpectedly.git"
51
+ "node": ">=18"
46
52
  }
47
53
  }