unexpectedly 0.0.7 → 1.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,38 +4,64 @@ 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]
14
+ unexpectedly [--defaultScript jsPattern] <...file|directory>
15
+ jsPattern (default: "../$<base>.js"): find the JS file relative to
16
+ the .tests file, replacing `$<base>` with the basename of the test file.
17
+ This may be specified multiple times, and modifies all files/directories
18
+ after.
15
19
  file: a file that contains tests
16
20
  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
19
21
  ```
20
22
 
21
23
  ## Example
22
24
 
23
- foo.tests:
25
+ This is the easiest case, when the function that you want to test is already
26
+ exposed as the default export or exported as a function named `test`:
27
+
28
+ tests/bar.tests:
24
29
  ```
25
- #! inline: (a, b) => a + b
26
- 5 2 3
27
- 8 7 1
30
+ # "8" is the expected result, "4" and "2" are the inputs to the tested function
31
+ 8 4 2
28
32
  ```
29
33
 
30
- run:
34
+ bar.js:
35
+ ```js
36
+ // The default or `test` export from the associated .js file is the
37
+ // tested function by default
38
+ export default mult(a, b) {
39
+ return a * b;
40
+ }
41
+
42
+ export function baz(a) {
43
+ return a * 6;
44
+ }
45
+ ```
46
+
47
+ To test the `baz` function, we needed to write a little wrapper code:
48
+
49
+ tests/baz.tests:
31
50
  ```
32
- unexpectedly foo.tests
51
+ #! inline: `export {baz as test} from '../foo.js'`
52
+ 6 1 # Expect 6 to result from `baz("1")`
53
+ 12 2
54
+ ```
55
+
56
+ run:
57
+ ```sh
58
+ unexpectedly tests/
33
59
  ```
34
60
 
35
61
  ## API
36
62
 
37
63
  ```js
38
- const suite = require('unexpectedly')
64
+ import {suite} from 'unexpectedly';
39
65
 
40
66
  // returns a Promise
41
67
  suite(directoryOrFile, jsPattern).catch(console.error)
@@ -57,11 +83,12 @@ test"
57
83
  ```
58
84
 
59
85
  Aggressive type coercion is used, including if the test JavaScript
60
- returns an non-string the result string is parsed as JSON before
86
+ returns an non-string the result string is parsed as JavaScript before
61
87
  comparison.
62
88
 
63
89
  Set file-wide globals with lines that start with `#!`, separating
64
- the name and the value with a colon:
90
+ the name and the value with a colon. These globals will be made available
91
+ to your test script as global variables:
65
92
 
66
93
  ```
67
94
  #! name: value
@@ -75,25 +102,32 @@ Special globals include:
75
102
  - "timeout": If any test in the file takes longer than this (in ms),
76
103
  it fails. Default: 2000
77
104
 
105
+ Environment variables can be set for the test code with `#!! name: value`.
106
+
78
107
  ## JavaScript to test
79
108
 
80
109
  You can specify JavaScript to run for each test in one of three ways:
81
110
 
82
111
  - Use the mapping from test file name to a JavaScript file. By default, this
83
112
  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.
113
+ file of the same basename. So `tests/foo.tests` looks for `foo.js`. If
114
+ needed you can replace the mapping with something else, that uses `$<base>`
115
+ as a placeholder for the basename.
87
116
  - Specify a global in your .tests file with the name `script`. That is the name
88
117
  of the file, relative to the directory that contains the .tests file, to look
89
118
  for JavaScript.
90
119
  - Specify a global in your .tests file with the name `inline`. That's just
91
120
  JavaScript to run per-line.
92
121
 
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.
122
+ In es6 module projects, for each of the above, make sure `export default` is a
123
+ function, or that you export a function named `test`. For inline code, if you
124
+ don't export anything, `export default` will be added to the beginning of your
125
+ code, so that you can just put in a simple function.
126
+
127
+ In commonjs projects, for each of the above, make sure `module.exports` is a
128
+ function, or that you export a function named `test`. For inline code, if you
129
+ don't export anything, `module.exports=` will be added to the beginning of
130
+ your code, so that you can just put in a simple function.
97
131
 
