unexpectedly 1.0.0 → 3.0.3

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
@@ -11,13 +11,20 @@ npm install -g unexpectedly
11
11
  ## Usage
12
12
 
13
13
  ```
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.
19
- file: a file that contains tests
20
- directory (default: "."): a directory containing ".tests" files
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
21
28
  ```
22
29
 
23
30
  ## Example
@@ -63,8 +70,10 @@ unexpectedly tests/
63
70
  ```js
64
71
  import {suite} from 'unexpectedly';
65
72
 
66
- // returns a Promise
67
- suite(directoryOrFile, jsPattern).catch(console.error)
73
+ await suite(directoryOrFile, {
74
+ defaultScript: '../$<base>.js',
75
+ function: 'test',
76
+ }).catch(console.error)
68
77
  ```
69
78
 
70
79
  ## Test language
@@ -101,6 +110,9 @@ Special globals include:
101
110
  instead of the default
102
111
  - "timeout": If any test in the file takes longer than this (in ms),
103
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.
104
116
 
105
117
  Environment variables can be set for the test code with `#!! name: value`.
106
118
 
@@ -1,23 +1,28 @@
1
1
  #!/usr/bin/env -S node --experimental-vm-modules --no-warnings
2
- /* eslint-disable no-console */
3
-
2
+ import {Command} from 'commander';
4
3
  import {suite} from '../index.js';
4
+ import {version} from '../version.js';
5
5
 
6
6
  let failures = 0;
7
7
 
8
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
- }
9
+ const program = new Command();
10
+ const opts = program
11
+ .version(version)
12
+ .argument('[file...]', 'Files or directories to test', './test/')
13
+ .option('-d,--defaultScript <replacement>', 'Find the script from the file name. Replace `$<base>` with the basename of the file.', '../$<base>.js')
14
+ .option('-f,--function <functionName>', 'Use this function for testing in the associated script', 'test')
15
+ .parse()
16
+ .opts();
17
+
18
+ const {args} = program;
19
+ if (args.length === 0) {
20
+ args.push('./test/');
21
+ }
22
+
23
+ for (const arg of args) {
24
+ // eslint-disable-next-line require-atomic-updates
25
+ failures += await suite(arg, opts);
21
26
  }
22
27
  }
23
28
 
@@ -0,0 +1,26 @@
1
+ import base from '@cto.af/eslint-config';
2
+ import mocha from '@cto.af/eslint-config/mocha.js';
3
+ import mod from '@cto.af/eslint-config/module.js';
4
+
5
+ export default [
6
+ {
7
+ ignores: [
8
+ 'docs/**',
9
+ 'coverage/**',
10
+ '**/*.peg.js',
11
+ '**/*.d.ts',
12
+ 'test/pkg_bad/package.json',
13
+ ],
14
+ },
15
+ ...base,
16
+ ...mod,
17
+ ...mocha,
18
+ {
19
+ files: [
20
+ 'test/**/*.js',
21
+ ],
22
+ rules: {
23
+ 'n/no-top-level-await': 'off',
24
+ },
25
+ },
26
+ ];
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
+ import {Runner, SKIPPED} from './runner.js';
1
2
  import {hexlify, mapObj} from './utils.js';
2
3
  import Mocha from 'mocha';
3
- import {Runner} from './runner.js';
4
4
  import assert from 'node:assert';
5
5
  import fs from 'node:fs/promises';
6
6
  import {parse} from './testFile.peg.js';
