harmonyc 0.6.0-9 → 0.7.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2025 Bernát Kalló
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,57 +1,80 @@
1
1
  # Harmony Code
2
2
 
3
- A test design & BDD tool that helps you separate the _what_ to test from the _how_ to automate it. You write test cases in a simple Markdown format, and then automate them with Vitest (and soon with many more frameworks and languages).
3
+ A test design & BDD tool that helps you separate the _what_ to test from the _how_ to automate it. You write test cases in a simple easy-to-read format, and then automate them with Vitest (and soon with other frameworks and languages).
4
4
 
5
- ## Usage
5
+ ## Setup
6
6
 
7
- - ### watch and run mode
7
+ You need to have Node.js installed. Then you can install Harmony Code in your project folder by:
8
8
 
9
- - You can compile and run your `*.harmony.md` files in the `src` folder, and watch it, by running
9
+ ```bash
10
+ npm install harmonyc
11
+ ```
12
+
13
+ And then run it for all `.harmony` files in your `src` folder:
10
14
 
11
- ```bash script
12
- npx harmonyc --run --watch 'src/**/*.harmony.md'
13
- ```
15
+ ```bash
16
+ harmonyc src/**/*.harmony
17
+ ```
14
18
 
15
- - => this will generate tests into `*.test.mjs` files
16
- - => this will create a stub `*.steps.ts` file if it doesn't exist
19
+ This will generate `.test.mjs` files next to the `.harmony` files, and generate empty definition files for you.
17
20
 
18
21
  ## Syntax
19
22
 
20
- A Harmony Code file is a Markdown file with a syntax that looks like this:
21
-
22
- ```markdown
23
- # Products API
23
+ A `.harmony` file is a text file with a syntax that looks like this:
24
24
 
25
- - **Create**:
26
- - **Anonymous:**
27
- - create product => error: unauthorized
28
- - **Admin**:
29
- 1. authenticate admin
30
- 2. create product => product created
31
- 3. **Delete:**
32
- - delete product => product deleted
25
+ ```
26
+ + Products API:
27
+ + Create:
28
+ + Anonymous:
29
+ - create product => !! "unauthorized"
30
+ + Admin:
31
+ - authenticate with "admin" => product count `0`
32
+ - create product
33
+ => product created
34
+ => product count `1`
35
+ - Delete:
36
+ - delete product => product deleted => product count `0`
33
37
  ```
34
38
 
35
- ### Actions and responses
36
-
37
- List items (either in ordered or bulleted lists) consist of an **action** and zero or more **response**s, separated by a `=>`.
39
+ ### Indentation
38
40
 
39
- The generated steps contain the feature name after a `||`. Cucumber's steps are in one global namespace, but including the feature name makes it easy to scope step defintions by feature.
41
+ The lines of a file are nodes of a tree. The tree is specified with the indentation of the lines, which is n times 2 spaces and a `+` or `-` with one more space. The `+` or `-` sign is considered to be part of the indentation.
40
42
 
41
43
  ### Sequences and forks
42
44
 
43
- An ordered list means a **sequence**: the list items are included int the tests in order.
45
+ `-` means a sequence: the node follows the previous sibling node and its descendants.
46
+
47
+ `+` means a fork: the node directly follows its parent node. All siblings with `+` are separate branches, they will generate separate scenarios.
48
+
49
+ ### Actions and responses (phrases)
44
50
 
45
- A bulleted list (`-` or `*` or `+`) means a **fork**: the node directly follows its parent node. All list items are separate branches, they will generate separate scenarios.
51
+ After the mark, every node can contain an action and zero or more responses. The action is the text before the `=>`, and the responses are the text after the `=>`.
52
+
53
+ Both actions and responses get compiled to simple function calls - in JavaScript, awaited function calls. The return value of the action is passed to the responses of the same step as the last argument.
54
+
55
+ ### Arguments
56
+
57
+ Phrases can have arguments which are passed to the implementation function. There are two types of arguments: double-quoted strings are passed to the code as strings, and backtick-quoted strings are passed as is. You can use backticks to pass numbers, booleans, null, or objects.
46
58
 
47
59
  ### Labels
48
60
 
49
61
  Label are nodes that end with `:`. You can use them to structure your test design.
50
62
  They are not included in the test case, but the test case name is generated from the labels.
51
63
 
52
- ### Text
64
+ ### Comments
65
+
66
+ Lines starting with `#` or `//` are comments and are ignored.
67
+
68
+ ### Steps
69
+
70
+ All other lines are steps. A step consists of an action, and one or more responses denoted by `=>`.
71
+ Actions will become `When` steps, and responses will become `Then` steps.
72
+
73
+ ### Error matching
74
+
75
+ You can use `!!` to denote an error response. This will verify that the action throws an error. You can specify the error message after the `!!`.
53
76
 