98
132
  Special globals available in all forms of the JavaScript above include:
99
133
 
@@ -101,3 +135,6 @@ Special globals available in all forms of the JavaScript above include:
101
135
  - `__expected` is the string form of the expected result
102
136
  - `__line` is the line number where the test starts
103
137
  - `__column` is the column where the test text starts
138
+
139
+ [![Tests](https://github.com/hildjj/unexpectedly/actions/workflows/node.js.yml/badge.svg)](https://github.com/hildjj/unexpectedly/actions/workflows/node.js.yml)
140
+ [![codecov](https://codecov.io/gh/hildjj/unexpectedly/graph/badge.svg?token=H8EXAJRBU1)](https://codecov.io/gh/hildjj/unexpectedly)
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env -S node --experimental-vm-modules --no-warnings
2
+ /* eslint-disable no-console */
3
+
4
+ import {suite} from '../index.js';
5
+
6
+ let failures = 0;
7
+
8
+ async function main() {
9
+ let defaultScript = undefined;
10
+ let nextDefault = false;
11
+ for (const arg of process.argv.slice(2)) {
12
+ if (nextDefault) {
13
+ defaultScript = arg;
14
+ nextDefault = false;
15
+ } else if (arg === '--defaultScript') {
16
+ nextDefault = true;
17
+ } else {
18
+ // eslint-disable-next-line require-atomic-updates
19
+ failures += await suite(arg, defaultScript);
20
+ }
21
+ }
22
+ }
23
+
24
+ await main().catch(er => {
25
+ console.error(er);
26
+ process.exit(1);
27
+ });
28
+
29
+ if (failures) {
30
+ console.error(`Total of ${failures} failure${failures === 1 ? '' : 's'}`);
31
+ process.exit(2);
32
+ }
package/index.js CHANGED
@@ -1,115 +1,147 @@
1
- 'use strict'
1
+ import {hexlify, mapObj} from './utils.js';
2
+ import Mocha from 'mocha';
3
+ import {Runner} from './runner.js';
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];
22
15
  }
23
16
  if (actual && (typeof actual === 'object') && actual.constructor) {
24
- const name = actual.constructor.name
17
+ if (/^0x/.test(expected)) {
18
+ const {name} = actual.constructor;
25
19
 
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
20
+ switch (name) {
21
+ case 'Buffer':
22
+ return [`0x${actual.toString('hex')}`, expected];
23
+ case 'Float32Array':
24
+ case 'Float64Array':
25
+ case 'Int16Array':
26
+ case 'Int32Array':
27
+ case 'Int8Array':
28
+ case 'Uint16Array':
29
+ case 'Uint32Array':
30
+ case 'Uint8Array':
31
+ case 'Uint8ClampedArray':
32
+ return [`0x${hexlify(actual)}`, expected];
33
+ }
31
34
  }
32
35
  }
33
- return [actual, JSON.parse(expected)]
36
+
37
+ const exp = await runner.parse(expected, {
38
+ __line: pt.line,
39
+ __column: pt.column,
40
+ });
41
+ const def = Object.hasOwn(exp, 'default') ? exp.default : exp;
42
+ return [actual, def];
34
43
  }
35
44
 
36
45
  /**
37
- * Run a test suite from a file or directory
46
+ * Run a test suite from a file or directory.
38
47
  *
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
48
+ * @param {string} [target='.'] The file or directory to read. If a
49
+ * directory, reads all files ending in `.tests`.
50
+ * @param {string} [defaultScript='../$<base>.js'] Find the script from the
51
+ * file name. Replace $1 with the basename of the file. For example,
52
+ * "foo.test" will use "../foo.js" by default.
53
+ * @returns {Promise<number>} Promise fulfills on completion, with number of
54
+ * test failures.
45
55
  */
46
- async function suite (target = '.', defaultScript = '../$1.js') {
47
- let dir = path.resolve(target)
48
- let files = [dir]
56
+ export async function suite(target = '.', defaultScript = '../$<base>.js') {
57
+ let dir = path.resolve(target);
58
+ let files = [dir];
59
+ const mocha = new Mocha();
60
+ mocha.suite.title = dir;
49
61
 
50
- const stats = await fsp.stat(target)
62
+ const stats = await fs.stat(dir);
51
63
  if (stats.isDirectory()) {
52
- files = (await fsp.readdir(target))
53
- .filter(f => f.endsWith('.tests'))
54
- .map(f => path.join(dir, f))
64
+ files = (await fs.readdir(target))
65
+ .filter(f => /\.tests?$/.test(f))
66
+ .map(f => path.join(dir, f));
55
67
  } else if (stats.isFile()) {
56
- const p = path.parse(target)
57
- dir = p.dir
68
+ // eslint-disable-next-line require-atomic-updates
69
+ ({dir} = path.parse(target));
70
+ } else {
71
+ throw new Error(`Unknown file type: "${target}"`);
58
72
  }
59
- const mocha = new Mocha()
60
73
 
61
74
  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 })
75
+ const msuite = Mocha.Suite.create(mocha.suite, f);
76
+ const contents = await fs.readFile(f, 'utf8');
65
77
 
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
78
+ let parsed = null;
79
+ try {
80
+ parsed = parse(contents, {EXCEPTION, grammarSource: f});
81
+ } catch (er) {
82
+ if (typeof er.format === 'function') {
83
+ er.message = er.format([{
84
+ source: f,
85
+ text: contents,
86
+ }]);
87
+ delete er.expected;
88
+ delete er.location;
72
89
  }
73
- opts.lineOffset = parsed.vars.inline.line
74
- opts.columnOffset = parsed.vars.inline.column
90
+ throw er;
91
+ }
92
+
93
+ const opts = {};
94
+ let text = '';
95
+ if (parsed.vars.inline) {
96
+ opts.filename = f;
97
+ text = parsed.vars.inline.value;
98
+ opts.lineOffset = parsed.vars.inline.line - 1;
99
+ opts.columnOffset = parsed.vars.inline.column;
75
100
  } else {
76
- opts.filename = parsed.vars.script
77
- ? path.resolve(dir, parsed.vars.script.value)
78
- : path.resolve(dir, f.replace(/([^./]*)\.tests?$/, defaultScript))
101
+ opts.filename = parsed.vars.script ?
102
+ path.resolve(dir, parsed.vars.script.value) :
103
+ path.resolve(dir, f.replace(/(?<base>[^./]*)\.tests?$/, defaultScript));
104
+ text = await fs.readFile(opts.filename, 'utf8');
79
105
  }
80
106
  if (parsed.vars.timeout) {
81
- suite.timeout(parseInt(parsed.vars.timeout.value))
107
+ msuite.timeout(parseInt(parsed.vars.timeout.value, 10));
82
108
  }
83
109
  opts.sandbox = mapObj(
84
- Object.entries(parsed.vars),
85
- ([key, value]) => [key, value.value])
86
-
87
- let runner = new Runner(opts)
110
+ Object.entries(parsed.vars).filter(([k, v]) => !v.env),
111
+ ([key, value]) => [key, value.value]
112
+ );
113
+ opts.env = mapObj(
114
+ Object.entries(parsed.vars).filter(([k, v]) => v.env),
115
+ ([key, value]) => [key, value.value]
116
+ );
117
+ const runner = new Runner(opts);
88
118
 
