harmonyc 0.6.0-9 → 0.7.1

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,90 @@
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
10
-
11
- ```bash script
12
- npx harmonyc --run --watch 'src/**/*.harmony.md'
13
- ```
9
+ ```bash
10
+ npm install harmonyc
11
+ ```
14
12
 
15
- - => this will generate tests into `*.test.mjs` files
16
- - => this will create a stub `*.steps.ts` file if it doesn't exist
13
+ Then add it to your `vitest.config.js` or `vite.config.js` file, and specify which folder to watch for `.harmony` files:
17
14
 
18
- ## Syntax
15
+ ```js
16
+ import harmony from 'harmonyc/vitest'
19
17
 
20
- A Harmony Code file is a Markdown file with a syntax that looks like this:
18
+ export default {
19
+ plugins: [harmony({ watchDir: 'src' })],
20
+ }
21
+ ```
21
22
 
22
- ```markdown
23
- # Products API
23
+ You can run it manually for all `.harmony` files in your `src` folder:
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
+ ```bash
26
+ harmonyc src/**/*.harmony
33
27
  ```
34
28
 
35
- ### Actions and responses
29
+ This will generate `.test.mjs` files next to the `.harmony` files, and generate empty definition files for you.
30
+
31
+ ## Syntax
32
+
33
+ A `.harmony` file is a text file with a syntax that looks like this:
34
+
35
+ ```
36
+ + Products API:
37
+ + Create:
38
+ + Anonymous:
39
+ - create product => !! "unauthorized"
40
+ + Admin:
41
+ - authenticate with "admin" => product count `0`
42
+ - create product
43
+ => product created
44
+ => product count `1`
45
+ - Delete:
46
+ - delete product => product deleted => product count `0`
47
+ ```
36
48
 
37
- List items (either in ordered or bulleted lists) consist of an **action** and zero or more **response**s, separated by a `=>`.
49
+ ### Indentation
38
50
 
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.
51
+ 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
52
 
41
53
  ### Sequences and forks
42
54
 
43
- An ordered list means a **sequence**: the list items are included int the tests in order.
55
+ `-` means a sequence: the node follows the previous sibling node and its descendants.
44
56
 
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.
57
+ `+` means a fork: the node directly follows its parent node. All siblings with `+` are separate branches, they will generate separate scenarios.
58
+
59
+ ### Actions and responses (phrases)
60
+
61
+ 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 `=>`.
62
+
63
+ 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.
64
+
65
+ ### Arguments
66
+
67
+ 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
68
 
47
69
  ### Labels
48
70
 
49
71
  Label are nodes that end with `:`. You can use them to structure your test design.
50
72
  They are not included in the test case, but the test case name is generated from the labels.
51
73
 
52
- ### Text
74
+ ### Comments
75
+
76
+ Lines starting with `#` or `//` are comments and are ignored.
77
+
78
+ ### Steps
79
+
80
+ All other lines are steps. A step consists of an action, and one or more responses denoted by `=>`.
81
+ Actions will become `When` steps, and responses will become `Then` steps.
82
+
83
+ ### Error matching
84
+
85
+ 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
86
 
54
- Paragraphs outside of lists are for humans only, they are ignored in the automated tests.
87
+ ## Running the tests
55
88
 
56
89
  ## License
57
90
 
@@ -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;