testchimp-runner-core 0.1.32 → 0.1.34

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.
@@ -59,6 +59,9 @@ const orchestrator_1 = require("./orchestrator");
59
59
  const test_file_parser_1 = require("./utils/test-file-parser");
60
60
  const dotenv = __importStar(require("dotenv"));
61
61
  class ModuleResolutionManager {
62
+ static setFallbackContext(ctx) {
63
+ this.fallbackContext = ctx;
64
+ }
62
65
  /**
63
66
  * Install the global hook (only once, shared by all instances)
64
67
  */
@@ -88,8 +91,9 @@ class ModuleResolutionManager {
88
91
  }
89
92
  // Install hook that uses AsyncLocalStorage to get the current tempDir and testFileDir
90
93
  Module._resolveFilename = function (request, parent, isMain, options) {
91
- // Get resolution context for current execution
92
- const context = ModuleResolutionManager.asyncLocalStorage.getStore();
94
+ // Get resolution context: AsyncLocalStorage first, then fallback (for require() from inside vm.runInContext)
95
+ const context = ModuleResolutionManager.asyncLocalStorage.getStore()
96
+ ?? ModuleResolutionManager.fallbackContext;
93
97
  // If no context, use original resolution (for requires outside our execution context)
94
98
  if (!context) {
95
99
  return ModuleResolutionManager.originalResolveFilename.call(this, request, parent, isMain, options);
@@ -222,6 +226,8 @@ class ModuleResolutionManager {
222
226
  ModuleResolutionManager.asyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
223
227
  ModuleResolutionManager.hookInstalled = false;
224
228
  ModuleResolutionManager.scriptserviceNodeModules = null;
229
+ /** Fallback when require runs inside vm.runInContext and AsyncLocalStorage is not visible */
230
+ ModuleResolutionManager.fallbackContext = null;
225
231
  /**
226
232
  * Persistent execution context using vm.createContext
227
233
  * Maintains variable scope across multiple step executions
@@ -356,9 +362,16 @@ class PersistentExecutionContext {
356
362
  if (!this.context) {
357
363
  throw new Error('Context has been disposed');
358
364
  }
359
- const script = new vm.Script(wrappedCode);
360
- const result = script.runInContext(this.context);
361
- await result;
365
+ // Ensure require() from inside VM sees context even if AsyncLocalStorage is lost there
366
+ ModuleResolutionManager.setFallbackContext(context);
367
+ try {
368
+ const script = new vm.Script(wrappedCode);
369
+ const result = script.runInContext(this.context);
370
+ await result;
371
+ }
372
+ finally {
373
+ ModuleResolutionManager.setFallbackContext(null);
374
+ }
362
375
  });
363
376
  }
364
377
  else {
@@ -1993,525 +2006,601 @@ class ExecutionService {
1993
2006
  applyFileUploadOverrides(page, request.tempDir, request.testFolderPath, (msg, level) => this.log(msg, level));
1994
2007
  this.log(`Applied file upload overrides with tempDir: ${request.tempDir}`);
1995
2008
  }
1996
- // Create suite context manager - one context per suite, shared across all hooks and tests
1997
- const suiteContexts = new Map();
1998
- // Helper to get or create suite context
1999
- const getOrCreateSuiteContext = async (suitePath, suiteVariables, fileVariables) => {
2000
- const suiteKey = JSON.stringify(suitePath);
2001
- if (!suiteContexts.has(suiteKey)) {
2002
- const { expect, test } = require('@playwright/test');
2003
- const { ai } = require('ai-wright');
2004
- const suiteContext = new PersistentExecutionContext(page, expect, test, ai, browser, context, request.tempDir);
2005
- suiteContexts.set(suiteKey, { context: suiteContext, variablesInitialized: false });
2006
- }
2007
- const suiteContextData = suiteContexts.get(suiteKey);
2008
- if (!suiteContextData.variablesInitialized) {
2009
- try {
2010
- // Set testFileDir on context for module resolution (needed before executing requires)
2011
- // Use testFolderPath from request if available, otherwise default to tempDir/tests
2012
- const testFileDir = request.testFolderPath && request.testFolderPath.length > 0
2013
- ? path.join(request.tempDir, ...request.testFolderPath)
2014
- : path.join(request.tempDir, 'tests');
2015
- suiteContextData.context.setTestFileDir(testFileDir);
2016
- // Load .env inside VM before any user code so process.env is available in test and POM code
2017
- if (request.tempDir) {
2018
- await suiteContextData.context.executeCode(getEnvBootstrapCode());
2019
- }
2020
- // Execute imports from originalScript (for module resolution)
2021
- if (originalScript && request.tempDir) {
2022
- const importStatements = import_utils_1.ImportUtils.extractImportStatements(originalScript, (msg) => this.log(msg));
2023
- if (importStatements.length > 0) {
2024
- const importsCode = import_utils_1.ImportUtils.convertImportsToRequires(importStatements, (msg) => this.log(msg));
2025
- if (importsCode) {
2026
- await suiteContextData.context.executeCode(importsCode);
2027
- }
2028
- }
2029
- }
2030
- // Initialize file variables (parent scope) - order: file variables → suite variables
2031
- if (fileVariables.length > 0) {
2032
- const fileVarsCode = fileVariables.join('\n');
2033
- await suiteContextData.context.executeCode(fileVarsCode);
2034
- }
2035
- // Initialize suite variables (child scope)
2036
- if (suiteVariables.length > 0) {
2037
- const suiteVarsCode = suiteVariables.join('\n');
2038
- await suiteContextData.context.executeCode(suiteVarsCode);
2039
- }
2040
- suiteContextData.variablesInitialized = true;
2041
- }
2042
- catch (error) {
2043
- const errorMsg = error instanceof Error ? error.message : String(error);
2044
- this.log(`Variable initialization failed for suite ${suitePath.join('__')}: ${errorMsg}`, 'warn');
2045
- // Continue - variables may be optional
2046
- }
2047
- }
2048
- return suiteContextData.context;
2049
- };
2050
- // Helper to dispose suite context
2051
- const disposeSuiteContext = (suitePath) => {
2052
- const suiteKey = JSON.stringify(suitePath);
2053
- const suiteContextData = suiteContexts.get(suiteKey);
2054
- if (suiteContextData) {
2055
- suiteContextData.context.dispose();
2056
- suiteContexts.delete(suiteKey);
2009
+ const testFileDir = request.testFolderPath && request.testFolderPath.length > 0
2010
+ ? path.join(request.tempDir, ...request.testFolderPath)
2011
+ : path.join(request.tempDir, 'tests');
2012
+ // Clear require cache so page/test files are always loaded fresh (avoids stale POM/page cache across runs).
2013
+ // Clear (1) current run's tempDir in both normalized and realpath form, and (2) ALL workspace dirs under
2014
+ // /tmp/testchimp (and /private/tmp/testchimp on macOS) so no prior run can serve a cached module.
2015
+ const tempDirNorm = path.normalize(request.tempDir) + path.sep;
2016
+ let tempDirRealNorm = null;
2017
+ try {
2018
+ const rp = fs.realpathSync(request.tempDir);
2019
+ tempDirRealNorm = path.normalize(rp) + path.sep;
2020
+ }
2021
+ catch (_) {
2022
+ // ignore
2023
+ }
2024
+ const testchimpPrefixes = [];
2025
+ try {
2026
+ testchimpPrefixes.push(path.normalize('/tmp/testchimp') + path.sep);
2027
+ const rp = fs.realpathSync('/tmp/testchimp');
2028
+ if (rp !== '/tmp/testchimp') {
2029
+ testchimpPrefixes.push(path.normalize(rp) + path.sep);
2057
2030
  }
2058
- };
2059
- // Create file-level shared context for file-level hooks
2060
- let fileLevelContext = null;
2061
- const initializeFileLevelContext = async () => {
2062
- if (!fileLevelContext && (flattened.fileVariables.length > 0 || flattened.fileLevelHooks.beforeAll.length > 0)) {
2063
- const { expect, test } = require('@playwright/test');
2064
- const { ai } = require('ai-wright');
2065
- fileLevelContext = new PersistentExecutionContext(page, expect, test, ai, browser, context, request.tempDir);
2031
+ }
2032
+ catch (_) {
2033
+ // /tmp/testchimp may not exist yet
2034
+ }
2035
+ const cacheKeys = Object.keys(require.cache);
2036
+ let cleared = 0;
2037
+ for (const id of cacheKeys) {
2038
+ try {
2039
+ const normalized = path.normalize(id);
2040
+ let idRealNorm = null;
2066
2041
  try {
2067
- // Set testFileDir on context for module resolution (needed before executing requires)
2068
- // Use testFolderPath from request if available, otherwise default to tempDir/tests
2069
- const testFileDir = request.testFolderPath && request.testFolderPath.length > 0
2070
- ? path.join(request.tempDir, ...request.testFolderPath)
2071
- : path.join(request.tempDir, 'tests');
2072
- fileLevelContext.setTestFileDir(testFileDir);
2073
- // Load .env inside VM before any user code so process.env is available in test and POM code
2074
- if (request.tempDir) {
2075
- await fileLevelContext.executeCode(getEnvBootstrapCode());
2076
- }
2077
- // Execute imports from originalScript (for module resolution)
2078
- if (originalScript && request.tempDir) {
2079
- const importStatements = import_utils_1.ImportUtils.extractImportStatements(originalScript, (msg) => this.log(msg));
2080
- if (importStatements.length > 0) {
2081
- const importsCode = import_utils_1.ImportUtils.convertImportsToRequires(importStatements, (msg) => this.log(msg));
2082
- if (importsCode) {
2083
- await fileLevelContext.executeCode(importsCode);
2084
- }
2085
- }
2086
- }
2087
- // Initialize file-level variables
2088
- if (flattened.fileVariables.length > 0) {
2089
- const fileVarsCode = flattened.fileVariables.join('\n');
2090
- await fileLevelContext.executeCode(fileVarsCode);
2091
- }
2042
+ idRealNorm = path.normalize(fs.realpathSync(id));
2092
2043
  }
2093
- catch (error) {
2094
- const errorMsg = error instanceof Error ? error.message : String(error);
2095
- this.log(`File-level variable initialization failed: ${errorMsg}`, 'warn');
2096
- // Continue - variables may be optional
2044
+ catch (_) {
2045
+ // file may not exist
2097
2046
  }
2098
- }
2099
- };
2100
- // Initialize file-level context before file-level hooks
2101
- await initializeFileLevelContext();
2102
- // Execute file-level beforeAll hooks
2103
- if (flattened.fileLevelHooks.beforeAll.length > 0) {
2104
- this.log(`Executing ${flattened.fileLevelHooks.beforeAll.length} file-level beforeAll hook(s)...`);
2105
- try {
2106
- for (const hook of flattened.fileLevelHooks.beforeAll) {
2107
- await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook);
2047
+ const matchCurrent = normalized.startsWith(tempDirNorm) ||
2048
+ normalized === path.normalize(request.tempDir) ||
2049
+ (tempDirRealNorm != null && (normalized.startsWith(tempDirRealNorm) || normalized === tempDirRealNorm.slice(0, -1))) ||
2050
+ (idRealNorm != null && tempDirRealNorm != null && idRealNorm.startsWith(tempDirRealNorm));
2051
+ const matchAnyWorkspace = testchimpPrefixes.some((prefix) => normalized.startsWith(prefix) ||
2052
+ (idRealNorm != null && idRealNorm.startsWith(prefix)));
2053
+ if (matchCurrent || matchAnyWorkspace) {
2054
+ delete require.cache[id];
2055
+ cleared++;
2108
2056
  }
2109
- this.log('File-level beforeAll hooks executed successfully');
2110
2057
  }
2111
- catch (error) {
2112
- const errorMsg = error instanceof Error ? error.message : String(error);
2113
- this.log(`File-level beforeAll hook failed: ${errorMsg}`, 'error');
2114
- // Cleanup browser if we created it
2115
- if (browserCreated && browser) {
2116
- try {
2117
- await browser.close();
2118
- }
2119
- catch (closeError) {
2120
- // Ignore close errors
2121
- }
2058
+ catch (_) {
2059
+ // ignore
2060
+ }
2061
+ }
2062
+ if (cleared > 0) {
2063
+ this.log(`Cleared ${cleared} require.cache entries for workspace(s) to avoid stale page/module cache`);
2064
+ }
2065
+ // Clear Node's Module._pathCache so resolution is not short-circuited by a cached path from a previous run
2066
+ const Mod = require('module');
2067
+ if (typeof Mod._pathCache === 'object' && Mod._pathCache !== null) {
2068
+ const pathCache = Mod._pathCache;
2069
+ const pathCacheKeys = Object.keys(pathCache);
2070
+ let pathCacheCleared = 0;
2071
+ for (const key of pathCacheKeys) {
2072
+ const keyStr = String(key);
2073
+ if (keyStr.includes(path.normalize('/tmp/testchimp')) ||
2074
+ (tempDirRealNorm != null && keyStr.includes(tempDirRealNorm.slice(0, -1)))) {
2075
+ delete pathCache[key];
2076
+ pathCacheCleared++;
2122
2077
  }
2123
- return {
2124
- success: false,
2125
- testResults: [],
2126
- executionTime: Date.now() - startTime,
2127
- error: `File-level beforeAll hook failed: ${errorMsg}`
2128
- };
2129
2078
  }
2130
- }
2131
- // Helper function to create a unique key from suite path (avoids collisions if suite names contain '__')
2132
- const getSuiteKey = (suitePath) => {
2133
- // Use JSON.stringify to safely serialize the array, avoiding collisions
2134
- return JSON.stringify(suitePath);
2135
- };
2136
- // Build a Map of suites by key for O(1) lookups (instead of O(n) .find() calls)
2137
- const suiteMap = new Map();
2138
- for (const suite of flattened.suites) {
2139
- const suiteKey = getSuiteKey(suite.suitePath);
2140
- suiteMap.set(suiteKey, suite);
2141
- }
2142
- // Track suite execution state (which suites have had beforeAll executed)
2143
- const suiteBeforeAllExecuted = new Set();
2144
- // Track test counts per suite (for determining last test in suite)
2145
- const suiteTestCounts = new Map();
2146
- const suiteTotalTests = new Map();
2147
- // Initialize suite test counts
2148
- for (const suite of flattened.suites) {
2149
- const suiteKey = getSuiteKey(suite.suitePath);
2150
- suiteTotalTests.set(suiteKey, suite.testIndices.length);
2151
- suiteTestCounts.set(suiteKey, 0);
2152
- }
2153
- // Execute each test
2154
- for (let testIndex = 0; testIndex < flattened.tests.length; testIndex++) {
2155
- const testData = flattened.tests[testIndex];
2156
- const test = testData.test;
2157
- const testStartTime = Date.now();
2158
- // Use pre-assigned jobId when caller provides one (e.g. batch pre-create) and we have exactly one test
2159
- const jobId = (request.jobId && flattened.tests.length === 1)
2160
- ? request.jobId
2161
- : crypto.randomUUID();
2162
- let testStatus = 'passed';
2163
- let testError;
2164
- try {
2165
- // Before test execution: Execute suite-level beforeAll hooks (if first test in suite)
2166
- if (testData.suitePath.length > 0) {
2167
- // Execute beforeAll for each suite in the path (in order: parent → child)
2168
- for (let i = 0; i < testData.suitePath.length; i++) {
2169
- const suitePath = testData.suitePath.slice(0, i + 1);
2170
- const suiteKey = getSuiteKey(suitePath);
2171
- // Look up suite in Map (O(1) instead of O(n) .find())
2172
- const suite = suiteMap.get(suiteKey);
2173
- if (suite && suite.beforeAll.length > 0 && !suiteBeforeAllExecuted.has(suiteKey)) {
2174
- // Use human-readable suite name for logging
2175
- const suiteDisplayName = suitePath.join('__');
2176
- this.log(`Executing suite-level beforeAll hooks for suite: ${suiteDisplayName}`);
2177
- try {
2178
- // Get or create suite context (includes file variables + suite variables)
2179
- const suiteContext = await getOrCreateSuiteContext(suitePath, suite.suiteVariables, flattened.fileVariables);
2180
- for (const hook of suite.beforeAll) {
2181
- await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook);
2079
+ if (pathCacheCleared > 0) {
2080
+ this.log(`Cleared ${pathCacheCleared} Module._pathCache entries for workspace(s)`);
2081
+ }
2082
+ }
2083
+ return await ModuleResolutionManager.runWithContext({ tempDir: request.tempDir, testFileDir }, async () => {
2084
+ // Create suite context manager - one context per suite, shared across all hooks and tests
2085
+ const suiteContexts = new Map();
2086
+ // Helper to get or create suite context
2087
+ const getOrCreateSuiteContext = async (suitePath, suiteVariables, fileVariables) => {
2088
+ const suiteKey = JSON.stringify(suitePath);
2089
+ if (!suiteContexts.has(suiteKey)) {
2090
+ const { expect, test } = require('@playwright/test');
2091
+ const { ai } = require('ai-wright');
2092
+ const suiteContext = new PersistentExecutionContext(page, expect, test, ai, browser, context, request.tempDir);
2093
+ suiteContexts.set(suiteKey, { context: suiteContext, variablesInitialized: false });
2094
+ }
2095
+ const suiteContextData = suiteContexts.get(suiteKey);
2096
+ if (!suiteContextData.variablesInitialized) {
2097
+ try {
2098
+ // Set testFileDir on context for module resolution (needed before executing requires)
2099
+ // Use testFolderPath from request if available, otherwise default to tempDir/tests
2100
+ const testFileDir = request.testFolderPath && request.testFolderPath.length > 0
2101
+ ? path.join(request.tempDir, ...request.testFolderPath)
2102
+ : path.join(request.tempDir, 'tests');
2103
+ suiteContextData.context.setTestFileDir(testFileDir);
2104
+ // Load .env inside VM before any user code so process.env is available in test and POM code
2105
+ if (request.tempDir) {
2106
+ await suiteContextData.context.executeCode(getEnvBootstrapCode());
2107
+ }
2108
+ // Execute imports from originalScript (for module resolution)
2109
+ if (originalScript && request.tempDir) {
2110
+ const importStatements = import_utils_1.ImportUtils.extractImportStatements(originalScript, (msg) => this.log(msg));
2111
+ if (importStatements.length > 0) {
2112
+ const importsCode = import_utils_1.ImportUtils.convertImportsToRequires(importStatements, (msg) => this.log(msg));
2113
+ if (importsCode) {
2114
+ await suiteContextData.context.executeCode(importsCode);
2182
2115
  }
2183
- suiteBeforeAllExecuted.add(suiteKey);
2184
- this.log(`Suite-level beforeAll hooks executed for suite: ${suiteDisplayName}`);
2185
- }
2186
- catch (error) {
2187
- const errorMsg = error instanceof Error ? error.message : String(error);
2188
- this.log(`Suite-level beforeAll hook failed for suite ${suiteDisplayName}: ${errorMsg}`, 'error');
2189
- testStatus = 'failed';
2190
- testError = `Suite-level beforeAll hook failed: ${errorMsg}`;
2191
2116
  }
2192
2117
  }
2193
- }
2194
- }
2195
- // Call onStartTest callback (use TestPathway)
2196
- if (this.progressReporter?.onStartTest) {
2197
- try {
2198
- await this.progressReporter.onStartTest({ suitePath: test.suitePath || [], testName: test.name }, jobId, page, browser, context);
2199
- }
2200
- catch (callbackError) {
2201
- this.log(`onStartTest callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, 'warn');
2202
- // Don't fail the test if callback has issues - runner-core is a library
2203
- }
2204
- }
2205
- // Track step counter for sequential numeric step IDs across hooks and test
2206
- let testStepCounter = 0;
2207
- // Execute file-level beforeEach hooks
2208
- if (flattened.fileLevelHooks.beforeEach.length > 0 && testStatus === 'passed') {
2209
- this.log(`Executing ${flattened.fileLevelHooks.beforeEach.length} file-level beforeEach hook(s) for test: ${test.fullName}`);
2210
- try {
2211
- for (const hook of flattened.fileLevelHooks.beforeEach) {
2212
- const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook, jobId, testStepCounter);
2213
- testStepCounter += stepsExecuted;
2118
+ // Initialize file variables (parent scope) - order: file variables → suite variables
2119
+ if (fileVariables.length > 0) {
2120
+ const fileVarsCode = fileVariables.join('\n');
2121
+ await suiteContextData.context.executeCode(fileVarsCode);
2214
2122
  }
2123
+ // Initialize suite variables (child scope)
2124
+ if (suiteVariables.length > 0) {
2125
+ const suiteVarsCode = suiteVariables.join('\n');
2126
+ await suiteContextData.context.executeCode(suiteVarsCode);
2127
+ }
2128
+ suiteContextData.variablesInitialized = true;
2215
2129
  }
2216
2130
  catch (error) {
2217
2131
  const errorMsg = error instanceof Error ? error.message : String(error);
2218
- this.log(`File-level beforeEach hook failed for test ${test.fullName}: ${errorMsg}`, 'error');
2219
- testStatus = 'failed';
2220
- testError = `File-level beforeEach hook failed: ${errorMsg}`;
2132
+ this.log(`Variable initialization failed for suite ${suitePath.join('__')}: ${errorMsg}`, 'warn');
2133
+ // Continue - variables may be optional
2221
2134
  }
2222
2135
  }
2223
- // Execute suite-level beforeEach hooks (from all parent suites, in order: parent → child)
2224
- if (testData.suiteBeforeEachHooks.length > 0 && testStatus === 'passed') {
2225
- this.log(`Executing ${testData.suiteBeforeEachHooks.length} suite-level beforeEach hook(s) for test: ${test.fullName}`);
2136
+ return suiteContextData.context;
2137
+ };
2138
+ // Helper to dispose suite context
2139
+ const disposeSuiteContext = (suitePath) => {
2140
+ const suiteKey = JSON.stringify(suitePath);
2141
+ const suiteContextData = suiteContexts.get(suiteKey);
2142
+ if (suiteContextData) {
2143
+ suiteContextData.context.dispose();
2144
+ suiteContexts.delete(suiteKey);
2145
+ }
2146
+ };
2147
+ // Create file-level shared context for file-level hooks
2148
+ let fileLevelContext = null;
2149
+ const initializeFileLevelContext = async () => {
2150
+ if (!fileLevelContext && (flattened.fileVariables.length > 0 || flattened.fileLevelHooks.beforeAll.length > 0)) {
2151
+ const { expect, test } = require('@playwright/test');
2152
+ const { ai } = require('ai-wright');
2153
+ fileLevelContext = new PersistentExecutionContext(page, expect, test, ai, browser, context, request.tempDir);
2226
2154
  try {
2227
- // Get or create suite context for this test's suite
2228
- const suiteContext = await getOrCreateSuiteContext(testData.suitePath, testData.suiteVariables, flattened.fileVariables);
2229
- for (const hook of testData.suiteBeforeEachHooks) {
2230
- const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook, jobId, testStepCounter);
2231
- testStepCounter += stepsExecuted;
2155
+ // Set testFileDir on context for module resolution (needed before executing requires)
2156
+ // Use testFolderPath from request if available, otherwise default to tempDir/tests
2157
+ const testFileDir = request.testFolderPath && request.testFolderPath.length > 0
2158
+ ? path.join(request.tempDir, ...request.testFolderPath)
2159
+ : path.join(request.tempDir, 'tests');
2160
+ fileLevelContext.setTestFileDir(testFileDir);
2161
+ // Load .env inside VM before any user code so process.env is available in test and POM code
2162
+ if (request.tempDir) {
2163
+ await fileLevelContext.executeCode(getEnvBootstrapCode());
2164
+ }
2165
+ // Execute imports from originalScript (for module resolution)
2166
+ if (originalScript && request.tempDir) {
2167
+ const importStatements = import_utils_1.ImportUtils.extractImportStatements(originalScript, (msg) => this.log(msg));
2168
+ if (importStatements.length > 0) {
2169
+ const importsCode = import_utils_1.ImportUtils.convertImportsToRequires(importStatements, (msg) => this.log(msg));
2170
+ if (importsCode) {
2171
+ await fileLevelContext.executeCode(importsCode);
2172
+ }
2173
+ }
2174
+ }
2175
+ // Initialize file-level variables
2176
+ if (flattened.fileVariables.length > 0) {
2177
+ const fileVarsCode = flattened.fileVariables.join('\n');
2178
+ await fileLevelContext.executeCode(fileVarsCode);
2232
2179
  }
2233
2180
  }
2234
2181
  catch (error) {
2235
2182
  const errorMsg = error instanceof Error ? error.message : String(error);
2236
- this.log(`Suite-level beforeEach hook failed for test ${test.fullName}: ${errorMsg}`, 'error');
2237
- testStatus = 'failed';
2238
- testError = `Suite-level beforeEach hook failed: ${errorMsg}`;
2183
+ this.log(`File-level variable initialization failed: ${errorMsg}`, 'warn');
2184
+ // Continue - variables may be optional
2239
2185
  }
2240
2186
  }
2241
- // Run the test if hooks didn't fail
2242
- if (testStatus === 'passed') {
2243
- // Count non-variable statements for step counter tracking (needed in catch block)
2244
- const testStepsExecuted = test.statements.filter(stmt => !stmt.isVariableDeclaration).length;
2245
- try {
2246
- // Construct a script with imports + this test (needed for module resolution)
2247
- // Variables are already in shared context, so we don't need to include them in script
2248
- const testScript = test_file_parser_1.TestFileParser.constructTestScriptWithImports(originalScript, test.name, // Use test name only, not fullName (suite prefix not needed in generated script)
2249
- test.code);
2250
- // Use pre-parsed statements from initial file parse
2251
- // These were extracted directly from the original AST, ensuring accuracy
2252
- this.log(`Using ${test.statements.length} pre-parsed statements for test: ${test.fullName}`);
2253
- // Count non-variable statements for step ID assignment
2254
- let stepIndex = 0; // Track step index (only for non-variable statements)
2255
- const steps = test.statements.map((stmt) => {
2256
- // CRITICAL: Always use hash-based stepId from parser if available
2257
- // Only assign step ID to non-variable statements
2258
- const stepId = stmt.isVariableDeclaration
2259
- ? undefined
2260
- : (stmt.stepId || String(testStepCounter + stepIndex++));
2261
- // Log stepId assignment for debugging
2262
- if (!stmt.isVariableDeclaration) {
2263
- if (stmt.stepId) {
2264
- this.log(`[stepId] Using hash-based stepId from parser: ${stmt.stepId.substring(0, 16)}... (test: ${test.fullName})`);
2265
- }
2266
- else {
2267
- this.log(`[stepId] WARNING: No hash-based stepId from parser, using sequential: ${stepId} (test: ${test.fullName})`);
2268
- }
2269
- }
2270
- return {
2271
- id: stepId,
2272
- code: stmt.code,
2273
- description: stmt.intentComment || stmt.code.trim().substring(0, 100) || '',
2274
- isVariableDeclaration: stmt.isVariableDeclaration, // Pass AST-based detection flag
2275
- scenarioAnnotation: stmt.scenarioAnnotation
2276
- };
2277
- });
2278
- // Get or create suite context for this test's suite
2279
- const suiteContext = await getOrCreateSuiteContext(testData.suitePath, testData.suiteVariables, flattened.fileVariables);
2280
- // Execute using the existing step-wise execution infrastructure
2281
- // executeStepsInPersistentContext will:
2282
- // 1. Use shared context (variables already initialized)
2283
- // 2. Execute ALL statements sequentially (variables + actions)
2284
- // 3. Variables execute silently and persist in context
2285
- // 4. Non-variable statements trigger callbacks (onStepProgress)
2286
- const result = await this.executeStepsInPersistentContext(steps, // Pre-parsed steps from initial parse with AST-based flags
2287
- page, browser, context, {
2288
- mode: request.mode || types_1.ExecutionMode.RUN_EXACTLY,
2289
- jobId: jobId,
2290
- tempDir: request.tempDir,
2291
- originalScript: testScript, // For module resolution (imports)
2292
- model: request.model,
2293
- testFolderPath: request.testFolderPath,
2294
- runPomDenormalized: request.runPomDenormalized
2295
- }, suiteContext // Pass shared context
2296
- );
2297
- if (!result.success) {
2298
- testStatus = 'failed';
2299
- testError = result.error || 'Test execution failed';
2187
+ };
2188
+ // Initialize file-level context before file-level hooks
2189
+ await initializeFileLevelContext();
2190
+ // Execute file-level beforeAll hooks
2191
+ if (flattened.fileLevelHooks.beforeAll.length > 0) {
2192
+ this.log(`Executing ${flattened.fileLevelHooks.beforeAll.length} file-level beforeAll hook(s)...`);
2193
+ try {
2194
+ for (const hook of flattened.fileLevelHooks.beforeAll) {
2195
+ await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook);
2196
+ }
2197
+ this.log('File-level beforeAll hooks executed successfully');
2198
+ }
2199
+ catch (error) {
2200
+ const errorMsg = error instanceof Error ? error.message : String(error);
2201
+ this.log(`File-level beforeAll hook failed: ${errorMsg}`, 'error');
2202
+ // Cleanup browser if we created it
2203
+ if (browserCreated && browser) {
2204
+ try {
2205
+ await browser.close();
2300
2206
  }
2301
- // Track repair if we're in repair mode and steps were updated
2302
- // Note: updatedSteps are always returned, but in repair mode they may contain repaired code
2303
- if (request.mode === types_1.ExecutionMode.RUN_WITH_AI_REPAIR && result.updatedSteps && result.updatedSteps.length > 0) {
2304
- // Generate updated script from steps (will include repairs if any occurred)
2305
- const updatedScript = this.generateUpdatedScript(result.updatedSteps, undefined, testScript);
2306
- if (updatedScript) {
2307
- // Extract the test body from the generated script
2308
- const repairedTestBody = test_file_parser_1.TestFileParser.extractTestBodyFromScript(updatedScript);
2309
- if (repairedTestBody) {
2310
- // Compare with original test body to confirm it's actually different (repairs occurred)
2311
- const originalTestBody = test.code.trim();
2312
- const repairedTestBodyTrimmed = repairedTestBody.trim();
2313
- if (repairedTestBodyTrimmed !== originalTestBody) {
2314
- // Create TestPathway key (JSON stringified for Map lookup)
2315
- const testPathwayKey = JSON.stringify({
2316
- suitePath: test.suitePath || [],
2317
- testName: test.name.trim()
2318
- });
2319
- testRepairs.set(testPathwayKey, repairedTestBody);
2320
- this.log(`Tracked repair for test: ${test.fullName} (pathway: ${testPathwayKey})`);
2207
+ catch (closeError) {
2208
+ // Ignore close errors
2209
+ }
2210
+ }
2211
+ return {
2212
+ success: false,
2213
+ testResults: [],
2214
+ executionTime: Date.now() - startTime,
2215
+ error: `File-level beforeAll hook failed: ${errorMsg}`
2216
+ };
2217
+ }
2218
+ }
2219
+ // Helper function to create a unique key from suite path (avoids collisions if suite names contain '__')
2220
+ const getSuiteKey = (suitePath) => {
2221
+ // Use JSON.stringify to safely serialize the array, avoiding collisions
2222
+ return JSON.stringify(suitePath);
2223
+ };
2224
+ // Build a Map of suites by key for O(1) lookups (instead of O(n) .find() calls)
2225
+ const suiteMap = new Map();
2226
+ for (const suite of flattened.suites) {
2227
+ const suiteKey = getSuiteKey(suite.suitePath);
2228
+ suiteMap.set(suiteKey, suite);
2229
+ }
2230
+ // Track suite execution state (which suites have had beforeAll executed)
2231
+ const suiteBeforeAllExecuted = new Set();
2232
+ // Track test counts per suite (for determining last test in suite)
2233
+ const suiteTestCounts = new Map();
2234
+ const suiteTotalTests = new Map();
2235
+ // Initialize suite test counts
2236
+ for (const suite of flattened.suites) {
2237
+ const suiteKey = getSuiteKey(suite.suitePath);
2238
+ suiteTotalTests.set(suiteKey, suite.testIndices.length);
2239
+ suiteTestCounts.set(suiteKey, 0);
2240
+ }
2241
+ // Execute each test
2242
+ for (let testIndex = 0; testIndex < flattened.tests.length; testIndex++) {
2243
+ const testData = flattened.tests[testIndex];
2244
+ const test = testData.test;
2245
+ const testStartTime = Date.now();
2246
+ // Use pre-assigned jobId when caller provides one (e.g. batch pre-create) and we have exactly one test
2247
+ const jobId = (request.jobId && flattened.tests.length === 1)
2248
+ ? request.jobId
2249
+ : crypto.randomUUID();
2250
+ let testStatus = 'passed';
2251
+ let testError;
2252
+ try {
2253
+ // Before test execution: Execute suite-level beforeAll hooks (if first test in suite)
2254
+ if (testData.suitePath.length > 0) {
2255
+ // Execute beforeAll for each suite in the path (in order: parent → child)
2256
+ for (let i = 0; i < testData.suitePath.length; i++) {
2257
+ const suitePath = testData.suitePath.slice(0, i + 1);
2258
+ const suiteKey = getSuiteKey(suitePath);
2259
+ // Look up suite in Map (O(1) instead of O(n) .find())
2260
+ const suite = suiteMap.get(suiteKey);
2261
+ if (suite && suite.beforeAll.length > 0 && !suiteBeforeAllExecuted.has(suiteKey)) {
2262
+ // Use human-readable suite name for logging
2263
+ const suiteDisplayName = suitePath.join('__');
2264
+ this.log(`Executing suite-level beforeAll hooks for suite: ${suiteDisplayName}`);
2265
+ try {
2266
+ // Get or create suite context (includes file variables + suite variables)
2267
+ const suiteContext = await getOrCreateSuiteContext(suitePath, suite.suiteVariables, flattened.fileVariables);
2268
+ for (const hook of suite.beforeAll) {
2269
+ await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook);
2321
2270
  }
2271
+ suiteBeforeAllExecuted.add(suiteKey);
2272
+ this.log(`Suite-level beforeAll hooks executed for suite: ${suiteDisplayName}`);
2273
+ }
2274
+ catch (error) {
2275
+ const errorMsg = error instanceof Error ? error.message : String(error);
2276
+ this.log(`Suite-level beforeAll hook failed for suite ${suiteDisplayName}: ${errorMsg}`, 'error');
2277
+ testStatus = 'failed';
2278
+ testError = `Suite-level beforeAll hook failed: ${errorMsg}`;
2322
2279
  }
2323
2280
  }
2324
2281
  }
2325
- // Update step counter after test execution (whether success or failure)
2326
- // Steps were executed up to the failure point
2327
- testStepCounter += testStepsExecuted;
2328
2282
  }
2329
- catch (error) {
2330
- testStatus = 'failed';
2331
- testError = error instanceof Error ? error.message : String(error);
2332
- // Still update step counter even if test failed (steps were executed up to failure point)
2333
- testStepCounter += testStepsExecuted;
2283
+ // Call onStartTest callback (use TestPathway)
2284
+ if (this.progressReporter?.onStartTest) {
2285
+ try {
2286
+ await this.progressReporter.onStartTest({ suitePath: test.suitePath || [], testName: test.name }, jobId, page, browser, context);
2287
+ }
2288
+ catch (callbackError) {
2289
+ this.log(`onStartTest callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, 'warn');
2290
+ // Don't fail the test if callback has issues - runner-core is a library
2291
+ }
2334
2292
  }
2335
- }
2336
- // Execute suite-level afterEach hooks (from all parent suites, in reverse order: child → parent)
2337
- if (testData.suiteAfterEachHooks.length > 0) {
2338
- this.log(`Executing ${testData.suiteAfterEachHooks.length} suite-level afterEach hook(s) for test: ${test.fullName}`);
2339
- try {
2340
- // Get or create suite context for this test's suite
2341
- const suiteContext = await getOrCreateSuiteContext(testData.suitePath, testData.suiteVariables, flattened.fileVariables);
2342
- for (const hook of testData.suiteAfterEachHooks) {
2343
- const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook, jobId, testStepCounter);
2344
- testStepCounter += stepsExecuted;
2293
+ // Track step counter for sequential numeric step IDs across hooks and test
2294
+ let testStepCounter = 0;
2295
+ // Execute file-level beforeEach hooks
2296
+ if (flattened.fileLevelHooks.beforeEach.length > 0 && testStatus === 'passed') {
2297
+ this.log(`Executing ${flattened.fileLevelHooks.beforeEach.length} file-level beforeEach hook(s) for test: ${test.fullName}`);
2298
+ try {
2299
+ for (const hook of flattened.fileLevelHooks.beforeEach) {
2300
+ const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook, jobId, testStepCounter);
2301
+ testStepCounter += stepsExecuted;
2302
+ }
2303
+ }
2304
+ catch (error) {
2305
+ const errorMsg = error instanceof Error ? error.message : String(error);
2306
+ this.log(`File-level beforeEach hook failed for test ${test.fullName}: ${errorMsg}`, 'error');
2307
+ testStatus = 'failed';
2308
+ testError = `File-level beforeEach hook failed: ${errorMsg}`;
2345
2309
  }
2346
2310
  }
2347
- catch (error) {
2348
- const errorMsg = error instanceof Error ? error.message : String(error);
2349
- this.log(`Suite-level afterEach hook failed for test ${test.fullName}: ${errorMsg}`, 'warn');
2350
- // Log but continue - don't fail the test because of afterEach failure
2311
+ // Execute suite-level beforeEach hooks (from all parent suites, in order: parent → child)
2312
+ if (testData.suiteBeforeEachHooks.length > 0 && testStatus === 'passed') {
2313
+ this.log(`Executing ${testData.suiteBeforeEachHooks.length} suite-level beforeEach hook(s) for test: ${test.fullName}`);
2314
+ try {
2315
+ // Get or create suite context for this test's suite
2316
+ const suiteContext = await getOrCreateSuiteContext(testData.suitePath, testData.suiteVariables, flattened.fileVariables);
2317
+ for (const hook of testData.suiteBeforeEachHooks) {
2318
+ const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook, jobId, testStepCounter);
2319
+ testStepCounter += stepsExecuted;
2320
+ }
2321
+ }
2322
+ catch (error) {
2323
+ const errorMsg = error instanceof Error ? error.message : String(error);
2324
+ this.log(`Suite-level beforeEach hook failed for test ${test.fullName}: ${errorMsg}`, 'error');
2325
+ testStatus = 'failed';
2326
+ testError = `Suite-level beforeEach hook failed: ${errorMsg}`;
2327
+ }
2351
2328
  }
2352
- }
2353
- // Execute file-level afterEach hooks (even if test failed)
2354
- if (flattened.fileLevelHooks.afterEach.length > 0) {
2355
- this.log(`Executing ${flattened.fileLevelHooks.afterEach.length} file-level afterEach hook(s) for test: ${test.fullName}`);
2356
- try {
2357
- for (const hook of flattened.fileLevelHooks.afterEach) {
2358
- const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook, jobId, testStepCounter);
2359
- testStepCounter += stepsExecuted;
2329
+ // Run the test if hooks didn't fail
2330
+ if (testStatus === 'passed') {
2331
+ // Count non-variable statements for step counter tracking (needed in catch block)
2332
+ const testStepsExecuted = test.statements.filter(stmt => !stmt.isVariableDeclaration).length;
2333
+ try {
2334
+ // Construct a script with imports + this test (needed for module resolution)
2335
+ // Variables are already in shared context, so we don't need to include them in script
2336
+ const testScript = test_file_parser_1.TestFileParser.constructTestScriptWithImports(originalScript, test.name, // Use test name only, not fullName (suite prefix not needed in generated script)
2337
+ test.code);
2338
+ // Use pre-parsed statements from initial file parse
2339
+ // These were extracted directly from the original AST, ensuring accuracy
2340
+ this.log(`Using ${test.statements.length} pre-parsed statements for test: ${test.fullName}`);
2341
+ // Count non-variable statements for step ID assignment
2342
+ let stepIndex = 0; // Track step index (only for non-variable statements)
2343
+ const steps = test.statements.map((stmt) => {
2344
+ // CRITICAL: Always use hash-based stepId from parser if available
2345
+ // Only assign step ID to non-variable statements
2346
+ const stepId = stmt.isVariableDeclaration
2347
+ ? undefined
2348
+ : (stmt.stepId || String(testStepCounter + stepIndex++));
2349
+ // Log stepId assignment for debugging
2350
+ if (!stmt.isVariableDeclaration) {
2351
+ if (stmt.stepId) {
2352
+ this.log(`[stepId] Using hash-based stepId from parser: ${stmt.stepId.substring(0, 16)}... (test: ${test.fullName})`);
2353
+ }
2354
+ else {
2355
+ this.log(`[stepId] WARNING: No hash-based stepId from parser, using sequential: ${stepId} (test: ${test.fullName})`);
2356
+ }
2357
+ }
2358
+ return {
2359
+ id: stepId,
2360
+ code: stmt.code,
2361
+ description: stmt.intentComment || stmt.code.trim().substring(0, 100) || '',
2362
+ isVariableDeclaration: stmt.isVariableDeclaration, // Pass AST-based detection flag
2363
+ scenarioAnnotation: stmt.scenarioAnnotation
2364
+ };
2365
+ });
2366
+ // Get or create suite context for this test's suite
2367
+ const suiteContext = await getOrCreateSuiteContext(testData.suitePath, testData.suiteVariables, flattened.fileVariables);
2368
+ // Execute using the existing step-wise execution infrastructure
2369
+ // executeStepsInPersistentContext will:
2370
+ // 1. Use shared context (variables already initialized)
2371
+ // 2. Execute ALL statements sequentially (variables + actions)
2372
+ // 3. Variables execute silently and persist in context
2373
+ // 4. Non-variable statements trigger callbacks (onStepProgress)
2374
+ const result = await this.executeStepsInPersistentContext(steps, // Pre-parsed steps from initial parse with AST-based flags
2375
+ page, browser, context, {
2376
+ mode: request.mode || types_1.ExecutionMode.RUN_EXACTLY,
2377
+ jobId: jobId,
2378
+ tempDir: request.tempDir,
2379
+ originalScript: testScript, // For module resolution (imports)
2380
+ model: request.model,
2381
+ testFolderPath: request.testFolderPath,
2382
+ runPomDenormalized: request.runPomDenormalized
2383
+ }, suiteContext // Pass shared context
2384
+ );
2385
+ if (!result.success) {
2386
+ testStatus = 'failed';
2387
+ testError = result.error || 'Test execution failed';
2388
+ }
2389
+ // Track repair if we're in repair mode and steps were updated
2390
+ // Note: updatedSteps are always returned, but in repair mode they may contain repaired code
2391
+ if (request.mode === types_1.ExecutionMode.RUN_WITH_AI_REPAIR && result.updatedSteps && result.updatedSteps.length > 0) {
2392
+ // Generate updated script from steps (will include repairs if any occurred)
2393
+ const updatedScript = this.generateUpdatedScript(result.updatedSteps, undefined, testScript);
2394
+ if (updatedScript) {
2395
+ // Extract the test body from the generated script
2396
+ const repairedTestBody = test_file_parser_1.TestFileParser.extractTestBodyFromScript(updatedScript);
2397
+ if (repairedTestBody) {
2398
+ // Compare with original test body to confirm it's actually different (repairs occurred)
2399
+ const originalTestBody = test.code.trim();
2400
+ const repairedTestBodyTrimmed = repairedTestBody.trim();
2401
+ if (repairedTestBodyTrimmed !== originalTestBody) {
2402
+ // Create TestPathway key (JSON stringified for Map lookup)
2403
+ const testPathwayKey = JSON.stringify({
2404
+ suitePath: test.suitePath || [],
2405
+ testName: test.name.trim()
2406
+ });
2407
+ testRepairs.set(testPathwayKey, repairedTestBody);
2408
+ this.log(`Tracked repair for test: ${test.fullName} (pathway: ${testPathwayKey})`);
2409
+ }
2410
+ }
2411
+ }
2412
+ }
2413
+ // Update step counter after test execution (whether success or failure)
2414
+ // Steps were executed up to the failure point
2415
+ testStepCounter += testStepsExecuted;
2416
+ }
2417
+ catch (error) {
2418
+ testStatus = 'failed';
2419
+ testError = error instanceof Error ? error.message : String(error);
2420
+ // Still update step counter even if test failed (steps were executed up to failure point)
2421
+ testStepCounter += testStepsExecuted;
2360
2422
  }
2361
2423
  }
2362
- catch (error) {
2363
- const errorMsg = error instanceof Error ? error.message : String(error);
2364
- this.log(`File-level afterEach hook failed for test ${test.fullName}: ${errorMsg}`, 'warn');
2365
- // Log but continue - don't fail the test because of afterEach failure
2424
+ // Execute suite-level afterEach hooks (from all parent suites, in reverse order: child → parent)
2425
+ if (testData.suiteAfterEachHooks.length > 0) {
2426
+ this.log(`Executing ${testData.suiteAfterEachHooks.length} suite-level afterEach hook(s) for test: ${test.fullName}`);
2427
+ try {
2428
+ // Get or create suite context for this test's suite
2429
+ const suiteContext = await getOrCreateSuiteContext(testData.suitePath, testData.suiteVariables, flattened.fileVariables);
2430
+ for (const hook of testData.suiteAfterEachHooks) {
2431
+ const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook, jobId, testStepCounter);
2432
+ testStepCounter += stepsExecuted;
2433
+ }
2434
+ }
2435
+ catch (error) {
2436
+ const errorMsg = error instanceof Error ? error.message : String(error);
2437
+ this.log(`Suite-level afterEach hook failed for test ${test.fullName}: ${errorMsg}`, 'warn');
2438
+ // Log but continue - don't fail the test because of afterEach failure
2439
+ }
2366
2440
  }
2367
- }
2368
- // After test execution: Increment test counts and execute suite-level afterAll hooks (if last test in suite)
2369
- if (testData.suitePath.length > 0) {
2370
- // Increment test count for each suite in the path
2371
- for (let i = 0; i < testData.suitePath.length; i++) {
2372
- const suitePath = testData.suitePath.slice(0, i + 1);
2373
- const suiteKey = getSuiteKey(suitePath);
2374
- const currentCount = suiteTestCounts.get(suiteKey) || 0;
2375
- suiteTestCounts.set(suiteKey, currentCount + 1);
2441
+ // Execute file-level afterEach hooks (even if test failed)
2442
+ if (flattened.fileLevelHooks.afterEach.length > 0) {
2443
+ this.log(`Executing ${flattened.fileLevelHooks.afterEach.length} file-level afterEach hook(s) for test: ${test.fullName}`);
2444
+ try {
2445
+ for (const hook of flattened.fileLevelHooks.afterEach) {
2446
+ const stepsExecuted = await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook, jobId, testStepCounter);
2447
+ testStepCounter += stepsExecuted;
2448
+ }
2449
+ }
2450
+ catch (error) {
2451
+ const errorMsg = error instanceof Error ? error.message : String(error);
2452
+ this.log(`File-level afterEach hook failed for test ${test.fullName}: ${errorMsg}`, 'warn');
2453
+ // Log but continue - don't fail the test because of afterEach failure
2454
+ }
2376
2455
  }
2377
- // Execute afterAll for each suite in the path (in reverse order: child → parent)
2378
- for (let i = testData.suitePath.length - 1; i >= 0; i--) {
2379
- const suitePath = testData.suitePath.slice(0, i + 1);
2380
- const suiteKey = getSuiteKey(suitePath);
2381
- // Look up suite in Map (O(1) instead of O(n) .find())
2382
- const suite = suiteMap.get(suiteKey);
2383
- if (suite) {
2384
- const totalTests = suiteTotalTests.get(suiteKey) || 0;
2456
+ // After test execution: Increment test counts and execute suite-level afterAll hooks (if last test in suite)
2457
+ if (testData.suitePath.length > 0) {
2458
+ // Increment test count for each suite in the path
2459
+ for (let i = 0; i < testData.suitePath.length; i++) {
2460
+ const suitePath = testData.suitePath.slice(0, i + 1);
2461
+ const suiteKey = getSuiteKey(suitePath);
2385
2462
  const currentCount = suiteTestCounts.get(suiteKey) || 0;
2386
- // If this is the last test in the suite, execute afterAll (if any) and dispose context
2387
- if (currentCount >= totalTests) {
2388
- // Use human-readable suite name for logging
2389
- const suiteDisplayName = suitePath.join('__');
2390
- // Execute afterAll hooks if they exist
2391
- if (suite.afterAll.length > 0) {
2392
- this.log(`Executing suite-level afterAll hooks for suite: ${suiteDisplayName}`);
2393
- try {
2394
- // Get or create suite context (should already exist, but safe to call)
2395
- const suiteContext = await getOrCreateSuiteContext(suitePath, suite.suiteVariables, flattened.fileVariables);
2396
- for (const hook of suite.afterAll) {
2397
- await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook);
2463
+ suiteTestCounts.set(suiteKey, currentCount + 1);
2464
+ }
2465
+ // Execute afterAll for each suite in the path (in reverse order: child → parent)
2466
+ for (let i = testData.suitePath.length - 1; i >= 0; i--) {
2467
+ const suitePath = testData.suitePath.slice(0, i + 1);
2468
+ const suiteKey = getSuiteKey(suitePath);
2469
+ // Look up suite in Map (O(1) instead of O(n) .find())
2470
+ const suite = suiteMap.get(suiteKey);
2471
+ if (suite) {
2472
+ const totalTests = suiteTotalTests.get(suiteKey) || 0;
2473
+ const currentCount = suiteTestCounts.get(suiteKey) || 0;
2474
+ // If this is the last test in the suite, execute afterAll (if any) and dispose context
2475
+ if (currentCount >= totalTests) {
2476
+ // Use human-readable suite name for logging
2477
+ const suiteDisplayName = suitePath.join('__');
2478
+ // Execute afterAll hooks if they exist
2479
+ if (suite.afterAll.length > 0) {
2480
+ this.log(`Executing suite-level afterAll hooks for suite: ${suiteDisplayName}`);
2481
+ try {
2482
+ // Get or create suite context (should already exist, but safe to call)
2483
+ const suiteContext = await getOrCreateSuiteContext(suitePath, suite.suiteVariables, flattened.fileVariables);
2484
+ for (const hook of suite.afterAll) {
2485
+ await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, suiteContext, hook);
2486
+ }
2487
+ this.log(`Suite-level afterAll hooks executed for suite: ${suiteDisplayName}`);
2488
+ }
2489
+ catch (error) {
2490
+ const errorMsg = error instanceof Error ? error.message : String(error);
2491
+ this.log(`Suite-level afterAll hook failed for suite ${suiteDisplayName}: ${errorMsg}`, 'warn');
2492
+ // Log but continue - don't fail the test because of afterAll failure
2398
2493
  }
2399
- this.log(`Suite-level afterAll hooks executed for suite: ${suiteDisplayName}`);
2400
- }
2401
- catch (error) {
2402
- const errorMsg = error instanceof Error ? error.message : String(error);
2403
- this.log(`Suite-level afterAll hook failed for suite ${suiteDisplayName}: ${errorMsg}`, 'warn');
2404
- // Log but continue - don't fail the test because of afterAll failure
2405
2494
  }
2495
+ // Dispose suite context after afterAll hooks complete (or if no afterAll hooks)
2496
+ // This ensures contexts are always disposed, even for suites without afterAll hooks
2497
+ disposeSuiteContext(suitePath);
2406
2498
  }
2407
- // Dispose suite context after afterAll hooks complete (or if no afterAll hooks)
2408
- // This ensures contexts are always disposed, even for suites without afterAll hooks
2409
- disposeSuiteContext(suitePath);
2410
2499
  }
2411
2500
  }
2412
2501
  }
2413
- }
2414
- // Call onEndTest callback (use TestPathway)
2415
- if (this.progressReporter?.onEndTest) {
2416
- try {
2417
- await this.progressReporter.onEndTest({ suitePath: test.suitePath || [], testName: test.name }, jobId, testStatus, testError, page, browser, context);
2502
+ // Call onEndTest callback (use TestPathway)
2503
+ if (this.progressReporter?.onEndTest) {
2504
+ try {
2505
+ await this.progressReporter.onEndTest({ suitePath: test.suitePath || [], testName: test.name }, jobId, testStatus, testError, page, browser, context);
2506
+ }
2507
+ catch (callbackError) {
2508
+ this.log(`onEndTest callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, 'warn');
2509
+ // Don't fail the test if callback has issues - runner-core is a library
2510
+ }
2418
2511
  }
2419
- catch (callbackError) {
2420
- this.log(`onEndTest callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, 'warn');
2421
- // Don't fail the test if callback has issues - runner-core is a library
2512
+ }
2513
+ catch (error) {
2514
+ // Unexpected error during test execution
2515
+ testStatus = 'failed';
2516
+ testError = error instanceof Error ? error.message : String(error);
2517
+ this.log(`Unexpected error during test ${test.name}: ${testError}`, 'error');
2518
+ // Still call onEndTest if possible
2519
+ if (this.progressReporter?.onEndTest) {
2520
+ try {
2521
+ await this.progressReporter.onEndTest({ suitePath: test.suitePath || [], testName: test.name }, jobId, testStatus, testError, page, browser, context);
2522
+ }
2523
+ catch (callbackError) {
2524
+ this.log(`onEndTest callback failed: ${callbackError}`, 'warn');
2525
+ }
2422
2526
  }
2423
2527
  }
2424
- }
2425
- catch (error) {
2426
- // Unexpected error during test execution
2427
- testStatus = 'failed';
2428
- testError = error instanceof Error ? error.message : String(error);
2429
- this.log(`Unexpected error during test ${test.name}: ${testError}`, 'error');
2430
- // Still call onEndTest if possible
2431
- if (this.progressReporter?.onEndTest) {
2528
+ // Record test result (use TestPathway)
2529
+ testResults.push({
2530
+ testPathway: { suitePath: test.suitePath || [], testName: test.name },
2531
+ jobId: jobId,
2532
+ status: testStatus,
2533
+ error: testError,
2534
+ executionTime: Date.now() - testStartTime
2535
+ });
2536
+ this.log(`Test ${test.fullName} completed with status: ${testStatus}`);
2537
+ // Call afterEndTest callback if provided (called after each test completes)
2538
+ // This allows each test in a file to be treated as a separate journey
2539
+ if (request.afterEndTest) {
2432
2540
  try {
2433
- await this.progressReporter.onEndTest({ suitePath: test.suitePath || [], testName: test.name }, jobId, testStatus, testError, page, browser, context);
2541
+ const callbackStatus = testStatus === 'passed' ? 'passed' : 'failed';
2542
+ await request.afterEndTest(callbackStatus, testError, page, undefined, undefined);
2543
+ this.log(`afterEndTest callback completed for test ${test.fullName}`);
2434
2544
  }
2435
2545
  catch (callbackError) {
2436
- this.log(`onEndTest callback failed: ${callbackError}`, 'warn');
2546
+ this.log(`afterEndTest callback failed for test ${test.fullName}: ${callbackError}`, 'warn');
2547
+ // Don't fail the test execution if callback has issues
2437
2548
  }
2438
2549
  }
2439
2550
  }
2440
- // Record test result (use TestPathway)
2441
- testResults.push({
2442
- testPathway: { suitePath: test.suitePath || [], testName: test.name },
2443
- jobId: jobId,
2444
- status: testStatus,
2445
- error: testError,
2446
- executionTime: Date.now() - testStartTime
2447
- });
2448
- this.log(`Test ${test.fullName} completed with status: ${testStatus}`);
2449
- // Call afterEndTest callback if provided (called after each test completes)
2450
- // This allows each test in a file to be treated as a separate journey
2451
- if (request.afterEndTest) {
2551
+ // Execute file-level afterAll hooks
2552
+ if (flattened.fileLevelHooks.afterAll.length > 0) {
2553
+ this.log(`Executing ${flattened.fileLevelHooks.afterAll.length} file-level afterAll hook(s)...`);
2452
2554
  try {
2453
- const callbackStatus = testStatus === 'passed' ? 'passed' : 'failed';
2454
- await request.afterEndTest(callbackStatus, testError, page, undefined, undefined);
2455
- this.log(`afterEndTest callback completed for test ${test.fullName}`);
2456
- }
2457
- catch (callbackError) {
2458
- this.log(`afterEndTest callback failed for test ${test.fullName}: ${callbackError}`, 'warn');
2459
- // Don't fail the test execution if callback has issues
2555
+ for (const hook of flattened.fileLevelHooks.afterAll) {
2556
+ await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook);
2557
+ }
2558
+ this.log('File-level afterAll hooks executed successfully');
2460
2559
  }
2461
- }
2462
- }
2463
- // Execute file-level afterAll hooks
2464
- if (flattened.fileLevelHooks.afterAll.length > 0) {
2465
- this.log(`Executing ${flattened.fileLevelHooks.afterAll.length} file-level afterAll hook(s)...`);
2466
- try {
2467
- for (const hook of flattened.fileLevelHooks.afterAll) {
2468
- await this.executeHook(hook.code, page, context, browser, request.tempDir, originalScript, request.testFolderPath, request.runPomDenormalized, fileLevelContext || undefined, hook);
2560
+ catch (error) {
2561
+ const errorMsg = error instanceof Error ? error.message : String(error);
2562
+ this.log(`File-level afterAll hook failed: ${errorMsg}`, 'warn');
2563
+ // Log but don't fail overall execution
2469
2564
  }
2470
- this.log('File-level afterAll hooks executed successfully');
2471
- }
2472
- catch (error) {
2473
- const errorMsg = error instanceof Error ? error.message : String(error);
2474
- this.log(`File-level afterAll hook failed: ${errorMsg}`, 'warn');
2475
- // Log but don't fail overall execution
2476
- }
2477
- }
2478
- // Dispose file-level context after file-level afterAll hooks
2479
- if (fileLevelContext !== null && fileLevelContext !== undefined) {
2480
- fileLevelContext.dispose();
2481
- fileLevelContext = null;
2482
- }
2483
- // Cleanup browser if we created it
2484
- if (browserCreated && browser) {
2485
- try {
2486
- await browser.close();
2487
- this.log('Browser closed');
2488
2565
  }
2489
- catch (closeError) {
2490
- this.log(`Error closing browser: ${closeError}`, 'warn');
2566
+ // Dispose file-level context after file-level afterAll hooks
2567
+ if (fileLevelContext !== null && fileLevelContext !== undefined) {
2568
+ fileLevelContext.dispose();
2569
+ fileLevelContext = null;
2491
2570
  }
2492
- }
2493
- // Determine overall success (all tests passed)
2494
- const allPassed = testResults.every(result => result.status === 'passed');
2495
- // Reconstruct the full test file with repairs if any tests were repaired
2496
- let updatedScript;
2497
- if (testRepairs.size > 0) {
2498
- this.log(`Reconstructing test file with ${testRepairs.size} repaired test(s)...`);
2499
- try {
2500
- updatedScript = test_file_parser_1.TestFileParser.reconstructTestFileWithRepairs(originalScript, parsed, testRepairs);
2501
- this.log('Test file reconstruction completed successfully');
2571
+ // Cleanup browser if we created it
2572
+ if (browserCreated && browser) {
2573
+ try {
2574
+ await browser.close();
2575
+ this.log('Browser closed');
2576
+ }
2577
+ catch (closeError) {
2578
+ this.log(`Error closing browser: ${closeError}`, 'warn');
2579
+ }
2502
2580
  }
2503
- catch (error) {
2504
- const errorMsg = error instanceof Error ? error.message : String(error);
2505
- this.log(`Failed to reconstruct test file: ${errorMsg}`, 'warn');
2506
- // Continue without updatedScript - original script will be used
2581
+ // Determine overall success (all tests passed)
2582
+ const allPassed = testResults.every(result => result.status === 'passed');
2583
+ // Reconstruct the full test file with repairs if any tests were repaired
2584
+ let updatedScript;
2585
+ if (testRepairs.size > 0) {
2586
+ this.log(`Reconstructing test file with ${testRepairs.size} repaired test(s)...`);
2587
+ try {
2588
+ updatedScript = test_file_parser_1.TestFileParser.reconstructTestFileWithRepairs(originalScript, parsed, testRepairs);
2589
+ this.log('Test file reconstruction completed successfully');
2590
+ }
2591
+ catch (error) {
2592
+ const errorMsg = error instanceof Error ? error.message : String(error);
2593
+ this.log(`Failed to reconstruct test file: ${errorMsg}`, 'warn');
2594
+ // Continue without updatedScript - original script will be used
2595
+ }
2507
2596
  }
2508
- }
2509
- return {
2510
- success: allPassed,
2511
- testResults: testResults,
2512
- executionTime: Date.now() - startTime,
2513
- updatedScript: updatedScript
2514
- };
2597
+ return {
2598
+ success: allPassed,
2599
+ testResults: testResults,
2600
+ executionTime: Date.now() - startTime,
2601
+ updatedScript: updatedScript
2602
+ };
2603
+ });
2515
2604
  }
2516
2605
  catch (error) {
2517
2606
  const errorMsg = error instanceof Error ? error.message : String(error);