89
119
  for (const pt of parsed.tests) {
90
- const t = new Mocha.Test(`line ${pt.line}`, async () => {
91
- let actual = null
120
+ const firstLine = (pt.expected === EXCEPTION) ? '!' : pt.expected.split(/\n/)[0];
121
+ const t = new Mocha.Test(`line ${pt.line}: ${firstLine || '""'}`, async() => {
122
+ let actual = null;
92
123
  try {
93
- actual = await runner.run({
124
+ actual = await runner.run(text, {
94
125
  __expected: pt.expected,
95
126
  __line: pt.line,
96
- __column: pt.column
97
- }, ...pt.inputs)
127
+ __column: pt.column,
128
+ }, ...pt.inputs);
98
129
  } catch (e) {
99
- assert.strictEqual(pt.expected, EXCEPTION, e)
100
- return
130
+ if (pt.expected !== EXCEPTION) {
131
+ throw e;
132
+ }
133
+ return;
101
134
  }
102
- assert.notStrictEqual(pt.expected, EXCEPTION)
103
- assert.deepStrictEqual.apply(null, coerce(actual, pt.expected))
104
- })
105
- suite.addTest(t)
135
+ assert.notStrictEqual(pt.expected, EXCEPTION);
136
+ assert.deepEqual.apply(null, await coerce(actual, pt, runner));
137
+ });
138
+ msuite.addTest(t);
106
139
  }