54
- Paragraphs outside of lists are for humans only, they are ignored in the automated tests.
77
+ ## Running the tests
55
78
 
56
79
  ## License
57
80
 
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { compileFiles } from './compiler.js';
2
+ import { compileFiles } from "../compiler/compiler.js";
3
3
  import { parseArgs } from 'node:util';
4
- import { watchFiles } from './watch.js';
5
- import { run, runWatch } from './run.js';
4
+ import { watchFiles } from "./watch.js";
5
+ import { run, runWatch } from "./run.js";
6
6
  const args = parseArgs({
7
7
  options: {
8
8
  help: { type: 'boolean' },
package/cli/watch.js ADDED
@@ -0,0 +1,35 @@
1
+ import Watcher from 'watcher';
2
+ import { compileFile, compileFiles } from "../compiler/compiler.js";
3
+ export async function watchFiles(patterns) {
4
+ const { fns, outFns } = await compileFiles(patterns);
5
+ for (const file of fns) {
6
+ const watcher = new Watcher(file, { debounce: 20, ignoreInitial: true });
7
+ watcher.on('all', async () => {
8
+ var _a;
9
+ try {
10
+ await compileFile(file);
11
+ }
12
+ catch (e) {
13
+ process.stdout.write(`\n`);
14
+ console.log((_a = e.message) !== null && _a !== void 0 ? _a : e);
15
+ process.stdout.write(`\n`);
16
+ }
17
+ logger.log(`Compiled ${file}`);
18
+ });
19
+ }
20
+ return outFns;
21
+ }
22
+ const logger = {
23
+ last: '',
24
+ n: 0,
25
+ log(msg) {
26
+ if (msg === this.last) {
27
+ process.stdout.write(`\r${msg} ${++this.n}x`);
28
+ }
29
+ else {
30
+ process.stdout.write(`\r${msg}`);
31
+ this.last = msg;
32
+ this.n = 1;
33
+ }
34
+ },
35
+ };
@@ -0,0 +1,169 @@
1
+ import { basename } from 'path';
2
+ import { Arg, Word, } from "../model/model.js";
3
+ export class NodeTest {
4
+ constructor(tf, sf) {
5
+ this.tf = tf;
6
+ this.sf = sf;
7
+ this.framework = 'vitest';
8
+ this.phraseFns = new Map();
9
+ this.currentFeatureName = '';
10
+ this.resultCount = 0;
11
+ this.extraArgs = [];
12
+ }
13
+ feature(feature) {
14
+ const stepsModule = './' + basename(this.sf.name.replace(/.(js|ts)$/, ''));
15
+ const fn = (this.currentFeatureName = pascalCase(feature.name));
16
+ this.phraseFns = new Map();
17
+ if (this.framework === 'vitest') {
18
+ this.tf.print(`import { describe, test, expect } from 'vitest';`);
19
+ }
20
+ this.tf.print(`import ${fn}Steps from ${str(stepsModule)};`);
21
+ this.tf.print(``);
22
+ for (const item of feature.testGroups) {
23
+ item.toCode(this);
24
+ }
25
+ this.sf.print(`export default class ${pascalCase(feature.name)}Steps {`);
26
+ this.sf.indent(() => {
27
+ for (const ph of this.phraseFns.keys()) {
28
+ const p = this.phraseFns.get(ph);
29
+ const params = p.args.map((a, i) => a.toDeclaration(this, i)).join(', ');
30
+ this.sf.print(`async ${ph}(${params}) {`);
31
+ this.sf.indent(() => {
32
+ this.sf.print(`throw new Error(${str(`Pending: ${ph}`)});`);
33
+ });
34
+ this.sf.print(`}`);
35
+ }
36
+ });
37
+ this.sf.print(`};`);
38
+ }
39
+ testGroup(g) {
40
+ this.tf.print(`describe(${str(g.name)}, () => {`);
41
+ this.tf.indent(() => {
42
+ for (const item of g.items) {
43
+ item.toCode(this);
44
+ }
45
+ });
46
+ this.tf.print('});');
47
+ }
48
+ test(t) {
49
+ this.resultCount = 0;
50
+ this.featureVars = new Map();
51
+ // avoid shadowing this import name
52
+ this.featureVars.set(new Object(), this.currentFeatureName);
53
+ this.tf.print(`test(${str(t.name)}, async () => {`);
54
+ this.tf.indent(() => {
55
+ for (const step of t.steps) {
56
+ step.toCode(this);
57
+ }
58
+ });
59
+ this.tf.print('});');
60
+ }
61
+ errorStep(action, errorMessage) {
62
+ var _a;
63
+ this.tf.print(`expect(async () => {`);
64
+ this.tf.indent(() => {
65
+ action.toCode(this);
66
+ });
67
+ this.tf.print(`}).rejects.toThrow(${(_a = errorMessage === null || errorMessage === void 0 ? void 0 : errorMessage.toCode(this)) !== null && _a !== void 0 ? _a : ''});`);
68
+ }
69
+ step(action, responses) {
70
+ for (const p of [action, ...responses]) {
71
+ const feature = p.feature.name;
72
+ let f = this.featureVars.get(feature);
73
+ if (!f) {
74
+ f = toId(feature, abbrev, this.featureVars);
75
+ this.tf.print(`const ${f} = new ${pascalCase(feature)}Steps();`);
76
+ }
77
+ }
78
+ if (responses.length === 0) {
79
+ action.toCode(this);
80
+ return;
81
+ }
82
+ const res = `r${this.resultCount++ || ''}`;
83
+ this.tf.print(`const ${res} =`);
84
+ this.tf.indent(() => {
85
+ action.toCode(this);
86
+ try {
87
+ this.extraArgs = [res];
88
+ for (const response of responses) {
89
+ response.toCode(this);
90
+ }
91
+ }
92
+ finally {
93
+ this.extraArgs = [];
94
+ }
95
+ });
96
+ }
97
+ phrase(p) {
98
+ const phrasefn = this.functionName(p);
99
+ if (!this.phraseFns.has(phrasefn))
100
+ this.phraseFns.set(phrasefn, p);
101
+ const f = this.featureVars.get(p.feature.name);
102
+ const args = p.args.map((a) => a.toCode(this));
103
+ args.push(...this.extraArgs);
104
+ this.tf.print(`await ${f}.${this.functionName(p)}(${args.join(', ')});`);
105
+ }
106
+ stringLiteral(text) {
107
+ return str(text);
108
+ }
109
+ codeLiteral(src) {
110
+ return src;
111
+ }
112
+ paramName(index) {
113
+ return 'xyz'.charAt(index) || `a${index + 1}`;
114
+ }
115
+ stringParamDeclaration(index) {
116
+ return `${this.paramName(index)}: string`;
117
+ }
118
+ variantParamDeclaration(index) {
119
+ return `${this.paramName(index)}: any`;
120
+ }
121
+ functionName(phrase) {
122
+ const { kind } = phrase;
123
+ return ((kind === 'response' ? 'Then_' : 'When_') +
124
+ ([...phrase.content, phrase.docstring ? [phrase.docstring] : []]
125
+ .map((c) => c instanceof Word ? underscore(c.text) : c instanceof Arg ? '_' : '')
126
+ .filter((x) => x)
127
+ .join('_') || '_'));
128
+ }
129
+ }
130
+ function str(s) {
131
+ if (s.includes('\n'))
132
+ return '\n' + templateStr(s);
133
+ let r = JSON.stringify(s);
134
+ return r;
135
+ }
136
+ function templateStr(s) {
137
+ return '`' + s.replace(/([`$\\])/g, '\\$1') + '`';
138
+ }
139
+ function capitalize(s) {
140
+ return s.charAt(0).toUpperCase() + s.slice(1);
141
+ }
142
+ function toId(s, transform, previous) {
143
+ if (previous.has(s))
144
+ return previous.get(s);
145
+ let base = transform(s);
146
+ let id = base;
147
+ if ([...previous.values()].includes(id)) {
148
+ let i = 1;
149
+ while ([...previous.values()].includes(id + i))
150
+ i++;
151
+ id = base + i;
152
+ }
153
+ previous.set(s, id);
154
+ return id;
155
+ }
156
+ function words(s) {
157
+ return s.split(/[^0-9\p{L}]+/gu);
158
+ }
159
+ function pascalCase(s) {
160
+ return words(s).map(capitalize).join('');
161
+ }
162
+ function underscore(s) {
163
+ return words(s).join('_');
164
+ }
165
+ function abbrev(s) {
166
+ return words(s)
167
+ .map((x) => x.charAt(0).toUpperCase())
168
+ .join('');
169
+ }
@@ -0,0 +1,32 @@
1
+ import { NodeTest } from "../code_generator/JavaScript.js";
2
+ import { OutFile } from "../code_generator/outFile.js";
3
+ import { parse } from "../parser/parser.js";
4
+ import { base, stepsFileName, testFileName } from "../filenames/filenames.js";
5
+ import { Feature } from "../model/model.js";
6
+ import { basename } from 'node:path';
7
+ import { autoLabel } from "../optimizations/autoLabel/autoLabel.js";
8
+ export function compileFeature(fileName, src) {
9
+ const feature = new Feature(basename(base(fileName)));
10
+ try {
11
+ feature.root = parse(src);
12
+ }
13
+ catch (e) {
14
+ if (e.pos && e.errorMessage) {
15
+ e.message =
16
+ e.stack = `Error in ${fileName}:${e.pos.rowBegin}:${e.pos.columnBegin}\n${e.errorMessage}`;
17
+ }
18
+ else {
19
+ e.stack = `Error in ${fileName}\n${e.stack}`;
20
+ }
21
+ throw e;
22
+ }
23
+ feature.root.setFeature(feature);
24
+ autoLabel(feature.root);
25
+ const testFn = testFileName(fileName);
26
+ const testFile = new OutFile(testFn);
27
+ const stepsFn = stepsFileName(fileName);
28
+ const stepsFile = new OutFile(stepsFn);
29
+ const cg = new NodeTest(testFile, stepsFile);
30
+ feature.toCode(cg);
31
+ return { outFile: testFile, stepsFile };
32
+ }
@@ -1,11 +1,18 @@
1
1
  import glob from 'fast-glob';
2
2
  import { existsSync, readFileSync, writeFileSync } from 'fs';
3
- import { compileFeature } from './compile.js';
3
+ import { resolve } from 'path';
4
+ import { compileFeature } from "./compile.js";
4
5
  export async function compileFiles(pattern) {
6
+ var _a;
5
7
  const fns = await glob(pattern);
6
8
  if (!fns.length)
7
9
  throw new Error(`No files found for pattern: ${String(pattern)}`);
8
- const features = await Promise.all(fns.map((fn) => compileFile(fn)));
10
+ const results = await Promise.allSettled(fns.map((fn) => compileFile(fn)));
11
+ const features = results.flatMap((r) => r.status === 'fulfilled' ? [r.value] : []);
12
+ const errors = results.flatMap((r) => r.status === 'rejected' ? [r.reason] : []);
13
+ for (const error of errors) {
14
+ console.log((_a = error.message) !== null && _a !== void 0 ? _a : error);
15
+ }
9
16
  console.log(`Compiled ${fns.length} file${fns.length === 1 ? '' : 's'}.`);
10
17
  const generated = features.filter((f) => f.stepsFileAction === 'generated');
11
18
  if (generated.length) {
@@ -14,7 +21,11 @@ export async function compileFiles(pattern) {
14
21
  return { fns, outFns: features.map((f) => f.outFile.name) };
15
22
  }
16
23
  export async function compileFile(fn) {
17
- const src = readFileSync(fn, 'utf8').toString();
24
+ fn = resolve(fn);
25
+ const src = readFileSync(fn, 'utf8')
26
+ .toString()
27
+ .replace(/\r\n/g, '\n')
28
+ .replace(/\r/g, '\n');
18
29
  const { outFile, stepsFile } = compileFeature(fn, src);
19
30
  writeFileSync(outFile.name, outFile.value);
20
31
  let stepsFileAction = 'ignored';
@@ -1,7 +1,7 @@
1
1
  import glob from 'fast-glob';
2
2
  const { globSync, convertPathToPattern } = glob;
3
3
  export function base(fn) {
4
- return fn.replace(/\.harmony\.md$/i, '');
4
+ return fn.replace(/\.harmony(\.\w+)?$/i, '');
5
5
  }
6
6
  export function testFileName(fn) {
7
7
  return base(fn) + '.test.mjs';
@@ -9,9 +9,9 @@ export function testFileName(fn) {
9
9
  export function stepsFileName(fn) {
10
10
  const baseFn = base(fn);
11
11
  const pattern = convertPathToPattern(baseFn);
12
- const existing = globSync(`${pattern}.steps.*`);
12
+ const existing = globSync(`${pattern}.steps.{tsx,jsx,ts,js}`);
13
13
  if (existing.length) {
14
- return existing.sort()[0];
14
+ return existing.sort().at(-1);
15
15
  }
16
16
  return `${baseFn}.steps.ts`;
17
17
  }
@@ -1,4 +1,4 @@
1
- import { xmur3 } from './util/xmur3.js';
1
+ import { xmur3 } from "../util/xmur3.js";
2
2
  export class Router {
3
3
  constructor(outs, seed = 'TODO') {
4
4
  this.outs = outs;