@apiquest/fracture 1.0.2 → 1.0.4

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.
Files changed (168) hide show
  1. package/README.md +119 -0
  2. package/bin/cli.js +2 -2
  3. package/dist/CollectionRunner.js +3 -3
  4. package/dist/ScriptEngine.js +4 -4
  5. package/dist/cli/plugin-commands.d.ts.map +1 -1
  6. package/dist/cli/plugin-commands.js +2 -1
  7. package/dist/cli/plugin-commands.js.map +1 -1
  8. package/package.json +55 -50
  9. package/src/CollectionAnalyzer.ts +102 -102
  10. package/src/CollectionRunner.ts +1423 -1423
  11. package/src/CollectionRunner.types.ts +9 -9
  12. package/src/CollectionValidator.ts +289 -289
  13. package/src/ConsoleReporter.ts +143 -143
  14. package/src/CookieJar.ts +258 -258
  15. package/src/DagScheduler.ts +439 -439
  16. package/src/Logger.ts +85 -85
  17. package/src/PluginLoader.ts +126 -126
  18. package/src/PluginManager.ts +208 -208
  19. package/src/PluginResolver.ts +154 -154
  20. package/src/QuestAPI.ts +764 -764
  21. package/src/QuestAPI.types.ts +33 -33
  22. package/src/QuestTestAPI.ts +164 -164
  23. package/src/RequestFilter.ts +224 -224
  24. package/src/ScriptEngine.ts +219 -219
  25. package/src/ScriptValidator.ts +428 -428
  26. package/src/TaskGraph.ts +598 -598
  27. package/src/TestCounter.ts +109 -109
  28. package/src/VariableResolver.ts +114 -114
  29. package/src/cli/index.ts +480 -480
  30. package/src/cli/plugin-commands.ts +342 -341
  31. package/src/cli/plugin-discovery.ts +44 -44
  32. package/src/index.ts +24 -24
  33. package/src/utils.ts +52 -52
  34. package/tsconfig.json +20 -20
  35. package/tsconfig.test.json +5 -5
  36. package/vitest.config.ts +22 -22
  37. package/dist/ExecutionTree.d.ts +0 -77
  38. package/dist/ExecutionTree.d.ts.map +0 -1
  39. package/dist/ExecutionTree.js +0 -265
  40. package/dist/ExecutionTree.js.map +0 -1
  41. package/dist/fracture/src/CollectionAnalyzer.d.ts +0 -17
  42. package/dist/fracture/src/CollectionAnalyzer.d.ts.map +0 -1
  43. package/dist/fracture/src/CollectionAnalyzer.js +0 -70
  44. package/dist/fracture/src/CollectionAnalyzer.js.map +0 -1
  45. package/dist/fracture/src/CollectionRunner.d.ts +0 -39
  46. package/dist/fracture/src/CollectionRunner.d.ts.map +0 -1
  47. package/dist/fracture/src/CollectionRunner.js +0 -802
  48. package/dist/fracture/src/CollectionRunner.js.map +0 -1
  49. package/dist/fracture/src/CollectionRunner.types.d.ts +0 -8
  50. package/dist/fracture/src/CollectionRunner.types.d.ts.map +0 -1
  51. package/dist/fracture/src/CollectionRunner.types.js +0 -2
  52. package/dist/fracture/src/CollectionRunner.types.js.map +0 -1
  53. package/dist/fracture/src/CollectionValidator.d.ts +0 -14
  54. package/dist/fracture/src/CollectionValidator.d.ts.map +0 -1
  55. package/dist/fracture/src/CollectionValidator.js +0 -145
  56. package/dist/fracture/src/CollectionValidator.js.map +0 -1
  57. package/dist/fracture/src/ConsoleReporter.d.ts +0 -24
  58. package/dist/fracture/src/ConsoleReporter.d.ts.map +0 -1
  59. package/dist/fracture/src/ConsoleReporter.js +0 -123
  60. package/dist/fracture/src/ConsoleReporter.js.map +0 -1
  61. package/dist/fracture/src/CookieJar.d.ts +0 -70
  62. package/dist/fracture/src/CookieJar.d.ts.map +0 -1
  63. package/dist/fracture/src/CookieJar.js +0 -233
  64. package/dist/fracture/src/CookieJar.js.map +0 -1
  65. package/dist/fracture/src/ExecutionTree.d.ts +0 -77
  66. package/dist/fracture/src/ExecutionTree.d.ts.map +0 -1
  67. package/dist/fracture/src/ExecutionTree.js +0 -258
  68. package/dist/fracture/src/ExecutionTree.js.map +0 -1
  69. package/dist/fracture/src/Logger.d.ts +0 -25
  70. package/dist/fracture/src/Logger.d.ts.map +0 -1
  71. package/dist/fracture/src/Logger.js +0 -78
  72. package/dist/fracture/src/Logger.js.map +0 -1
  73. package/dist/fracture/src/PluginLoader.d.ts +0 -23
  74. package/dist/fracture/src/PluginLoader.d.ts.map +0 -1
  75. package/dist/fracture/src/PluginLoader.js +0 -102
  76. package/dist/fracture/src/PluginLoader.js.map +0 -1
  77. package/dist/fracture/src/PluginManager.d.ts +0 -64
  78. package/dist/fracture/src/PluginManager.d.ts.map +0 -1
  79. package/dist/fracture/src/PluginManager.js +0 -162
  80. package/dist/fracture/src/PluginManager.js.map +0 -1
  81. package/dist/fracture/src/PluginResolver.d.ts +0 -35
  82. package/dist/fracture/src/PluginResolver.d.ts.map +0 -1
  83. package/dist/fracture/src/PluginResolver.js +0 -128
  84. package/dist/fracture/src/PluginResolver.js.map +0 -1
  85. package/dist/fracture/src/QuestAPI.d.ts +0 -9
  86. package/dist/fracture/src/QuestAPI.d.ts.map +0 -1
  87. package/dist/fracture/src/QuestAPI.js +0 -679
  88. package/dist/fracture/src/QuestAPI.js.map +0 -1
  89. package/dist/fracture/src/QuestAPI.types.d.ts +0 -35
  90. package/dist/fracture/src/QuestAPI.types.d.ts.map +0 -1
  91. package/dist/fracture/src/QuestAPI.types.js +0 -3
  92. package/dist/fracture/src/QuestAPI.types.js.map +0 -1
  93. package/dist/fracture/src/QuestTestAPI.d.ts +0 -12
  94. package/dist/fracture/src/QuestTestAPI.d.ts.map +0 -1
  95. package/dist/fracture/src/QuestTestAPI.js +0 -133
  96. package/dist/fracture/src/QuestTestAPI.js.map +0 -1
  97. package/dist/fracture/src/ScriptEngine.d.ts +0 -21
  98. package/dist/fracture/src/ScriptEngine.d.ts.map +0 -1
  99. package/dist/fracture/src/ScriptEngine.js +0 -183
  100. package/dist/fracture/src/ScriptEngine.js.map +0 -1
  101. package/dist/fracture/src/ScriptValidator.d.ts +0 -68
  102. package/dist/fracture/src/ScriptValidator.d.ts.map +0 -1
  103. package/dist/fracture/src/ScriptValidator.js +0 -351
  104. package/dist/fracture/src/ScriptValidator.js.map +0 -1
  105. package/dist/fracture/src/TestCounter.d.ts +0 -18
  106. package/dist/fracture/src/TestCounter.d.ts.map +0 -1
  107. package/dist/fracture/src/TestCounter.js +0 -82
  108. package/dist/fracture/src/TestCounter.js.map +0 -1
  109. package/dist/fracture/src/VariableResolver.d.ts +0 -20
  110. package/dist/fracture/src/VariableResolver.d.ts.map +0 -1
  111. package/dist/fracture/src/VariableResolver.js +0 -100
  112. package/dist/fracture/src/VariableResolver.js.map +0 -1
  113. package/dist/fracture/src/cli/index.d.ts +0 -3
  114. package/dist/fracture/src/cli/index.d.ts.map +0 -1
  115. package/dist/fracture/src/cli/index.js +0 -347
  116. package/dist/fracture/src/cli/index.js.map +0 -1
  117. package/dist/fracture/src/cli/plugin-commands.d.ts +0 -6
  118. package/dist/fracture/src/cli/plugin-commands.d.ts.map +0 -1
  119. package/dist/fracture/src/cli/plugin-commands.js +0 -263
  120. package/dist/fracture/src/cli/plugin-commands.js.map +0 -1
  121. package/dist/fracture/src/cli/plugin-discovery.d.ts +0 -11
  122. package/dist/fracture/src/cli/plugin-discovery.d.ts.map +0 -1
  123. package/dist/fracture/src/cli/plugin-discovery.js +0 -64
  124. package/dist/fracture/src/cli/plugin-discovery.js.map +0 -1
  125. package/dist/fracture/src/index.d.ts +0 -13
  126. package/dist/fracture/src/index.d.ts.map +0 -1
  127. package/dist/fracture/src/index.js +0 -17
  128. package/dist/fracture/src/index.js.map +0 -1
  129. package/dist/fracture/src/utils.d.ts +0 -28
  130. package/dist/fracture/src/utils.d.ts.map +0 -1
  131. package/dist/fracture/src/utils.js +0 -48
  132. package/dist/fracture/src/utils.js.map +0 -1
  133. package/dist/plugin-auth/src/apikey-auth.d.ts +0 -3
  134. package/dist/plugin-auth/src/apikey-auth.d.ts.map +0 -1
  135. package/dist/plugin-auth/src/apikey-auth.js +0 -73
  136. package/dist/plugin-auth/src/apikey-auth.js.map +0 -1
  137. package/dist/plugin-auth/src/basic-auth.d.ts +0 -3
  138. package/dist/plugin-auth/src/basic-auth.d.ts.map +0 -1
  139. package/dist/plugin-auth/src/basic-auth.js +0 -61
  140. package/dist/plugin-auth/src/basic-auth.js.map +0 -1
  141. package/dist/plugin-auth/src/bearer-auth.d.ts +0 -3
  142. package/dist/plugin-auth/src/bearer-auth.d.ts.map +0 -1
  143. package/dist/plugin-auth/src/bearer-auth.js +0 -49
  144. package/dist/plugin-auth/src/bearer-auth.js.map +0 -1
  145. package/dist/plugin-auth/src/helpers.d.ts +0 -3
  146. package/dist/plugin-auth/src/helpers.d.ts.map +0 -1
  147. package/dist/plugin-auth/src/helpers.js +0 -8
  148. package/dist/plugin-auth/src/helpers.js.map +0 -1
  149. package/dist/plugin-auth/src/index.d.ts +0 -10
  150. package/dist/plugin-auth/src/index.d.ts.map +0 -1
  151. package/dist/plugin-auth/src/index.js +0 -25
  152. package/dist/plugin-auth/src/index.js.map +0 -1
  153. package/dist/plugin-auth/src/oauth2-auth.d.ts +0 -35
  154. package/dist/plugin-auth/src/oauth2-auth.d.ts.map +0 -1
  155. package/dist/plugin-auth/src/oauth2-auth.js +0 -266
  156. package/dist/plugin-auth/src/oauth2-auth.js.map +0 -1
  157. package/dist/plugin-http/src/index.d.ts +0 -4
  158. package/dist/plugin-http/src/index.d.ts.map +0 -1
  159. package/dist/plugin-http/src/index.js +0 -266
  160. package/dist/plugin-http/src/index.js.map +0 -1
  161. package/dist/plugin-vault-file/src/index.d.ts +0 -67
  162. package/dist/plugin-vault-file/src/index.d.ts.map +0 -1
  163. package/dist/plugin-vault-file/src/index.js +0 -171
  164. package/dist/plugin-vault-file/src/index.js.map +0 -1
  165. package/dist/types.d.ts +0 -374
  166. package/dist/types.d.ts.map +0 -1
  167. package/dist/types.js +0 -13
  168. package/dist/types.js.map +0 -1