107
140
  }
108
141
 
109
142
  return new Promise((resolve, reject) => {
110
- mocha.run(resolve)
111
- })
143
+ mocha.run(resolve);
144
+ });
112
145
  }
113
146
 
114
- suite.EXCEPTION = EXCEPTION
115
- module.exports = suite
147
+ suite.EXCEPTION = EXCEPTION;
package/modtype.js ADDED
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export class ModType {
5
+ static #cache = new Map(); // Filename => 'commonjs', 'module', 'unknown'
6
+
7
+ static async #readPkg(dir) {
8
+ const pkg = path.join(dir, 'package.json');
9
+ try {
10
+ const {type = 'commonjs'} = JSON.parse(await fs.readFile(pkg, 'utf8'));
11
+ return type;
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ }
16
+
17
+ static async find(name, dir = false, root = undefined) {
18
+ const prev = this.#cache.get(name);
19
+ if (prev) {
20
+ return prev;
21
+ }
22
+ let res = 'unknown';
23
+
24
+ if (!root) {
25
+ const p = path.parse(name);
26
+ switch (p.ext) {
27
+ case '.mjs':
28
+ res = 'module';
29
+ this.#cache.set(name, res);
30
+ return res;
31
+ case '.cjs':
32
+ res = 'commonjs';
33
+ this.#cache.set(name, res);
34
+ return res;
35
+ }
36
+ ({root} = p);
37
+ try {
38
+ // Avoid problems if an existing directory name is passed in originally.
39
+ dir = (await fs.stat(name)).isDirectory();
40
+ } catch {
41
+ // File doesn't exist. Safe to assume it's file-ish, keep dir = false.
42
+ }
43
+ }
44
+
45
+ if (name !== root) { // Never go all the way to the root directory.
46
+ const t = dir ? await this.#readPkg(name) : undefined;
47
+ res = t ?? this.find(path.dirname(name), true, root);
48
+ }
49
+ this.#cache.set(name, res);
50
+ return res;
51
+ }
52
+
53
+ static clear() {
54
+ this.#cache.clear();
55
+ }
56
+
57
+ static get cacheSize() {
58
+ return this.#cache.size;
59
+ }
60
+ }
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "unexpectedly",
3
- "version": "0.0.7",
3
+ "version": "1.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.pegjs",
9
+ "pretest": "npm run build",
10
+ "test": "c8 npm run runtests",
11
+ "runtests": "mocha && bin/unexpectedly.js 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,30 @@
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
+ "mocha": "^10.2.0"
36
+ },
27
37
  "devDependencies": {
28
- "eslint": "^7.24.0",
29
- "eslint-config-standard": "^16.0.2",
30
- "eslint-plugin-import": "^2.22.1",
38
+ "@cto.af/eslint-config": "3.0.2",
39
+ "c8": "8.0.1",
40
+ "eslint": "^8.54.0",
41
+ "eslint-config-standard": "^17.1.0",
42
+ "eslint-plugin-import": "^2.29.0",
43
+ "eslint-plugin-jsdoc": "46.9.0",
44
+ "eslint-plugin-n": "16.3.1",
31
45
  "eslint-plugin-node": "^11.1.0",
32
- "eslint-plugin-promise": "^5.1.0",
46
+ "eslint-plugin-promise": "^6.1.1",
33
47
  "eslint-plugin-standard": "^4.1.0",
34
- "peggy": "^1.0.0"
48
+ "peggy": "^3.0.2",
49
+ "rimraf": "5.0.5",
50
+ "zx": "7.2.3"
35
51
  },
36
52
  "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"
53
+ "node": ">=18"
46
54
  }
47
55
  }