puty 0.0.1 → 0.0.3-rc1
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 +59 -0
- package/package.json +4 -1
- package/src/puty.js +144 -29
- package/src/utils.js +135 -5
package/README.md
CHANGED
|
@@ -26,6 +26,8 @@ It should run all the tests in the file.
|
|
|
26
26
|
|
|
27
27
|
## Usage
|
|
28
28
|
|
|
29
|
+
### Testing Functions
|
|
30
|
+
|
|
29
31
|
```yaml
|
|
30
32
|
file: './math.js'
|
|
31
33
|
group: math
|
|
@@ -61,3 +63,60 @@ in:
|
|
|
61
63
|
- 2
|
|
62
64
|
out: 3
|
|
63
65
|
```
|
|
66
|
+
|
|
67
|
+
### Testing Classes
|
|
68
|
+
|
|
69
|
+
Puty also supports testing classes with method calls and state assertions:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
file: './calculator.js'
|
|
73
|
+
group: Calculator
|
|
74
|
+
suites: [basic-operations]
|
|
75
|
+
---
|
|
76
|
+
suite: basic-operations
|
|
77
|
+
mode: 'class'
|
|
78
|
+
exportName: default
|
|
79
|
+
constructorArgs: [10] # Initial value
|
|
80
|
+
---
|
|
81
|
+
case: add and multiply operations
|
|
82
|
+
executions:
|
|
83
|
+
- method: add
|
|
84
|
+
in: [5]
|
|
85
|
+
out: 15
|
|
86
|
+
asserts:
|
|
87
|
+
- property: value
|
|
88
|
+
op: eq
|
|
89
|
+
value: 15
|
|
90
|
+
- method: multiply
|
|
91
|
+
in: [2]
|
|
92
|
+
out: 30
|
|
93
|
+
asserts:
|
|
94
|
+
- property: value
|
|
95
|
+
op: eq
|
|
96
|
+
value: 30
|
|
97
|
+
- method: getValue
|
|
98
|
+
in: []
|
|
99
|
+
out: 30
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### Class Test Structure
|
|
103
|
+
|
|
104
|
+
- `mode: 'class'` - Indicates this suite tests a class
|
|
105
|
+
- `constructorArgs` - Arguments passed to the class constructor
|
|
106
|
+
- `executions` - Array of method calls to execute in sequence
|
|
107
|
+
- `method` - Name of the method to call
|
|
108
|
+
- `in` - Arguments to pass to the method
|
|
109
|
+
- `out` - Expected return value (optional)
|
|
110
|
+
- `asserts` - Assertions to run after the method call
|
|
111
|
+
- Property assertions: Check instance properties
|
|
112
|
+
- Method assertions: Call methods and check their return values
|
|
113
|
+
|
|
114
|
+
### Error Testing
|
|
115
|
+
|
|
116
|
+
You can test that functions or methods throw expected errors:
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
case: divide by zero
|
|
120
|
+
in: [10, 0]
|
|
121
|
+
throws: "Division by zero"
|
|
122
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "puty",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3-rc1",
|
|
4
4
|
"description": "A tooling function to test javascript functions and classes.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -19,5 +19,8 @@
|
|
|
19
19
|
"lint": "bunx prettier src tests -c",
|
|
20
20
|
"lint:fix": "bunx prettier src tests -w",
|
|
21
21
|
"build": "bun run esbuild.js"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"vitest": "^3.2.1"
|
|
22
25
|
}
|
|
23
26
|
}
|
package/src/puty.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import fs from "node:fs";
|
|
3
2
|
import yaml from "js-yaml";
|
|
4
3
|
import { expect, test, describe } from "vitest";
|
|
5
4
|
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
traverseAllFiles,
|
|
7
|
+
parseYamlDocumentsWithIncludes as parseWithIncludes,
|
|
8
|
+
} from "./utils.js";
|
|
7
9
|
|
|
8
10
|
const extensions = [".test.yaml", ".test.yml", ".spec.yaml", ".spec.yml"];
|
|
9
11
|
|
|
@@ -36,12 +38,27 @@ export const parseYamlDocuments = (yamlContent) => {
|
|
|
36
38
|
exportName: doc.exportName || doc.suite,
|
|
37
39
|
cases: [],
|
|
38
40
|
};
|
|
41
|
+
// Only add mode and constructorArgs if mode is explicitly 'class'
|
|
42
|
+
if (doc.mode === "class") {
|
|
43
|
+
currentSuite.mode = "class";
|
|
44
|
+
currentSuite.constructorArgs = doc.constructorArgs || [];
|
|
45
|
+
}
|
|
39
46
|
} else if (doc.case && currentSuite) {
|
|
40
|
-
|
|
47
|
+
const testCase = {
|
|
41
48
|
name: doc.case,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (currentSuite.mode === "class") {
|
|
52
|
+
testCase.executions = doc.executions || [];
|
|
53
|
+
} else {
|
|
54
|
+
testCase.in = doc.in || [];
|
|
55
|
+
testCase.out = doc.out;
|
|
56
|
+
if (doc.throws) {
|
|
57
|
+
testCase.throws = doc.throws;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
currentSuite.cases.push(testCase);
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
64
|
|
|
@@ -64,28 +81,111 @@ const setupTestSuite = (testConfig) => {
|
|
|
64
81
|
describe(group, () => {
|
|
65
82
|
for (const suite of suites) {
|
|
66
83
|
describe(suite.name, () => {
|
|
67
|
-
const { cases } = suite;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
functionUnderTest,
|
|
74
|
-
} = testCase;
|
|
75
|
-
test(name, () => {
|
|
76
|
-
const out = functionUnderTest(...(inArg || []));
|
|
77
|
-
if (out instanceof Error) {
|
|
78
|
-
expect(out.message).toEqual(out.message);
|
|
79
|
-
} else {
|
|
80
|
-
expect(out).toEqual(expectedOut);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
84
|
+
const { cases, mode } = suite;
|
|
85
|
+
|
|
86
|
+
if (mode === "class") {
|
|
87
|
+
setupClassTests(suite);
|
|
88
|
+
} else {
|
|
89
|
+
setupFunctionTests(suite);
|
|
83
90
|
}
|
|
84
91
|
});
|
|
85
92
|
}
|
|
86
93
|
});
|
|
87
94
|
};
|
|
88
95
|
|
|
96
|
+
const setupFunctionTests = (suite) => {
|
|
97
|
+
const { cases } = suite;
|
|
98
|
+
for (const testCase of cases) {
|
|
99
|
+
const {
|
|
100
|
+
name,
|
|
101
|
+
in: inArg,
|
|
102
|
+
out: expectedOut,
|
|
103
|
+
functionUnderTest,
|
|
104
|
+
throws,
|
|
105
|
+
} = testCase;
|
|
106
|
+
test(name, () => {
|
|
107
|
+
if (!functionUnderTest) {
|
|
108
|
+
throw new Error(`Function not found for test case: ${name}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (throws) {
|
|
112
|
+
// Test expects an error to be thrown
|
|
113
|
+
expect(() => functionUnderTest(...(inArg || []))).toThrow(throws);
|
|
114
|
+
} else {
|
|
115
|
+
const out = functionUnderTest(...(inArg || []));
|
|
116
|
+
expect(out).toEqual(expectedOut);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const setupClassTests = (suite) => {
|
|
123
|
+
const { cases, ClassUnderTest, constructorArgs } = suite;
|
|
124
|
+
for (const testCase of cases) {
|
|
125
|
+
const { name, executions } = testCase;
|
|
126
|
+
test(name, () => {
|
|
127
|
+
if (!ClassUnderTest) {
|
|
128
|
+
throw new Error(`Class not found for test suite: ${suite.name}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const instance = new ClassUnderTest(...constructorArgs);
|
|
132
|
+
|
|
133
|
+
for (const execution of executions) {
|
|
134
|
+
const {
|
|
135
|
+
method,
|
|
136
|
+
in: inArg,
|
|
137
|
+
out: expectedOut,
|
|
138
|
+
throws,
|
|
139
|
+
asserts,
|
|
140
|
+
} = execution;
|
|
141
|
+
|
|
142
|
+
// Validate method exists
|
|
143
|
+
if (!instance[method] || typeof instance[method] !== "function") {
|
|
144
|
+
throw new Error(`Method '${method}' not found on class instance`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Execute the method and check its return value
|
|
148
|
+
if (throws) {
|
|
149
|
+
expect(() => instance[method](...(inArg || []))).toThrow(throws);
|
|
150
|
+
} else {
|
|
151
|
+
const result = instance[method](...(inArg || []));
|
|
152
|
+
if (expectedOut !== undefined) {
|
|
153
|
+
expect(result).toEqual(expectedOut);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Run assertions
|
|
158
|
+
if (asserts) {
|
|
159
|
+
for (const assertion of asserts) {
|
|
160
|
+
if (assertion.property) {
|
|
161
|
+
// Property assertion
|
|
162
|
+
const actualValue = instance[assertion.property];
|
|
163
|
+
if (assertion.op === "eq") {
|
|
164
|
+
expect(actualValue).toEqual(assertion.value);
|
|
165
|
+
}
|
|
166
|
+
// Add more operators as needed
|
|
167
|
+
} else if (assertion.method) {
|
|
168
|
+
// Method assertion
|
|
169
|
+
if (
|
|
170
|
+
!instance[assertion.method] ||
|
|
171
|
+
typeof instance[assertion.method] !== "function"
|
|
172
|
+
) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Method '${assertion.method}' not found on class instance for assertion`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const result = instance[assertion.method](
|
|
178
|
+
...(assertion.in || []),
|
|
179
|
+
);
|
|
180
|
+
expect(result).toEqual(assertion.out);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
89
189
|
/**
|
|
90
190
|
*
|
|
91
191
|
* @param {*} module
|
|
@@ -97,11 +197,27 @@ export const injectFunctions = (module, originalTestConfig) => {
|
|
|
97
197
|
let functionUnderTest = module[testConfig.exportName || "default"];
|
|
98
198
|
|
|
99
199
|
for (const suite of testConfig.suites) {
|
|
100
|
-
if (suite.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
200
|
+
if (suite.mode === "class") {
|
|
201
|
+
const exportName = suite.exportName || "default";
|
|
202
|
+
const exported = module[exportName];
|
|
203
|
+
if (!exported) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Export '${exportName}' not found in module for class suite '${suite.name}'`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
suite.ClassUnderTest = exported;
|
|
209
|
+
} else {
|
|
210
|
+
if (suite.exportName) {
|
|
211
|
+
functionUnderTest = module[suite.exportName];
|
|
212
|
+
if (!functionUnderTest) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Export '${suite.exportName}' not found in module for suite '${suite.name}'`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const testCase of suite.cases) {
|
|
219
|
+
testCase.functionUnderTest = functionUnderTest;
|
|
220
|
+
}
|
|
105
221
|
}
|
|
106
222
|
}
|
|
107
223
|
return testConfig;
|
|
@@ -113,8 +229,7 @@ export const injectFunctions = (module, originalTestConfig) => {
|
|
|
113
229
|
export const setupTestSuiteFromYaml = async (dirname) => {
|
|
114
230
|
const testYamlFiles = traverseAllFiles(dirname, extensions);
|
|
115
231
|
for (const file of testYamlFiles) {
|
|
116
|
-
const
|
|
117
|
-
const testConfig = parseYamlDocuments(yamlContent);
|
|
232
|
+
const testConfig = parseWithIncludes(file);
|
|
118
233
|
const filepathRelativeToSpecFile = path.join(
|
|
119
234
|
path.dirname(file),
|
|
120
235
|
testConfig.file,
|
package/src/utils.js
CHANGED
|
@@ -3,16 +3,56 @@ import path, { join } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import yaml from "js-yaml";
|
|
5
5
|
|
|
6
|
-
export const loadYamlWithPath = (
|
|
6
|
+
export const loadYamlWithPath = (filePath, visitedFiles = new Set()) => {
|
|
7
|
+
const absolutePath = path.resolve(filePath);
|
|
8
|
+
|
|
9
|
+
// Check for circular dependencies
|
|
10
|
+
if (visitedFiles.has(absolutePath)) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Circular dependency detected: ${absolutePath} is already being processed`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Add current file to visited set
|
|
17
|
+
visitedFiles.add(absolutePath);
|
|
18
|
+
|
|
7
19
|
const includeType = new yaml.Type("!include", {
|
|
8
20
|
kind: "scalar",
|
|
9
|
-
construct: function (
|
|
10
|
-
|
|
11
|
-
|
|
21
|
+
construct: function (includePath) {
|
|
22
|
+
// Resolve include path relative to the current file's directory
|
|
23
|
+
const currentDir = path.dirname(absolutePath);
|
|
24
|
+
const resolvedIncludePath = path.resolve(currentDir, includePath);
|
|
25
|
+
|
|
26
|
+
// Check if included file exists
|
|
27
|
+
if (!fs.existsSync(resolvedIncludePath)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Include file not found: ${resolvedIncludePath} (included from ${absolutePath})`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Recursively load the included file with the same visited files set
|
|
34
|
+
return loadYamlWithPath(resolvedIncludePath, new Set(visitedFiles));
|
|
12
35
|
},
|
|
13
36
|
});
|
|
37
|
+
|
|
14
38
|
const schema = yaml.DEFAULT_SCHEMA.extend([includeType]);
|
|
15
|
-
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const content = fs.readFileSync(absolutePath, "utf8");
|
|
42
|
+
// Check if content contains document separators
|
|
43
|
+
if (content.includes("---")) {
|
|
44
|
+
return yaml.loadAll(content, { schema });
|
|
45
|
+
} else {
|
|
46
|
+
return yaml.load(content, { schema });
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Error loading YAML file ${absolutePath}: ${error.message}`,
|
|
51
|
+
);
|
|
52
|
+
} finally {
|
|
53
|
+
// Remove current file from visited set when done processing
|
|
54
|
+
visitedFiles.delete(absolutePath);
|
|
55
|
+
}
|
|
16
56
|
};
|
|
17
57
|
|
|
18
58
|
/**
|
|
@@ -33,3 +73,93 @@ export const traverseAllFiles = (startPath, extensions) => {
|
|
|
33
73
|
}
|
|
34
74
|
return results;
|
|
35
75
|
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Recursively flatten nested arrays of documents
|
|
79
|
+
*/
|
|
80
|
+
const flattenDocuments = (data) => {
|
|
81
|
+
if (!Array.isArray(data)) {
|
|
82
|
+
return [data];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = [];
|
|
86
|
+
for (const item of data) {
|
|
87
|
+
if (Array.isArray(item)) {
|
|
88
|
+
result.push(...flattenDocuments(item));
|
|
89
|
+
} else {
|
|
90
|
+
result.push(item);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Process an array of YAML documents into structured test config
|
|
98
|
+
*/
|
|
99
|
+
const processDocuments = (docs) => {
|
|
100
|
+
const config = {
|
|
101
|
+
file: null,
|
|
102
|
+
group: null,
|
|
103
|
+
suites: [],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let currentSuite = null;
|
|
107
|
+
|
|
108
|
+
for (const doc of docs) {
|
|
109
|
+
if (doc.file) {
|
|
110
|
+
config.file = doc.file;
|
|
111
|
+
config.group = doc.group || doc.name;
|
|
112
|
+
if (doc.suites) {
|
|
113
|
+
config.suiteNames = doc.suites;
|
|
114
|
+
}
|
|
115
|
+
} else if (doc.suite) {
|
|
116
|
+
if (currentSuite) {
|
|
117
|
+
config.suites.push(currentSuite);
|
|
118
|
+
}
|
|
119
|
+
currentSuite = {
|
|
120
|
+
name: doc.suite,
|
|
121
|
+
exportName: doc.exportName || doc.suite,
|
|
122
|
+
cases: [],
|
|
123
|
+
};
|
|
124
|
+
// Only add mode and constructorArgs if mode is explicitly 'class'
|
|
125
|
+
if (doc.mode === "class") {
|
|
126
|
+
currentSuite.mode = "class";
|
|
127
|
+
currentSuite.constructorArgs = doc.constructorArgs || [];
|
|
128
|
+
}
|
|
129
|
+
} else if (doc.case && currentSuite) {
|
|
130
|
+
const testCase = {
|
|
131
|
+
name: doc.case,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (currentSuite.mode === "class") {
|
|
135
|
+
testCase.executions = doc.executions || [];
|
|
136
|
+
} else {
|
|
137
|
+
testCase.in = doc.in || [];
|
|
138
|
+
testCase.out = doc.out;
|
|
139
|
+
if (doc.throws) {
|
|
140
|
+
testCase.throws = doc.throws;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
currentSuite.cases.push(testCase);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (currentSuite) {
|
|
149
|
+
config.suites.push(currentSuite);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return config;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse YAML file with !include support into structured test config
|
|
157
|
+
*/
|
|
158
|
+
export const parseYamlDocumentsWithIncludes = (filePath) => {
|
|
159
|
+
const yamlData = loadYamlWithPath(filePath);
|
|
160
|
+
|
|
161
|
+
// Flatten any nested arrays that come from multi-document includes
|
|
162
|
+
const flattenedDocs = flattenDocuments(yamlData);
|
|
163
|
+
|
|
164
|
+
return processDocuments(flattenedDocs);
|
|
165
|
+
};
|