@testsmith/testblocks 0.6.0 → 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.
Files changed (71) hide show
  1. package/dist/cli/executor.d.ts +4 -1
  2. package/dist/cli/executor.js +101 -5
  3. package/dist/cli/index.js +78 -3
  4. package/dist/cli/reporters/ConsoleReporter.d.ts +12 -0
  5. package/dist/cli/reporters/ConsoleReporter.js +39 -0
  6. package/dist/cli/reporters/HTMLReporter.d.ts +19 -0
  7. package/dist/cli/reporters/HTMLReporter.js +506 -0
  8. package/dist/cli/reporters/JSONReporter.d.ts +15 -0
  9. package/dist/cli/reporters/JSONReporter.js +80 -0
  10. package/dist/cli/reporters/JUnitReporter.d.ts +19 -0
  11. package/dist/cli/reporters/JUnitReporter.js +105 -0
  12. package/dist/cli/reporters/index.d.ts +17 -0
  13. package/dist/cli/reporters/index.js +31 -0
  14. package/dist/cli/reporters/types.d.ts +28 -0
  15. package/dist/cli/reporters/types.js +2 -0
  16. package/dist/cli/reporters/utils.d.ts +31 -0
  17. package/dist/cli/reporters/utils.js +136 -0
  18. package/dist/cli/reporters.d.ts +13 -62
  19. package/dist/cli/reporters.js +16 -719
  20. package/dist/client/assets/index-Boo8ZrY_.js +2195 -0
  21. package/dist/client/assets/{index-dXniUrbi.js.map → index-Boo8ZrY_.js.map} +1 -1
  22. package/dist/client/assets/index-OxNH9dW-.css +1 -0
  23. package/dist/client/index.html +2 -2
  24. package/dist/core/blocks/api.js +3 -6
  25. package/dist/core/blocks/assertions.d.ts +31 -0
  26. package/dist/core/blocks/assertions.js +72 -0
  27. package/dist/core/blocks/index.d.ts +1 -0
  28. package/dist/core/blocks/index.js +6 -1
  29. package/dist/core/blocks/lifecycle.js +5 -3
  30. package/dist/core/blocks/logic.js +2 -3
  31. package/dist/core/blocks/playwright/assertions.d.ts +5 -0
  32. package/dist/core/blocks/playwright/assertions.js +321 -0
  33. package/dist/core/blocks/playwright/index.d.ts +17 -0
  34. package/dist/core/blocks/playwright/index.js +49 -0
  35. package/dist/core/blocks/playwright/interactions.d.ts +5 -0
  36. package/dist/core/blocks/playwright/interactions.js +191 -0
  37. package/dist/core/blocks/playwright/navigation.d.ts +5 -0
  38. package/dist/core/blocks/playwright/navigation.js +133 -0
  39. package/dist/core/blocks/playwright/retrieval.d.ts +5 -0
  40. package/dist/core/blocks/playwright/retrieval.js +144 -0
  41. package/dist/core/blocks/playwright/types.d.ts +65 -0
  42. package/dist/core/blocks/playwright/types.js +5 -0
  43. package/dist/core/blocks/playwright/utils.d.ts +26 -0
  44. package/dist/core/blocks/playwright/utils.js +137 -0
  45. package/dist/core/blocks/playwright.d.ts +13 -2
  46. package/dist/core/blocks/playwright.js +14 -761
  47. package/dist/core/executor/BaseTestExecutor.d.ts +60 -0
  48. package/dist/core/executor/BaseTestExecutor.js +297 -0
  49. package/dist/core/executor/index.d.ts +1 -0
  50. package/dist/core/executor/index.js +5 -0
  51. package/dist/core/index.d.ts +1 -0
  52. package/dist/core/index.js +4 -0
  53. package/dist/core/types.d.ts +12 -0
  54. package/dist/core/utils/blocklyParser.d.ts +18 -0
  55. package/dist/core/utils/blocklyParser.js +84 -0
  56. package/dist/core/utils/dataLoader.d.ts +9 -0
  57. package/dist/core/utils/dataLoader.js +117 -0
  58. package/dist/core/utils/index.d.ts +2 -0
  59. package/dist/core/utils/index.js +12 -0
  60. package/dist/core/utils/logger.d.ts +14 -0
  61. package/dist/core/utils/logger.js +48 -0
  62. package/dist/core/utils/variableResolver.d.ts +24 -0
  63. package/dist/core/utils/variableResolver.js +92 -0
  64. package/dist/server/executor.d.ts +6 -0
  65. package/dist/server/executor.js +207 -47
  66. package/dist/server/globals.d.ts +6 -1
  67. package/dist/server/globals.js +7 -0
  68. package/dist/server/startServer.js +15 -0
  69. package/package.json +1 -1
  70. package/dist/client/assets/index-dXniUrbi.js +0 -2193
  71. package/dist/client/assets/index-oTTttNKd.css +0 -1