@@ -1,428 +1,428 @@
1
- import * as acorn from 'acorn';
2
- import * as walk from 'acorn-walk';
3
- import { ValidationError, ScriptType, PluginEventDefinition, IProtocolPlugin } from '@apiquest/types';
4
-
5
- /**
6
- * ScriptValidator provides AST-based validation and analysis of collection scripts
7
- * - Validates quest.test() placement (disallowed in pre-scripts, certain plugin events)
8
- * - Validates quest.expectMessages() placement (only in preRequestScript)
9
- * - Detects conditional test declarations (breaks determinism)
10
- * - Counts expected tests for progress reporting
11
- */
12
- export class ScriptValidator {
13
- /**
14
- * Validate that quest.test() calls are only in allowed script types
15
- * @param script - JavaScript code to validate
16
- * @param scriptType - Type of script (collection-pre, request-post, etc.)
17
- * @param path - Request path for error reporting
18
- * @returns Array of validation errors (empty if valid)
19
- */
20
- static validateTestLocation(
21
- script: string,
22
- scriptType: ScriptType,
23
- path: string
24
- ): ValidationError[] {
25
- const errors: ValidationError[] = [];
26
-
27
- // Disallow quest.test() in these script types
28
- const disallowedTypes = [
29
- ScriptType.CollectionPre,
30
- ScriptType.CollectionPost,
31
- ScriptType.FolderPre,
32
- ScriptType.FolderPost,
33
- ScriptType.PreRequest,
34
- ];
35
-
36
- if (!disallowedTypes.includes(scriptType)) {
37
- return []; // Allowed in PostRequest and some PluginEvent scripts
38
- }
39
-
40
- // Parse and check for quest.test() calls
41
- try {
42
- const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
43
-
44
- walk.simple(ast, {
45
- CallExpression(node: acorn.CallExpression) {
46
- if (
47
- node.callee.type === 'MemberExpression' &&
48
- (node.callee).object.type === 'Identifier' &&
49
- ((node.callee).object).name === 'quest' &&
50
- (node.callee).property.type === 'Identifier' &&
51
- ((node.callee).property).name === 'test'
52
- ) {
53
- errors.push({
54
- message: `quest.test() is not allowed in ${scriptType} scripts`,
55
- location: path,
56
- source: 'script',
57
- scriptType,
58
- details: {
59
- line: node.loc?.start.line,
60
- column: node.loc?.start.column,
61
- suggestion: 'Move tests to postRequestScript or use quest.skip() inside tests',
62
- },
63
- });
64
- }
65
- },
66
- });
67
- } catch (error) {
68
- const err = error as { message?: string; loc?: { line?: number; column?: number } };
69
- errors.push({
70
- message: `Syntax error in script: ${err.message ?? String(error)}`,
71
- location: path,
72
- source: 'script',
73
- scriptType,
74
- details: {
75
- line: err.loc?.line,
76
- column: err.loc?.column,
77
- },
78
- });
79
- }
80
-
81
- return errors;
82
- }
83
-
84
- /**
85
- * Validate that quest.test() is NOT inside conditional statements (breaks determinism)
86
- * @param script - JavaScript code to validate
87
- * @param scriptType - Type of script
88
- * @param path - Request path for error reporting
89
- * @returns Array of validation errors (empty if valid)
90
- */
91
- static validateNoConditionalTests(
92
- script: string,
93
- scriptType: ScriptType,
94
- path: string
95
- ): ValidationError[] {
96
- const errors: ValidationError[] = [];
97
-
98
- try {
99
- const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
100
-
101
- walk.ancestor(ast, {
102
- CallExpression(node: acorn.CallExpression, ancestors: acorn.Node[]) {
103
- if (
104
- node.callee.type === 'MemberExpression' &&
105
- (node.callee).object.type === 'Identifier' &&
106
- ((node.callee).object).name === 'quest' &&
107
- (node.callee).property.type === 'Identifier' &&
108
- ((node.callee).property).name === 'test'
109
- ) {
110
- const insideConditional = ancestors.some(
111
- (ancestor) =>
112
- ancestor.type === 'IfStatement' ||
113
- ancestor.type === 'ConditionalExpression' ||
114
- ancestor.type === 'LogicalExpression' ||
115
- ancestor.type === 'TryStatement'
116
- );
117
-
118
- if (insideConditional) {
119
- errors.push({
120
- message: 'quest.test() cannot be declared conditionally (breaks deterministic test counting)',
121
- location: path,
122
- source: 'script',
123
- scriptType,
124
- details: {
125
- line: node.loc?.start.line,
126
- column: node.loc?.start.column,
127
- suggestion: 'Use quest.skip() inside the test, or use request.condition field for request-level control',
128
- },
129
- });
130
- }
131
- }
132
- },
133
- });
134
- } catch (error) {
135
- // Syntax errors already caught by validateTestLocation
136
- }
137
-
138
- return errors;
139
- }
140
-
141
- /**
142
- * Validate quest.expectMessages() is only called in preRequestScript
143
- * and validates protocol supports plugin events with canHaveTests
144
- * @param script - JavaScript code to validate
145
- * @param scriptType - Type of script
146
- * @param path - Request path for error reporting
147
- * @param protocolPlugin - Protocol plugin to check for event support (optional)
148
- * @param eventName - Specific event name if this is a plugin event script (optional)
149
- * @returns Array of validation errors (empty if valid)
150
- */
151
- static validateExpectMessages(
152
- script: string,
153
- scriptType: ScriptType,
154
- path: string,
155
- protocolPlugin?: IProtocolPlugin,
156
- eventName?: string
157
- ): ValidationError[] {
158
- const errors: ValidationError[] = [];
159
-
160
- try {
161
- const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
162
-
163
- walk.simple(ast, {
164
- CallExpression(node: acorn.CallExpression) {
165
- if (
166
- node.callee.type === 'MemberExpression' &&
167
- (node.callee).object.type === 'Identifier' &&
168
- ((node.callee).object).name === 'quest' &&
169
- (node.callee).property.type === 'Identifier' &&
170
- ((node.callee).property).name === 'expectMessages'
171
- ) {
172
- if (scriptType !== ScriptType.PreRequest) {
173
- errors.push({
174
- message: 'quest.expectMessages() can only be called in preRequestScript',
175
- location: path,
176
- source: 'script',
177
- scriptType,
178
- details: {
179
- line: node.loc?.start.line,
180
- column: node.loc?.start.column,
181
- },
182
- });
183
- return;
184
- }
185
-
186
- if (node.arguments.length > 0) {
187
- const arg = node.arguments[0];
188
- if (arg.type === 'Literal' && typeof (arg).value === 'number') {
189
- const numValue = (arg).value;
190
- if (!Number.isInteger(numValue) || numValue <= 0) {
191
- errors.push({
192
- message: 'quest.expectMessages() requires a positive integer count',
193
- location: path,
194
- source: 'script',
195
- scriptType,
196
- details: {
197
- line: node.loc?.start.line,
198
- column: node.loc?.start.column,
199
- suggestion: 'Use a positive integer like quest.expectMessages(10)',
200
- },
201
- });
202
- }
203
- } else if (arg.type === 'UnaryExpression' && (arg).operator === '-') {
204
- errors.push({
205
- message: 'quest.expectMessages() requires a positive integer count',
206
- location: path,
207
- source: 'script',
208
- scriptType,
209
- details: {
210
- line: node.loc?.start.line,
211
- column: node.loc?.start.column,
212
- suggestion: 'Use a positive integer like quest.expectMessages(10)',
213
- },
214
- });
215
- }
216
- }
217
-
218
- if (protocolPlugin !== undefined) {
219
- if (eventName !== undefined) {
220
- const eventDef = protocolPlugin.events?.find(
221
- (event: PluginEventDefinition) => event.name === eventName
222
- );
223
- if (eventDef !== undefined && eventDef.canHaveTests !== true) {
224
- errors.push({
225
- message: `quest.expectMessages() is not supported for event '${eventName}' (canHaveTests is false)`,
226
- location: path,
227
- source: 'script',
228
- scriptType,
229
- details: {
230
- line: node.loc?.start.line,
231
- column: node.loc?.start.column,
232
- suggestion: 'quest.expectMessages() can only be used with events that support tests',
233
- },
234
- });
235
- }
236
- } else {
237
- const hasTestableEvents = protocolPlugin.events?.some(
238
- (event: PluginEventDefinition) => event.canHaveTests === true
239
- ) ?? false;
240
-
241
- if (hasTestableEvents === false) {
242
- errors.push({
243
- message: `quest.expectMessages() is not supported for protocol '${protocolPlugin.protocols[0]}' (no plugin events with canHaveTests)`,
244
- location: path,
245
- source: 'script',
246
- scriptType,
247
- details: {
248
- line: node.loc?.start.line,
249
- column: node.loc?.start.column,
250
- suggestion: 'quest.expectMessages() is only for streaming protocols (websocket, sse, grpc)',
251
- },
252
- });
253
- }
254
- }
255
- }
256
- }
257
- },
258
- });
259
- } catch (error) {
260
- // Syntax errors already caught by validateTestLocation
261
- }
262
-
263
- return errors;
264
- }
265
-
266
- /**
267
- * Validate plugin event script can have tests (based on PluginEventDefinition)
268
- * @param script - JavaScript code to validate
269
- * @param eventDefinition - Plugin event definition with canHaveTests flag
270
- * @param path - Request path for error reporting
271
- * @returns Array of validation errors (empty if valid)
272
- */
273
- static validatePluginEventScript(
274
- script: string,
275
- eventDefinition: PluginEventDefinition,
276
- path: string
277
- ): ValidationError[] {
278
- const errors: ValidationError[] = [];
279
-
280
- if (eventDefinition.canHaveTests) {
281
- return []; // Tests are allowed
282
- }
283
-
284
- // Check for quest.test() calls when not allowed
285
- try {
286
- const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
287
-
288
- walk.simple(ast, {
289
- CallExpression(node: acorn.CallExpression) {
290
- if (
291
- node.callee.type === 'MemberExpression' &&
292
- (node.callee).object.type === 'Identifier' &&
293
- ((node.callee).object).name === 'quest' &&
294
- (node.callee).property.type === 'Identifier' &&
295
- ((node.callee).property).name === 'test'
296
- ) {
297
- errors.push({
298
- message: `quest.test() is not allowed in plugin event '${eventDefinition.name}' (canHaveTests: false)`,
299
- location: path,
300
- source: 'script',
301
- scriptType: ScriptType.PluginEvent,
302
- details: {
303
- line: node.loc?.start.line,
304
- column: node.loc?.start.column,
305
- suggestion: `Only use quest.test() in plugin events that allow tests (check plugin.events[].canHaveTests)`,
306
- },
307
- });
308
- }
309
- },
310
- });
311
- } catch (error) {
312
- // Syntax errors already caught by validateTestLocation
313
- }
314
-
315
- return errors;
316
- }
317
-
318
- /**
319
- * Count total quest.test() calls in a script (for deterministic test counting)
320
- * @param script - JavaScript code to analyze
321
- * @returns Number of quest.test() calls found
322
- */
323
- static countTests(script: string): number {
324
- let count = 0;
325
-
326
- try {
327
- const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module' });
328
-
329
- walk.simple(ast, {
330
- CallExpression(node: acorn.CallExpression) {
331
- if (
332
- node.callee.type === 'MemberExpression' &&
333
- (node.callee).object.type === 'Identifier' &&
334
- ((node.callee).object).name === 'quest' &&
335
- (node.callee).property.type === 'Identifier' &&
336
- ((node.callee).property).name === 'test'
337
- ) {
338
- count++;
339
- }
340
- },
341
- });
342
- } catch (error) {
343
- // If script has syntax errors, return 0 (will be caught by validation)
344
- return 0;
345
- }
346
-
347
- return count;
348
- }
349
-
350
- /**
351
- * Extract expected message count from quest.expectMessages() call in preRequestScript
352
- * @param script - JavaScript code to analyze (must be preRequestScript)
353
- * @returns Expected message count, or null if not specified
354
- */
355
- static extractExpectedMessages(script: string): number | null {
356
- let expectedCount: number | null = null;
357
-
358
- try {
359
- const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module' });
360
-
361
- walk.simple(ast, {
362
- CallExpression(node: acorn.CallExpression) {
363
- if (
364
- node.callee.type === 'MemberExpression' &&
365
- (node.callee).object.type === 'Identifier' &&
366
- ((node.callee).object).name === 'quest' &&
367
- (node.callee).property.type === 'Identifier' &&
368
- ((node.callee).property).name === 'expectMessages'
369
- ) {
370
- if (node.arguments.length > 0) {
371
- const firstArg = node.arguments[0];
372
- if (firstArg.type === 'Literal' && typeof (firstArg).value === 'number') {
373
- expectedCount = (firstArg).value;
374
- }
375
- }
376
- }
377
- },
378
- });
379
- } catch (error) {
380
- // If script has syntax errors, return null
381
- return null;
382
- }
383
-
384
- return expectedCount;
385
- }
386
-
387
- /**
388
- * Validate all aspects of a script (comprehensive validation)
389
- * @param script - JavaScript code to validate
390
- * @param scriptType - Type of script
391
- * @param path - Request path for error reporting
392
- * @param eventDefinition - Optional plugin event definition (for PluginEvent scripts)
393
- * @param protocolPlugin - Optional protocol plugin for protocol-specific validation
394
- * @returns Array of validation errors (empty if valid)
395
- */
396
- static validateScript(
397
- script: string,
398
- scriptType: ScriptType,
399
- path: string,
400
- eventDefinition?: PluginEventDefinition,
401
- protocolPlugin?: IProtocolPlugin,
402
- strictMode: boolean = true
403
- ): ValidationError[] {
404
- const errors: ValidationError[] = [];
405
-
406
- // 1. Validate test location
407
- errors.push(...this.validateTestLocation(script, scriptType, path));
408
-
409
- if (strictMode === true && (scriptType === ScriptType.PostRequest || eventDefinition?.canHaveTests === true)) {
410
- errors.push(...this.validateNoConditionalTests(script, scriptType, path));
411
- }
412
-
413
- // 3. Validate quest.expectMessages() placement
414
- errors.push(...this.validateExpectMessages(
415
- script,
416
- scriptType,
417
- path,
418
- protocolPlugin,
419
- eventDefinition?.name // Pass event name if this is a plugin event script
420
- ));
421
-
422
- if (scriptType === ScriptType.PluginEvent && eventDefinition !== undefined) {
423
- errors.push(...this.validatePluginEventScript(script, eventDefinition, path));
424
- }
425
-
426
- return errors;
427
- }
428
- }
1
+ import * as acorn from 'acorn';
2
+ import * as walk from 'acorn-walk';
3
+ import { ValidationError, ScriptType, PluginEventDefinition, IProtocolPlugin } from '@apiquest/types';
4
+
5
+ /**
6
+ * ScriptValidator provides AST-based validation and analysis of collection scripts
7
+ * - Validates quest.test() placement (disallowed in pre-scripts, certain plugin events)
8
+ * - Validates quest.expectMessages() placement (only in preRequestScript)
9
+ * - Detects conditional test declarations (breaks determinism)
10
+ * - Counts expected tests for progress reporting
11
+ */
12
+ export class ScriptValidator {
13
+ /**
14
+ * Validate that quest.test() calls are only in allowed script types
15
+ * @param script - JavaScript code to validate
16
+ * @param scriptType - Type of script (collection-pre, request-post, etc.)
17
+ * @param path - Request path for error reporting
18
+ * @returns Array of validation errors (empty if valid)
19
+ */
20
+ static validateTestLocation(
21
+ script: string,
22
+ scriptType: ScriptType,
23
+ path: string
24
+ ): ValidationError[] {
25
+ const errors: ValidationError[] = [];
26
+
27
+ // Disallow quest.test() in these script types
28
+ const disallowedTypes = [
29
+ ScriptType.CollectionPre,
30
+ ScriptType.CollectionPost,
31
+ ScriptType.FolderPre,
32
+ ScriptType.FolderPost,
33
+ ScriptType.PreRequest,
34
+ ];
35
+
36
+ if (!disallowedTypes.includes(scriptType)) {
37
+ return []; // Allowed in PostRequest and some PluginEvent scripts
38
+ }
39
+
40
+ // Parse and check for quest.test() calls
41
+ try {
42
+ const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
43
+
44
+ walk.simple(ast, {
45
+ CallExpression(node: acorn.CallExpression) {
46
+ if (
47
+ node.callee.type === 'MemberExpression' &&
48
+ (node.callee).object.type === 'Identifier' &&
49
+ ((node.callee).object).name === 'quest' &&
50
+ (node.callee).property.type === 'Identifier' &&
51
+ ((node.callee).property).name === 'test'
52
+ ) {
53
+ errors.push({
54
+ message: `quest.test() is not allowed in ${scriptType} scripts`,
55
+ location: path,
56
+ source: 'script',
57
+ scriptType,
58
+ details: {
59
+ line: node.loc?.start.line,
60
+ column: node.loc?.start.column,
61
+ suggestion: 'Move tests to postRequestScript or use quest.skip() inside tests',
62
+ },
63
+ });
64
+ }
65
+ },
66
+ });
67
+ } catch (error) {
68
+ const err = error as { message?: string; loc?: { line?: number; column?: number } };
69
+ errors.push({
70
+ message: `Syntax error in script: ${err.message ?? String(error)}`,
71
+ location: path,
72
+ source: 'script',
73
+ scriptType,
74
+ details: {
75
+ line: err.loc?.line,
76
+ column: err.loc?.column,
77
+ },
78
+ });
79
+ }
80
+
81
+ return errors;
82
+ }
83
+
84
+ /**
85
+ * Validate that quest.test() is NOT inside conditional statements (breaks determinism)
86
+ * @param script - JavaScript code to validate
87
+ * @param scriptType - Type of script
88
+ * @param path - Request path for error reporting
89
+ * @returns Array of validation errors (empty if valid)
90
+ */
91
+ static validateNoConditionalTests(
92
+ script: string,
93
+ scriptType: ScriptType,
94
+ path: string
95
+ ): ValidationError[] {
96
+ const errors: ValidationError[] = [];
97
+
98
+ try {
99
+ const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
100
+
101
+ walk.ancestor(ast, {
102
+ CallExpression(node: acorn.CallExpression, ancestors: acorn.Node[]) {
103
+ if (
104
+ node.callee.type === 'MemberExpression' &&
105
+ (node.callee).object.type === 'Identifier' &&
106
+ ((node.callee).object).name === 'quest' &&
107
+ (node.callee).property.type === 'Identifier' &&
108
+ ((node.callee).property).name === 'test'
109
+ ) {
110
+ const insideConditional = ancestors.some(
111
+ (ancestor) =>
112
+ ancestor.type === 'IfStatement' ||
113
+ ancestor.type === 'ConditionalExpression' ||
114
+ ancestor.type === 'LogicalExpression' ||
115
+ ancestor.type === 'TryStatement'
116
+ );
117
+
118
+ if (insideConditional) {
119
+ errors.push({
120
+ message: 'quest.test() cannot be declared conditionally (breaks deterministic test counting)',
121
+ location: path,
122
+ source: 'script',
123
+ scriptType,
124
+ details: {
125
+ line: node.loc?.start.line,
126
+ column: node.loc?.start.column,
127
+ suggestion: 'Use quest.skip() inside the test, or use request.condition field for request-level control',
128
+ },
129
+ });
130
+ }
131
+ }
132
+ },
133
+ });
134
+ } catch (error) {
135
+ // Syntax errors already caught by validateTestLocation
136
+ }
137
+
138
+ return errors;
139
+ }
140
+
141
+ /**
142
+ * Validate quest.expectMessages() is only called in preRequestScript
143
+ * and validates protocol supports plugin events with canHaveTests
144
+ * @param script - JavaScript code to validate
145
+ * @param scriptType - Type of script
146
+ * @param path - Request path for error reporting
147
+ * @param protocolPlugin - Protocol plugin to check for event support (optional)
148
+ * @param eventName - Specific event name if this is a plugin event script (optional)
149
+ * @returns Array of validation errors (empty if valid)
150
+ */
151
+ static validateExpectMessages(
152
+ script: string,
153
+ scriptType: ScriptType,
154
+ path: string,
155
+ protocolPlugin?: IProtocolPlugin,
156
+ eventName?: string
157
+ ): ValidationError[] {
158
+ const errors: ValidationError[] = [];
159
+
160
+ try {
161
+ const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
162
+
163
+ walk.simple(ast, {
164
+ CallExpression(node: acorn.CallExpression) {
165
+ if (
166
+ node.callee.type === 'MemberExpression' &&
167
+ (node.callee).object.type === 'Identifier' &&
168
+ ((node.callee).object).name === 'quest' &&
169
+ (node.callee).property.type === 'Identifier' &&
170
+ ((node.callee).property).name === 'expectMessages'
171
+ ) {
172
+ if (scriptType !== ScriptType.PreRequest) {
173
+ errors.push({
174
+ message: 'quest.expectMessages() can only be called in preRequestScript',
175
+ location: path,
176
+ source: 'script',
177
+ scriptType,
178
+ details: {
179
+ line: node.loc?.start.line,
180
+ column: node.loc?.start.column,
181
+ },
182
+ });
183
+ return;
184
+ }
185
+
186
+ if (node.arguments.length > 0) {
187
+ const arg = node.arguments[0];
188
+ if (arg.type === 'Literal' && typeof (arg).value === 'number') {
189
+ const numValue = (arg).value;
190
+ if (!Number.isInteger(numValue) || numValue <= 0) {
191
+ errors.push({
192
+ message: 'quest.expectMessages() requires a positive integer count',
193
+ location: path,
194
+ source: 'script',
195
+ scriptType,
196
+ details: {
197
+ line: node.loc?.start.line,
198
+ column: node.loc?.start.column,
199
+ suggestion: 'Use a positive integer like quest.expectMessages(10)',
200
+ },
201
+ });
202
+ }
203
+ } else if (arg.type === 'UnaryExpression' && (arg).operator === '-') {
204
+ errors.push({
205
+ message: 'quest.expectMessages() requires a positive integer count',
206
+ location: path,
207
+ source: 'script',
208
+ scriptType,
209
+ details: {
210
+ line: node.loc?.start.line,
211
+ column: node.loc?.start.column,
212
+ suggestion: 'Use a positive integer like quest.expectMessages(10)',
213
+ },
214
+ });
215
+ }
216
+ }
217
+
218
+ if (protocolPlugin !== undefined) {
219
+ if (eventName !== undefined) {
220
+ const eventDef = protocolPlugin.events?.find(
221
+ (event: PluginEventDefinition) => event.name === eventName
222
+ );
223
+ if (eventDef !== undefined && eventDef.canHaveTests !== true) {
224
+ errors.push({
225
+ message: `quest.expectMessages() is not supported for event '${eventName}' (canHaveTests is false)`,
226
+ location: path,
227
+ source: 'script',
228
+ scriptType,
229
+ details: {
230
+ line: node.loc?.start.line,
231
+ column: node.loc?.start.column,
232
+ suggestion: 'quest.expectMessages() can only be used with events that support tests',
233
+ },
234
+ });
235
+ }
236
+ } else {
237
+ const hasTestableEvents = protocolPlugin.events?.some(
238
+ (event: PluginEventDefinition) => event.canHaveTests === true
239
+ ) ?? false;
240
+
241
+ if (hasTestableEvents === false) {
242
+ errors.push({
243
+ message: `quest.expectMessages() is not supported for protocol '${protocolPlugin.protocols[0]}' (no plugin events with canHaveTests)`,
244
+ location: path,
245
+ source: 'script',
246
+ scriptType,
247
+ details: {
248
+ line: node.loc?.start.line,
249
+ column: node.loc?.start.column,
250
+ suggestion: 'quest.expectMessages() is only for streaming protocols (websocket, sse, grpc)',
251
+ },
252
+ });
253
+ }
254
+ }
255
+ }
256
+ }
257
+ },
258
+ });
259
+ } catch (error) {
260
+ // Syntax errors already caught by validateTestLocation
261
+ }
262
+
263
+ return errors;
264
+ }
265
+
266
+ /**
267
+ * Validate plugin event script can have tests (based on PluginEventDefinition)
268
+ * @param script - JavaScript code to validate
269
+ * @param eventDefinition - Plugin event definition with canHaveTests flag
270
+ * @param path - Request path for error reporting
271
+ * @returns Array of validation errors (empty if valid)
272
+ */
273
+ static validatePluginEventScript(
274
+ script: string,
275
+ eventDefinition: PluginEventDefinition,
276
+ path: string
277
+ ): ValidationError[] {
278
+ const errors: ValidationError[] = [];
279
+
280
+ if (eventDefinition.canHaveTests) {
281
+ return []; // Tests are allowed
282
+ }
283
+
284
+ // Check for quest.test() calls when not allowed
285
+ try {
286
+ const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module', locations: true });
287
+
288
+ walk.simple(ast, {
289
+ CallExpression(node: acorn.CallExpression) {
290
+ if (
291
+ node.callee.type === 'MemberExpression' &&
292
+ (node.callee).object.type === 'Identifier' &&
293
+ ((node.callee).object).name === 'quest' &&
294
+ (node.callee).property.type === 'Identifier' &&
295
+ ((node.callee).property).name === 'test'
296
+ ) {
297
+ errors.push({
298
+ message: `quest.test() is not allowed in plugin event '${eventDefinition.name}' (canHaveTests: false)`,
299
+ location: path,
300
+ source: 'script',
301
+ scriptType: ScriptType.PluginEvent,
302
+ details: {
303
+ line: node.loc?.start.line,
304
+ column: node.loc?.start.column,
305
+ suggestion: `Only use quest.test() in plugin events that allow tests (check plugin.events[].canHaveTests)`,
306
+ },
307
+ });
308
+ }
309
+ },
310
+ });
311
+ } catch (error) {
312
+ // Syntax errors already caught by validateTestLocation
313
+ }
314
+
315
+ return errors;
316
+ }
317
+
318
+ /**
319
+ * Count total quest.test() calls in a script (for deterministic test counting)
320
+ * @param script - JavaScript code to analyze
321
+ * @returns Number of quest.test() calls found
322
+ */
323
+ static countTests(script: string): number {
324
+ let count = 0;
325
+
326
+ try {
327
+ const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module' });
328
+
329
+ walk.simple(ast, {
330
+ CallExpression(node: acorn.CallExpression) {
331
+ if (
332
+ node.callee.type === 'MemberExpression' &&
333
+ (node.callee).object.type === 'Identifier' &&
334
+ ((node.callee).object).name === 'quest' &&
335
+ (node.callee).property.type === 'Identifier' &&
336
+ ((node.callee).property).name === 'test'
337
+ ) {
338
+ count++;
339
+ }
340
+ },
341
+ });
342
+ } catch (error) {
343
+ // If script has syntax errors, return 0 (will be caught by validation)
344
+ return 0;
345
+ }
346
+
347
+ return count;
348
+ }
349
+
350
+ /**
351
+ * Extract expected message count from quest.expectMessages() call in preRequestScript
352
+ * @param script - JavaScript code to analyze (must be preRequestScript)
353
+ * @returns Expected message count, or null if not specified
354
+ */
355
+ static extractExpectedMessages(script: string): number | null {
356
+ let expectedCount: number | null = null;
357
+
358
+ try {
359
+ const ast = acorn.parse(script, { ecmaVersion: 2022, sourceType: 'module' });
360
+
361
+ walk.simple(ast, {
362
+ CallExpression(node: acorn.CallExpression) {
363
+ if (
364
+ node.callee.type === 'MemberExpression' &&
365
+ (node.callee).object.type === 'Identifier' &&
366
+ ((node.callee).object).name === 'quest' &&
367
+ (node.callee).property.type === 'Identifier' &&
368
+ ((node.callee).property).name === 'expectMessages'
369
+ ) {
370
+ if (node.arguments.length > 0) {
371
+ const firstArg = node.arguments[0];
372
+ if (firstArg.type === 'Literal' && typeof (firstArg).value === 'number') {
373
+ expectedCount = (firstArg).value;
374
+ }
375
+ }
376
+ }
377
+ },
378
+ });
379
+ } catch (error) {
380
+ // If script has syntax errors, return null
381
+ return null;
382
+ }
383
+
384
+ return expectedCount;
385
+ }
386
+
387
+ /**
388
+ * Validate all aspects of a script (comprehensive validation)
389
+ * @param script - JavaScript code to validate
390
+ * @param scriptType - Type of script
391
+ * @param path - Request path for error reporting
392
+ * @param eventDefinition - Optional plugin event definition (for PluginEvent scripts)
393
+ * @param protocolPlugin - Optional protocol plugin for protocol-specific validation
394
+ * @returns Array of validation errors (empty if valid)
395
+ */
396
+ static validateScript(
397
+ script: string,
398
+ scriptType: ScriptType,
399
+ path: string,
400
+ eventDefinition?: PluginEventDefinition,
401
+ protocolPlugin?: IProtocolPlugin,
402
+ strictMode: boolean = true
403
+ ): ValidationError[] {
404
+ const errors: ValidationError[] = [];
405
+
406
+ // 1. Validate test location
407
+ errors.push(...this.validateTestLocation(script, scriptType, path));
408
+
409
+ if (strictMode === true && (scriptType === ScriptType.PostRequest || eventDefinition?.canHaveTests === true)) {
410
+ errors.push(...this.validateNoConditionalTests(script, scriptType, path));
411
+ }
412
+
413
+ // 3. Validate quest.expectMessages() placement
414
+ errors.push(...this.validateExpectMessages(
415
+ script,
416
+ scriptType,
417
+ path,
418
+ protocolPlugin,
419
+ eventDefinition?.name // Pass event name if this is a plugin event script
420
+ ));
421
+
422
+ if (scriptType === ScriptType.PluginEvent && eventDefinition !== undefined) {
423
+ errors.push(...this.validatePluginEventScript(script, eventDefinition, path));
424
+ }
425
+
426
+ return errors;
427
+ }
428
+ }