@@ -13,6 +13,9 @@ async function coerce(actual, pt, runner) {
13
13
  if (typeof actual === 'string') {
14
14
  return [actual, expected];
15
15
  }
16
+ if (actual === SKIPPED) {
17
+ return [SKIPPED, SKIPPED];
18
+ }
16
19
  if (actual && (typeof actual === 'object') && actual.constructor) {
17
20
  if (/^0x/.test(expected)) {
18
21
  const {name} = actual.constructor;
@@ -42,18 +45,30 @@ async function coerce(actual, pt, runner) {
42
45
  return [actual, def];
43
46
  }
44
47
 
48
+ /**
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
+
45
57
  /**
46
58
  * Run a test suite from a file or directory.
47
59
  *
48
60
  * @param {string} [target='.'] The file or directory to read. If a
49
61
  * 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.
62
+ * @param {SuiteOptions} [options={}] Options for processing the suite.
53
63
  * @returns {Promise<number>} Promise fulfills on completion, with number of
54
64
  * test failures.
55
65
  */
56
- export async function suite(target = '.', defaultScript = '../$<base>.js') {
66
+ export async function suite(target = '.', options = {}) {
67
+ options = {
68
+ defaultScript: '../$<base>.js',
69
+ function: 'test',
70
+ ...options,
71
+ };
57
72
  let dir = path.resolve(target);
58
73
  let files = [dir];
59
74
  const mocha = new Mocha();
@@ -90,42 +105,61 @@ export async function suite(target = '.', defaultScript = '../$<base>.js') {
90
105
  throw er;
91
106
  }
92
107
 
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;
100
- } else {
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');
105
- }
106
- if (parsed.vars.timeout) {
107
- msuite.timeout(parseInt(parsed.vars.timeout.value, 10));
108
- }
109
- opts.sandbox = mapObj(
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);
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
+ if (pt.vars.script) {
120
+ opts.filename = path.resolve(dir, pt.vars.script.value);
121
+ } else {
122
+ // Avoid a previous ReDOS regexp.
123
+ const p = path.parse(f);
124
+ if (/^\.tests?$/.test(p.ext)) {
125
+ delete p.base;
126
+ delete p.ext;
127
+ p.name = p.name.replace(/^(?<base>.*)$/, options.defaultScript);
128
+ opts.filename = path.resolve(dir, path.format(p));
129
+ } else {
130
+ opts.filename = path.resolve(dir, f);
131
+ }
132
+ }
133
+ text = await fs.readFile(opts.filename, 'utf8');
134
+ }
135
+ if (pt.vars.timeout) {
136
+ msuite.timeout(parseInt(pt.vars.timeout.value, 10));
137
+ }
138
+ opts.context = mapObj(
139
+ Object.entries(pt.vars).filter(([_k, v]) => !v.env),
140
+ ([key, value]) => [key, value.value]
141
+ );
142
+ opts.env = mapObj(
143
+ Object.entries(pt.vars).filter(([_k, v]) => v.env),
144
+ ([key, value]) => [key, value.value]
145
+ );
118
146
 
119
- for (const pt of parsed.tests) {
120
- const firstLine = (pt.expected === EXCEPTION) ? '!' : pt.expected.split(/\n/)[0];
121
- const t = new Mocha.Test(`line ${pt.line}: ${firstLine || '""'}`, async() => {
147
+ const runner = new Runner(opts);
148
+
149
+ const firstLine = (pt.expected === EXCEPTION) ? `! ${pt.inputs[0]}` : pt.expected.split(/\n/)[0];
150
+ const t = new Mocha.Test(`line ${pt.line}: ${firstLine || '""'}`, async () => {
122
151
  let actual = null;
123
152
  try {
124
153
  actual = await runner.run(text, {
125
154
  __expected: pt.expected,
126
155
  __line: pt.line,
127
156
  __column: pt.column,
157
+ __offset: pt.offset,
128
158
  }, ...pt.inputs);
159
+ if (actual === SKIPPED) {
160
+ t.skip('Node 20.8 required');
161
+ return;
162
+ }
129
163
  } catch (e) {
130
164
  if (pt.expected !== EXCEPTION) {
131
165
  throw e;
@@ -135,11 +169,12 @@ export async function suite(target = '.', defaultScript = '../$<base>.js') {
135
169
  assert.notStrictEqual(pt.expected, EXCEPTION);
136
170
  assert.deepEqual.apply(null, await coerce(actual, pt, runner));
137
171
  });
172
+
138
173
  msuite.addTest(t);
139
174
  }
140
175
  }
141
176
 
142
- return new Promise((resolve, reject) => {
177
+ return new Promise((resolve, _reject) => {
143
178
  mocha.run(resolve);
144
179
  });
145
180
  }
package/package.json CHANGED
@@ -1,18 +1,9 @@
1
1
  {
2
2
  "name": "unexpectedly",
3
- "version": "1.0.0",
3
+ "version": "3.0.3",
4
4
  "description": "Keep a bunch of test cases in a file, with their expected results",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
- "scripts": {
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"
15
- },
16
7
  "bin": {
17
8
  "unexpectedly": "bin/unexpectedly.js"
18
9
  },
@@ -32,24 +23,11 @@
32
23
  },
33
24
  "license": "MPL-2.0",
34
25
  "dependencies": {
35
- "mocha": "^10.2.0"
36
- },
37
- "devDependencies": {
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",
45
- "eslint-plugin-node": "^11.1.0",
46
- "eslint-plugin-promise": "^6.1.1",
47
- "eslint-plugin-standard": "^4.1.0",
48
- "peggy": "^3.0.2",
49
- "rimraf": "5.0.5",
50
- "zx": "7.2.3"
26
+ "@peggyjs/from-mem": "3.1.1",
27
+ "commander": "14.0.0",
28
+ "mocha": "^11.7.1"
51
29
  },
52
30
  "engines": {
53
- "node": ">=18"
31
+ "node": ">=20"
54
32
  }
55
33
  }
package/runner.js CHANGED
@@ -1,38 +1,16 @@
1
- import {ModType} from './modtype.js';
2
- import {createRequire} from 'node:module';
3
- import {mapObj} from './utils.js';
1
+ import GrammarLocation from './vendor/peggy/grammar-location.cjs';
2
+ import fromMem from '@peggyjs/from-mem';
4
3
  import path from 'node:path';
5
- import vm from 'node:vm';
6
4
 
7
- const vmGlobals = new vm.Script('Object.getOwnPropertyNames(globalThis)')
8
- .runInNewContext()
9
- .filter(f => !f.startsWith('global'));
10
- vmGlobals.push('process', 'Buffer');
5
+ const PREFIX_MJS = 'export default ';
6
+ const PREFIX_CJS = 'module.exports = ';
7
+ const PREFIX_MJS_LEN = PREFIX_MJS.length;
8
+ const PREFIX_CJS_LEN = PREFIX_CJS.length;
11
9
 
12
- async function linker(specifier, referencingModule, cache) {
13
- // Note: not convinced the cache actually does anything, due to timing.
14
- if (cache?.has(specifier)) {
15
- return cache.get(specifier);
16
- }
17
- const m = await import(specifier);
18
- const exportNames = Object.keys(m);
19
- const imported = new vm.SyntheticModule(
20
- exportNames,
21
- // eslint-disable-next-line func-names
22
- function() {
23
- // eslint-disable-next-line no-invalid-this
24
- exportNames.forEach(key => this.setExport(key, m[key]));
25
- },
26
- {identifier: specifier, context: referencingModule.context}
27
- );
28
-
29
- cache?.set(specifier, imported);
30
- return imported;
31
- }
10
+ export const SKIPPED = Symbol('SKIPPED');
32
11
 
33
12
  /**
34
- * Fancy "eval". This is NOT for adding security, even though we're using
35
- * node:vm. This is about providing:
13
+ * Fancy "eval". This is NOT for adding security. This is about providing:
36
14
  *
37
15
  * - Good backtraces with file/line/column information correct, even when the
38
16
  * code was inlined into a test.
@@ -42,111 +20,122 @@ async function linker(specifier, referencingModule, cache) {
42
20
  */
43
21
  export class Runner {
44
22
  #columnOffset;
23
+ #context;
45
24
  #dirname;
46
25
  #env;
47
26
  #filename;
48
27
  #lineOffset;
49
- #sandbox;
28
+ #testFunction;
50
29
  #type;
30
+ #skipped;
51
31
 
52
32
  constructor({
53
- filename = null, // Resolved
33
+ filename = null, // Fully-qualified
54
34
  lineOffset = 0,
55
35
  columnOffset = 0,
56
- sandbox = null,
36
+ context = {},
57
37
  env = {},
58
- type = null,
38
+ testFunction = 'test',
39
+ type = 'guess',
59
40
  } = {}) {
41
+ if (typeof filename !== 'string') {
42
+ throw new TypeError('filename not specified');
43
+ }
60
44
  this.#filename = filename;
61
45
  this.#dirname = path.dirname(filename);
62
46
  this.#env = env;
63
- this.#sandbox = sandbox;
47
+ this.#context = context;
64
48
  this.#columnOffset = columnOffset;
65
49
  this.#lineOffset = lineOffset;
50
+ this.#skipped = null;
66
51
  this.#type = type;
52
+ this.#testFunction = testFunction;
53
+ }
54
+
55
+ get skipped() {
56
+ return this.#skipped;
67
57
  }
68
58
 
69
59
  async parse(text, extra = {}) {
70
- if (!this.#type) {
71
- this.#type = await ModType.find(this.#filename);
60
+ const format = this.#type === 'guess' ?
61
+ await fromMem.guessModuleType(this.#filename) :
62
+ this.#type;
63
+
64
+ let columnOffset = this.#columnOffset;
65
+ if (format === 'es') {
66
+ if (text.indexOf('export') === -1) {
67
+ text = PREFIX_MJS + text; // Most common case
68
+ columnOffset -= PREFIX_MJS_LEN;
69
+ }
70
+ } else if (text.indexOf('exports') === -1) {
71
+ text = PREFIX_CJS + text; // Most common case
72
+ columnOffset -= PREFIX_CJS_LEN;
72
73
  }
73
- // Find package.json relative to test, decide whether we're commonjs or
74
- // module.
75
- const exports = {};
76
- const context = {
77
- ...mapObj(vmGlobals, g => (
78
- (typeof globalThis[g] === 'function') ?
79
- globalThis[g] :
80
- {...globalThis[g]}
81
- )),
82
- require: createRequire(this.#filename),
83
- exports,
84
- module: {
85
- exports,
74
+
75
+ return await fromMem(text, {
76
+ filename: this.#filename,
77
+ format,
78
+ env: this.#env,
79
+ lineOffset: this.#lineOffset,
80
+ columnOffset,
81
+ context: {
86
82
  filename: this.#filename,
87
83
  id: this.#filename,
88
84
  path: this.#dirname,
85
+ ...this.#context,
86
+ ...extra,
89
87
  },
90
- __filename: this.#filename,
91
- __dirname: this.#dirname,
92
- ...this.#sandbox,
93
- ...extra,
94
- };
95
- context.process.env = this.#env;
96
- context.global = context;
97
- context.globalThis = context;
98
- vm.createContext(context);
88
+ });
89
+ }
99
90
 
100
- let f = null;
101
- const imports = new Map();
91
+ async run(text, extra = {}, ...params) {
92
+ let f = await this.parse(text, extra);
93
+ if (f === SKIPPED) {
94
+ return f;
95
+ }
102
96
 
103
- if (this.#type === 'module') {
104
- if (text.indexOf('export') === -1) {
105
- text = `export default ${text}`; // Most common case
106
- }
97
+ const possibleFunctions = [
98
+ this.#context.function,
99
+ this.#testFunction,
100
+ 'default',
101
+ ];
107
102
 
108
- const mod = new vm.SourceTextModule(text, {
109
- context,
110
- id: this.#filename,
111
- lineOffset: this.#lineOffset,
112
- columnOffset: this.#columnOffset,
103
+ let source = null;
104
+ if ('peggy' in this.#context) {
105
+ source = new GrammarLocation(this.#filename, {
106
+ line: extra.__line,
107
+ column: extra.__column,
108
+ offset: extra.__offset,
113
109
  });
110
+ const popts = {
111
+ grammarSource: source,
112
+ };
113
+ if (this.#context.peggy?.length > 0) {
114
+ popts.startRule = this.#context.peggy;
115
+ }
116
+ params[1] = popts;
117
+ possibleFunctions.splice(1, 0, 'parse');
118
+ }
114
119
 
115
- await mod.link((specifier, referencingModule) => {
116
- if (path.isAbsolute(specifier) || !/^\.\.?\//.test(specifier)) {
117
- return linker(specifier, referencingModule, imports);
120
+ if (f && (typeof f !== 'function')) {
121
+ for (const possible of possibleFunctions) {
122
+ if (possible && (typeof f[possible] === 'function')) {
123
+ f = f[possible];
124
+ break;
118
125
  }
119
- return linker(
120
- path.resolve(path.dirname(this.#filename), specifier),
121
- referencingModule,
122
- imports
123
- );
124
- });
125
- await mod.evaluate();
126
- f = mod.namespace;
127
- } else {
128
- if (text.indexOf('exports') === -1) {
129
- text = `module.exports= ${text}`; // Most common case
130
126
  }
131
-
132
- const script = new vm.Script(text, {
133
- filename: this.#filename,
134
- lineOffset: this.#lineOffset,
135
- columnOffset: this.#columnOffset,
136
- });
137
- f = script.runInContext(context);
138
127
  }
139
- return f;
140
- }
141
-
142
- async run(text, extra = {}, ...params) {
143
- let f = await this.parse(text, extra);
144
128
  if (typeof f !== 'function') {
145
- f = f?.test ?? f?.default;
146
- if (typeof f !== 'function') {
147
- throw new Error('Must export function or {test}');
129
+ throw new Error('Must export function or {test}');
130
+ }
131
+
132
+ try {
133
+ return f(...params);
134
+ } catch (er) {
135
+ if (source && (typeof er.format === 'function')) {
136
+ er.message = er.format([{source, text: params[0]}]);
148
137
  }
138
+ throw er;
149
139
  }
150
- return f(...params);
151
140
  }
152
141
  }