@@ -0,0 +1,14 @@
1
+ import { Logger } from '../types';
2
+ export interface LoggerOptions {
3
+ prefix?: string;
4
+ indent?: string;
5
+ debug?: boolean;
6
+ }
7
+ /**
8
+ * Create a logger instance for test execution
9
+ */
10
+ export declare function createLogger(options?: LoggerOptions): Logger;
11
+ /**
12
+ * Create a CLI-style logger with simpler output
13
+ */
14
+ export declare function createCliLogger(): Logger;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createLogger = createLogger;
4
+ exports.createCliLogger = createCliLogger;
5
+ /**
6
+ * Create a logger instance for test execution
7
+ */
8
+ function createLogger(options = {}) {
9
+ const { prefix = '', indent = ' ', debug = false } = options;
10
+ const logPrefix = prefix ? `${prefix} ` : '';
11
+ return {
12
+ info: (message, data) => {
13
+ console.log(`${indent}[INFO] ${logPrefix}${message}`, data !== undefined ? data : '');
14
+ },
15
+ warn: (message, data) => {
16
+ console.warn(`${indent}[WARN] ${logPrefix}${message}`, data !== undefined ? data : '');
17
+ },
18
+ error: (message, data) => {
19
+ console.error(`${indent}[ERROR] ${logPrefix}${message}`, data !== undefined ? data : '');
20
+ },
21
+ debug: (message, data) => {
22
+ if (debug || process.env.DEBUG) {
23
+ console.debug(`${indent}[DEBUG] ${logPrefix}${message}`, data !== undefined ? data : '');
24
+ }
25
+ },
26
+ };
27
+ }
28
+ /**
29
+ * Create a CLI-style logger with simpler output
30
+ */
31
+ function createCliLogger() {
32
+ return {
33
+ info: (message, data) => {
34
+ console.log(` ${message}`, data !== undefined ? data : '');
35
+ },
36
+ warn: (message, data) => {
37
+ console.warn(` \u26a0 ${message}`, data !== undefined ? data : '');
38
+ },
39
+ error: (message, data) => {
40
+ console.error(` \u2717 ${message}`, data !== undefined ? data : '');
41
+ },
42
+ debug: (message, data) => {
43
+ if (process.env.DEBUG) {
44
+ console.debug(` [debug] ${message}`, data !== undefined ? data : '');
45
+ }
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,24 @@
1
+ import { ExecutionContext } from '../types';
2
+ /**
3
+ * Utility class for resolving variable placeholders in strings
4
+ */
5
+ export declare class VariableResolver {
6
+ /**
7
+ * Resolve ${variable} placeholders in a string using context variables and currentData
8
+ * Supports dot notation for nested object access (e.g., ${user.email})
9
+ */
10
+ static resolve(text: string, context: ExecutionContext): string;
11
+ /**
12
+ * Resolve variables in an object recursively
13
+ */
14
+ static resolveObject(obj: unknown, context: ExecutionContext): unknown;
15
+ /**
16
+ * Check if a string contains variable placeholders
17
+ */
18
+ static hasVariables(text: string): boolean;
19
+ }
20
+ /**
21
+ * Resolve variable defaults from globals format
22
+ * Handles objects with { type, default, description } structure
23
+ */
24
+ export declare function resolveVariableDefaults(vars?: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VariableResolver = void 0;
4
+ exports.resolveVariableDefaults = resolveVariableDefaults;
5
+ /**
6
+ * Utility class for resolving variable placeholders in strings
7
+ */
8
+ class VariableResolver {
9
+ /**
10
+ * Resolve ${variable} placeholders in a string using context variables and currentData
11
+ * Supports dot notation for nested object access (e.g., ${user.email})
12
+ */
13
+ static resolve(text, context) {
14
+ if (typeof text !== 'string')
15
+ return text;
16
+ return text.replace(/\$\{([\w.]+)\}/g, (match, path) => {
17
+ const parts = path.split('.');
18
+ const varName = parts[0];
19
+ // Check currentData first (for data-driven tests)
20
+ if (context.currentData?.values[varName] !== undefined) {
21
+ let value = context.currentData.values[varName];
22
+ // Handle dot notation for nested access
23
+ if (parts.length > 1 && value !== null && typeof value === 'object') {
24
+ for (let i = 1; i < parts.length; i++) {
25
+ if (value === undefined || value === null)
26
+ break;
27
+ value = value[parts[i]];
28
+ }
29
+ }
30
+ if (value !== undefined && value !== null) {
31
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
32
+ }
33
+ }
34
+ // Then check context variables
35
+ let value = context.variables.get(varName);
36
+ if (parts.length > 1 && value !== undefined && value !== null) {
37
+ for (let i = 1; i < parts.length; i++) {
38
+ if (value === undefined || value === null)
39
+ break;
40
+ value = value[parts[i]];
41
+ }
42
+ }
43
+ if (value === undefined || value === null)
44
+ return match;
45
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
46
+ });
47
+ }
48
+ /**
49
+ * Resolve variables in an object recursively
50
+ */
51
+ static resolveObject(obj, context) {
52
+ if (typeof obj === 'string') {
53
+ return this.resolve(obj, context);
54
+ }
55
+ if (Array.isArray(obj)) {
56
+ return obj.map(item => this.resolveObject(item, context));
57
+ }
58
+ if (obj && typeof obj === 'object') {
59
+ const result = {};
60
+ for (const [key, value] of Object.entries(obj)) {
61
+ result[key] = this.resolveObject(value, context);
62
+ }
63
+ return result;
64
+ }
65
+ return obj;
66
+ }
67
+ /**
68
+ * Check if a string contains variable placeholders
69
+ */
70
+ static hasVariables(text) {
71
+ return typeof text === 'string' && /\$\{[\w.]+\}/.test(text);
72
+ }
73
+ }
74
+ exports.VariableResolver = VariableResolver;
75
+ /**
76
+ * Resolve variable defaults from globals format
77
+ * Handles objects with { type, default, description } structure
78
+ */
79
+ function resolveVariableDefaults(vars) {
80
+ if (!vars)
81
+ return {};
82
+ const resolved = {};
83
+ for (const [key, value] of Object.entries(vars)) {
84
+ if (value && typeof value === 'object' && 'default' in value) {
85
+ resolved[key] = value.default;
86
+ }
87
+ else {
88
+ resolved[key] = value;
89
+ }
90
+ }
91
+ return resolved;
92
+ }
@@ -7,6 +7,7 @@ export interface ExecutorOptions {
7
7
  plugins?: Plugin[];
8
8
  testIdAttribute?: string;
9
9
  baseDir?: string;
10
+ procedures?: Record<string, ProcedureDefinition>;
10
11
  }
11
12
  export declare class TestExecutor {
12
13
  private options;
@@ -14,6 +15,7 @@ export declare class TestExecutor {
14
15
  private context;
15
16
  private page;
16
17
  private plugins;
18
+ private projectProcedures;
17
19
  constructor(options?: ExecutorOptions);
18
20
  initialize(): Promise<void>;
19
21
  cleanup(): Promise<void>;
@@ -34,5 +36,9 @@ export declare class TestExecutor {
34
36
  private resolveParams;
35
37
  private loadDataFromFile;
36
38
  private resolveVariableDefaults;
39
+ /**
40
+ * Resolve ${variable} placeholders in a string using context variables and currentData
41
+ */
42
+ private static resolveVariablePlaceholders;
37
43
  private createLogger;
38
44
  }
@@ -93,6 +93,7 @@ class TestExecutor {
93
93
  this.context = null;
94
94
  this.page = null;
95
95
  this.plugins = new Map();
96
+ this.projectProcedures = new Map();
96
97
  this.options = {
97
98
  headless: true,
98
99
  timeout: 30000,
@@ -104,6 +105,13 @@ class TestExecutor {
104
105
  this.plugins.set(plugin.name, plugin);
105
106
  });
106
107
  }
108
+ // Register project-level procedures from options
109
+ if (options.procedures) {
110
+ for (const [name, procedure] of Object.entries(options.procedures)) {
111
+ this.projectProcedures.set(name, procedure);
112
+ }
113
+ this.registerCustomBlocksFromProcedures(options.procedures);
114
+ }
107
115
  }
108
116
  async initialize() {
109
117
  // Set the test ID attribute globally for Playwright selectors
@@ -169,10 +177,17 @@ class TestExecutor {
169
177
  await this.initialize();
170
178
  }
171
179
  // Create shared execution context for lifecycle hooks
180
+ // Merge project-level and file-level procedures (file takes precedence)
181
+ const mergedProcedures = new Map(this.projectProcedures);
182
+ if (testFile.procedures) {
183
+ for (const [name, proc] of Object.entries(testFile.procedures)) {
184
+ mergedProcedures.set(name, proc);
185
+ }
186
+ }
172
187
  const sharedContext = {
173
188
  variables: new Map(Object.entries({
174
189
  ...this.resolveVariableDefaults(testFile.variables),
175
- ...this.options.variables,
190
+ ...this.resolveVariableDefaults(this.options.variables),
176
191
  })),
177
192
  results: [],
178
193
  browser: this.browser,
@@ -180,7 +195,9 @@ class TestExecutor {
180
195
  logger: this.createLogger(),
181
196
  plugins: this.plugins,
182
197
  testIdAttribute: this.options.testIdAttribute,
198
+ procedures: mergedProcedures,
183
199
  };
200
+ let beforeAllFailed = false;
184
201
  try {
185
202
  // Run beforeAll hooks
186
203
  if (testFile.beforeAll && testFile.beforeAll.length > 0) {
@@ -188,29 +205,49 @@ class TestExecutor {
188
205
  const beforeAllResult = await this.runLifecycleSteps(testFile.beforeAll, 'beforeAll', sharedContext);
189
206
  results.push(beforeAllResult);
190
207
  if (beforeAllResult.status === 'failed' || beforeAllResult.status === 'error') {
191
- // Don't run tests if beforeAll failed
192
- return results;
208
+ // Don't run tests if beforeAll failed, but still run afterAll
209
+ beforeAllFailed = true;
193
210
  }
194
211
  }
195
- // Run each test with beforeEach/afterEach
196
- for (const test of testFile.tests) {
197
- // Load data from file if specified
198
- let testData = test.data;
199
- if (test.dataFile && !testData) {
200
- testData = this.loadDataFromFile(test.dataFile);
201
- }
202
- // Check if test has data-driven sets
203
- if (testData && testData.length > 0) {
204
- // Run test for each data set
205
- for (let i = 0; i < testData.length; i++) {
206
- const dataSet = testData[i];
212
+ // Only run tests if beforeAll succeeded
213
+ if (!beforeAllFailed) {
214
+ // Run each test with beforeEach/afterEach
215
+ for (const test of testFile.tests) {
216
+ // Load data from file if specified
217
+ let testData = test.data;
218
+ if (test.dataFile && !testData) {
219
+ testData = this.loadDataFromFile(test.dataFile);
220
+ }
221
+ // Check if test has data-driven sets
222
+ if (testData && testData.length > 0) {
223
+ // Run test for each data set
224
+ for (let i = 0; i < testData.length; i++) {
225
+ const dataSet = testData[i];
226
+ // Run suite-level beforeEach
227
+ if (testFile.beforeEach && testFile.beforeEach.length > 0) {
228
+ for (const step of testFile.beforeEach) {
229
+ await this.runStep(step, sharedContext);
230
+ }
231
+ }
232
+ const result = await this.runTestWithData(test, testFile.variables, dataSet, i, sharedContext);
233
+ results.push(result);
234
+ // Run suite-level afterEach
235
+ if (testFile.afterEach && testFile.afterEach.length > 0) {
236
+ for (const step of testFile.afterEach) {
237
+ await this.runStep(step, sharedContext);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ else {
243
+ // Run test once without data
207
244
  // Run suite-level beforeEach
208
245
  if (testFile.beforeEach && testFile.beforeEach.length > 0) {
209
246
  for (const step of testFile.beforeEach) {
210
247
  await this.runStep(step, sharedContext);
211
248
  }
212
249
  }
213
- const result = await this.runTestWithData(test, testFile.variables, dataSet, i, sharedContext);
250
+ const result = await this.runTest(test, testFile.variables, sharedContext);
214
251
  results.push(result);
215
252
  // Run suite-level afterEach
216
253
  if (testFile.afterEach && testFile.afterEach.length > 0) {
@@ -220,32 +257,37 @@ class TestExecutor {
220
257
  }
221
258
  }
222
259
  }
223
- else {
224
- // Run test once without data
225
- // Run suite-level beforeEach
226
- if (testFile.beforeEach && testFile.beforeEach.length > 0) {
227
- for (const step of testFile.beforeEach) {
228
- await this.runStep(step, sharedContext);
229
- }
230
- }
231
- const result = await this.runTest(test, testFile.variables, sharedContext);
232
- results.push(result);
233
- // Run suite-level afterEach
234
- if (testFile.afterEach && testFile.afterEach.length > 0) {
235
- for (const step of testFile.afterEach) {
236
- await this.runStep(step, sharedContext);
237
- }
238
- }
239
- }
240
260
  }
241
- // Run afterAll hooks
261
+ }
262
+ finally {
263
+ // Always run afterAll hooks, even if beforeAll failed
264
+ // This ensures cleanup happens regardless of setup failures
242
265
  if (testFile.afterAll && testFile.afterAll.length > 0) {
243
266
  sharedContext.logger.info('Running afterAll hooks...');
244
- const afterAllResult = await this.runLifecycleSteps(testFile.afterAll, 'afterAll', sharedContext);
245
- results.push(afterAllResult);
267
+ try {
268
+ const afterAllResult = await this.runLifecycleSteps(testFile.afterAll, 'afterAll', sharedContext);
269
+ results.push(afterAllResult);
270
+ }
271
+ catch (afterAllError) {
272
+ // Log but don't throw - we still want cleanup to complete
273
+ sharedContext.logger.error('afterAll hook failed', afterAllError.message);
274
+ results.push({
275
+ testId: 'lifecycle-afterAll',
276
+ testName: 'afterAll',
277
+ status: 'error',
278
+ duration: 0,
279
+ steps: [],
280
+ error: {
281
+ message: afterAllError.message,
282
+ stack: afterAllError.stack,
283
+ },
284
+ startedAt: new Date().toISOString(),
285
+ finishedAt: new Date().toISOString(),
286
+ isLifecycle: true,
287
+ lifecycleType: 'afterAll',
288
+ });
289
+ }
246
290
  }
247
- }
248
- finally {
249
291
  await this.cleanup();
250
292
  }
251
293
  return results;
@@ -285,7 +327,9 @@ class TestExecutor {
285
327
  this.registerCustomBlocksFromProcedures(procedures);
286
328
  }
287
329
  registerCustomBlocksFromProcedures(procedures) {
288
- Object.values(procedures).forEach(proc => {
330
+ Object.entries(procedures).forEach(([name, proc]) => {
331
+ // Register in the procedure registry so getProcedure() can find it
332
+ (0, core_1.registerProcedure)(name, proc);
289
333
  if (!proc.steps || proc.steps.length === 0)
290
334
  return;
291
335
  const blockType = `custom_${proc.name.toLowerCase().replace(/\s+/g, '_')}`;
@@ -308,10 +352,20 @@ class TestExecutor {
308
352
  execute: async (params, context) => {
309
353
  context.logger.info(`Executing custom block: ${proc.name}`);
310
354
  // Set procedure parameters in context.variables so ${paramName} references work
355
+ // Resolve any ${variable} placeholders in the parameter values first
311
356
  (proc.params || []).forEach(p => {
312
- const value = params[p.name.toUpperCase()];
357
+ const paramKey = p.name.toUpperCase();
358
+ let value = params[paramKey];
313
359
  if (value !== undefined) {
360
+ // Resolve variable placeholders like ${email} from context/currentData
361
+ if (typeof value === 'string') {
362
+ value = TestExecutor.resolveVariablePlaceholders(value, context);
363
+ }
314
364
  context.variables.set(p.name, value);
365
+ context.logger.debug(`Set procedure param: ${p.name} = "${value}"`);
366
+ }
367
+ else {
368
+ context.logger.warn(`Procedure param not found in params: ${paramKey} (available: ${Object.keys(params).join(', ')})`);
315
369
  }
316
370
  });
317
371
  return {
@@ -333,7 +387,7 @@ class TestExecutor {
333
387
  ? Object.fromEntries(sharedContext.variables)
334
388
  : {
335
389
  ...this.resolveVariableDefaults(fileVariables),
336
- ...this.options.variables,
390
+ ...this.resolveVariableDefaults(this.options.variables),
337
391
  };
338
392
  const context = {
339
393
  variables: new Map(Object.entries(baseVariables)),
@@ -343,6 +397,11 @@ class TestExecutor {
343
397
  logger: this.createLogger(),
344
398
  plugins: this.plugins,
345
399
  testIdAttribute: this.options.testIdAttribute,
400
+ // Inherit procedures from shared context
401
+ procedures: sharedContext?.procedures || new Map(),
402
+ // Enable soft assertions if configured on the test
403
+ softAssertions: test.softAssertions || false,
404
+ softAssertionErrors: [],
346
405
  };
347
406
  // Run beforeTest hooks
348
407
  for (const plugin of this.plugins.values()) {
@@ -352,6 +411,7 @@ class TestExecutor {
352
411
  }
353
412
  let testStatus = 'passed';
354
413
  let testError;
414
+ let collectedSoftErrors = [];
355
415
  try {
356
416
  // Execute steps from Blockly serialization format
357
417
  const steps = this.extractStepsFromBlocklyState(test.steps);
@@ -361,9 +421,21 @@ class TestExecutor {
361
421
  if (stepResult.status === 'failed' || stepResult.status === 'error') {
362
422
  testStatus = stepResult.status;
363
423
  testError = stepResult.error;
364
- break;
424
+ // In soft assertion mode, continue executing remaining steps
425
+ if (!context.softAssertions) {
426
+ break;
427
+ }
365
428
  }
366
429
  }
430
+ // Check for soft assertion errors at the end of the test
431
+ collectedSoftErrors = context.softAssertionErrors || [];
432
+ if (collectedSoftErrors.length > 0 && testStatus === 'passed') {
433
+ testStatus = 'failed';
434
+ const errorMessages = collectedSoftErrors.map((e, i) => ` ${i + 1}. ${e.message}`).join('\n');
435
+ testError = {
436
+ message: `${collectedSoftErrors.length} soft assertion(s) failed:\n${errorMessages}`,
437
+ };
438
+ }
367
439
  }
368
440
  catch (error) {
369
441
  testStatus = 'error';
@@ -381,6 +453,7 @@ class TestExecutor {
381
453
  error: testError,
382
454
  startedAt,
383
455
  finishedAt: new Date().toISOString(),
456
+ softAssertionErrors: collectedSoftErrors.length > 0 ? collectedSoftErrors : undefined,
384
457
  };
385
458
  // Run afterTest hooks
386
459
  for (const plugin of this.plugins.values()) {
@@ -403,7 +476,7 @@ class TestExecutor {
403
476
  ? Object.fromEntries(sharedContext.variables)
404
477
  : {
405
478
  ...this.resolveVariableDefaults(fileVariables),
406
- ...this.options.variables,
479
+ ...this.resolveVariableDefaults(this.options.variables),
407
480
  };
408
481
  const context = {
409
482
  variables: new Map(Object.entries(baseVariables)),
@@ -413,8 +486,13 @@ class TestExecutor {
413
486
  logger: this.createLogger(),
414
487
  plugins: this.plugins,
415
488
  testIdAttribute: this.options.testIdAttribute,
489
+ // Inherit procedures from shared context
490
+ procedures: sharedContext?.procedures || new Map(),
416
491
  currentData: dataSet,
417
492
  dataIndex,
493
+ // Enable soft assertions if configured on the test
494
+ softAssertions: test.softAssertions || false,
495
+ softAssertionErrors: [],
418
496
  };
419
497
  // Inject data values into variables
420
498
  for (const [key, value] of Object.entries(dataSet.values)) {
@@ -428,6 +506,7 @@ class TestExecutor {
428
506
  }
429
507
  let testStatus = 'passed';
430
508
  let testError;
509
+ let collectedSoftErrors = [];
431
510
  try {
432
511
  // Execute steps from Blockly serialization format
433
512
  const steps = this.extractStepsFromBlocklyState(test.steps);
@@ -437,9 +516,21 @@ class TestExecutor {
437
516
  if (stepResult.status === 'failed' || stepResult.status === 'error') {
438
517
  testStatus = stepResult.status;
439
518
  testError = stepResult.error;
440
- break;
519
+ // In soft assertion mode, continue executing remaining steps
520
+ if (!context.softAssertions) {
521
+ break;
522
+ }
441
523
  }
442
524
  }
525
+ // Check for soft assertion errors at the end of the test
526
+ collectedSoftErrors = context.softAssertionErrors || [];
527
+ if (collectedSoftErrors.length > 0 && testStatus === 'passed') {
528
+ testStatus = 'failed';
529
+ const errorMessages = collectedSoftErrors.map((e, i) => ` ${i + 1}. ${e.message}`).join('\n');
530
+ testError = {
531
+ message: `${collectedSoftErrors.length} soft assertion(s) failed:\n${errorMessages}`,
532
+ };
533
+ }
443
534
  }
444
535
  catch (error) {
445
536
  testStatus = 'error';
@@ -457,6 +548,7 @@ class TestExecutor {
457
548
  error: testError,
458
549
  startedAt,
459
550
  finishedAt: new Date().toISOString(),
551
+ softAssertionErrors: collectedSoftErrors.length > 0 ? collectedSoftErrors : undefined,
460
552
  };
461
553
  // Run afterTest hooks
462
554
  for (const plugin of this.plugins.values()) {
@@ -577,6 +669,29 @@ class TestExecutor {
577
669
  }
578
670
  }
579
671
  }
672
+ // Handle procedure calls (procedure_call block)
673
+ if (output && typeof output === 'object' && 'procedureCall' in output) {
674
+ const procOutput = output;
675
+ // Set procedure arguments as variables (resolve any ${var} placeholders first)
676
+ for (const [argName, argValue] of Object.entries(procOutput.args)) {
677
+ let resolvedValue = argValue;
678
+ if (typeof argValue === 'string') {
679
+ resolvedValue = TestExecutor.resolveVariablePlaceholders(argValue, context);
680
+ }
681
+ context.variables.set(argName, resolvedValue);
682
+ }
683
+ // Run the procedure's steps
684
+ if (procOutput.procedure.steps) {
685
+ for (const childStep of procOutput.procedure.steps) {
686
+ const childResult = await this.runStep(childStep, context);
687
+ if (childResult.status === 'failed' || childResult.status === 'error') {
688
+ status = childResult.status;
689
+ error = childResult.error;
690
+ break;
691
+ }
692
+ }
693
+ }
694
+ }
580
695
  }
581
696
  catch (err) {
582
697
  status = 'failed';
@@ -618,13 +733,20 @@ class TestExecutor {
618
733
  async resolveParams(params, context) {
619
734
  const resolved = {};
620
735
  for (const [key, value] of Object.entries(params)) {
621
- if (value && typeof value === 'object' && 'type' in value) {
622
- // This is a connected block - execute it to get the value
736
+ if (value && typeof value === 'object' && 'type' in value && 'id' in value) {
737
+ // This is a connected block (has both 'type' and 'id') - execute it to get the value
623
738
  const nestedStep = value;
624
739
  const blockDef = (0, core_1.getBlock)(nestedStep.type);
625
740
  if (blockDef) {
626
741
  const nestedParams = await this.resolveParams(nestedStep.params || {}, context);
627
- resolved[key] = await blockDef.execute(nestedParams, context);
742
+ const result = await blockDef.execute(nestedParams, context);
743
+ // Value blocks return { _value: actualValue, ... } - extract the actual value
744
+ if (result && typeof result === 'object' && '_value' in result) {
745
+ resolved[key] = result._value;
746
+ }
747
+ else {
748
+ resolved[key] = result;
749
+ }
628
750
  }
629
751
  }
630
752
  else {
@@ -683,6 +805,44 @@ class TestExecutor {
683
805
  }
684
806
  return resolved;
685
807
  }
808
+ /**
809
+ * Resolve ${variable} placeholders in a string using context variables and currentData
810
+ */
811
+ static resolveVariablePlaceholders(text, context) {
812
+ if (typeof text !== 'string')
813
+ return text;
814
+ return text.replace(/\$\{([\w.]+)\}/g, (match, path) => {
815
+ const parts = path.split('.');
816
+ const varName = parts[0];
817
+ // Check currentData first (for data-driven tests)
818
+ if (context.currentData?.values[varName] !== undefined) {
819
+ let value = context.currentData.values[varName];
820
+ // Handle dot notation for nested access
821
+ if (parts.length > 1 && value !== null && typeof value === 'object') {
822
+ for (let i = 1; i < parts.length; i++) {
823
+ if (value === undefined || value === null)
824
+ break;
825
+ value = value[parts[i]];
826
+ }
827
+ }
828
+ if (value !== undefined && value !== null) {
829
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
830
+ }
831
+ }
832
+ // Then check context variables
833
+ let value = context.variables.get(varName);
834
+ if (parts.length > 1 && value !== undefined && value !== null) {
835
+ for (let i = 1; i < parts.length; i++) {
836
+ if (value === undefined || value === null)
837
+ break;
838
+ value = value[parts[i]];
839
+ }
840
+ }
841
+ if (value === undefined || value === null)
842
+ return match;
843
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
844
+ });
845
+ }
686
846
  createLogger() {
687
847
  return {
688
848
  info: (message, data) => {
@@ -5,9 +5,10 @@
5
5
  * - globals.json - Shared variables across all test files
6
6
  * - snippets/ - Reusable block sequences (composite blocks)
7
7
  */
8
- import { TestStep } from '../core';
8
+ import { TestStep, ProcedureDefinition } from '../core';
9
9
  export interface GlobalsConfig {
10
10
  variables?: Record<string, unknown>;
11
+ procedures?: Record<string, ProcedureDefinition>;
11
12
  baseUrl?: string;
12
13
  timeout?: number;
13
14
  testIdAttribute?: string;
@@ -47,6 +48,10 @@ export declare function getGlobals(): GlobalsConfig;
47
48
  * Get global variables
48
49
  */
49
50
  export declare function getGlobalVariables(): Record<string, unknown>;
51
+ /**
52
+ * Get global procedures from globals.json
53
+ */
54
+ export declare function getGlobalProcedures(): Record<string, ProcedureDefinition>;
50
55
  /**
51
56
  * Get the configured test ID attribute (defaults to 'data-testid')
52
57
  */
@@ -45,6 +45,7 @@ exports.getGlobalsDirectory = getGlobalsDirectory;
45
45
  exports.loadGlobals = loadGlobals;
46
46
  exports.getGlobals = getGlobals;
47
47
  exports.getGlobalVariables = getGlobalVariables;
48
+ exports.getGlobalProcedures = getGlobalProcedures;
48
49
  exports.getTestIdAttribute = getTestIdAttribute;
49
50
  exports.setTestIdAttribute = setTestIdAttribute;
50
51
  exports.discoverSnippets = discoverSnippets;
@@ -105,6 +106,12 @@ function getGlobals() {
105
106
  function getGlobalVariables() {
106
107
  return loadedGlobals.variables || {};
107
108
  }
109
+ /**
110
+ * Get global procedures from globals.json
111
+ */
112
+ function getGlobalProcedures() {
113
+ return loadedGlobals.procedures || {};
114
+ }
108
115
  /**
109
116
  * Get the configured test ID attribute (defaults to 'data-testid')
110
117
  */