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 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.1",
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 { traverseAllFiles } from "./utils.js";
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
- currentSuite.cases.push({
47
+ const testCase = {
41
48
  name: doc.case,
42
- in: doc.in || [],
43
- out: doc.out,
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
- for (const testCase of cases) {
69
- const {
70
- name,
71
- in: inArg,
72
- out: expectedOut,
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.exportName) {
101
- functionUnderTest = module[suite.exportName];
102
- }
103
- for (const testCase of suite.cases) {
104
- testCase.functionUnderTest = functionUnderTest;
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 yamlContent = fs.readFileSync(file, "utf8");
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 = (path) => {
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 (filePath) {
10
- const content = fs.readFileSync(join(path, "..", filePath), "utf8");
11
- return yaml.load(content); // you could recurse here
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
- return yaml.load(fs.readFileSync(path, "utf8"), { schema });
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
+ };