diff --git a/.changeset/neat-birds-beam.md b/.changeset/neat-birds-beam.md new file mode 100644 index 00000000..58384b8a --- /dev/null +++ b/.changeset/neat-birds-beam.md @@ -0,0 +1,5 @@ +--- +"magnitude-test": patch +--- + +beforeAll, beforeEach, afterEach, afterAll hooks may now be registered within groups to scope them only to tests within the group. For the time being, afterAll hooks regardless of group will only run after all tests in a module are complete. This may change in the future. diff --git a/packages/magnitude-test/src/discovery/types.ts b/packages/magnitude-test/src/discovery/types.ts index 2263f2ac..3f7b1dfe 100644 --- a/packages/magnitude-test/src/discovery/types.ts +++ b/packages/magnitude-test/src/discovery/types.ts @@ -58,11 +58,12 @@ export type TestGroupFunction = () => void; export interface TestGroup { name: string; options?: TestOptions; + id?: string; } export interface TestGroupDeclaration { - (id: string, options: TestOptions, groupFn: TestGroupFunction): void; - (id: string, groupFn: TestGroupFunction): void; + (name: string, options: TestOptions, groupFn: TestGroupFunction): void; + (name: string, groupFn: TestGroupFunction): void; } export interface TestDeclaration { @@ -86,4 +87,5 @@ export interface RegisteredTest { // meta filepath: string, group?: string, + groupHierarchy?: Array<{ name: string; id?: string }>, } \ No newline at end of file diff --git a/packages/magnitude-test/src/term-app/uiRenderer.ts b/packages/magnitude-test/src/term-app/uiRenderer.ts index 6ade2f52..0b2b0bb7 100644 --- a/packages/magnitude-test/src/term-app/uiRenderer.ts +++ b/packages/magnitude-test/src/term-app/uiRenderer.ts @@ -130,67 +130,96 @@ export function generateTestString(test: RegisteredTest, state: RunnerTestState, return output; } -// Helper function to group tests for display -function groupRegisteredTestsForDisplay(tests: RegisteredTest[]): - Record }> { - const files: Record }> = {}; +// Tree node structure for hierarchical display +interface TreeNode { + tests: RegisteredTest[]; + children: Record; +} + +// Helper function to build a tree structure from hierarchical tests +function buildTestTree(tests: RegisteredTest[]): TreeNode { + const root: TreeNode = { tests: [], children: {} }; + for (const test of tests) { - if (!files[test.filepath]) { - files[test.filepath] = { ungrouped: [], groups: {} }; - } - if (test.group) { - if (!files[test.filepath].groups[test.group]) { - files[test.filepath].groups[test.group] = []; - } - files[test.filepath].groups[test.group].push(test); + if (!test.groupHierarchy || test.groupHierarchy.length === 0) { + // Tests without hierarchy go in the root + root.tests.push(test); } else { - files[test.filepath].ungrouped.push(test); + // Build the tree path for hierarchical tests + let currentNode = root; + for (const group of test.groupHierarchy) { + if (!currentNode.children[group.name]) { + currentNode.children[group.name] = { tests: [], children: {} }; + } + currentNode = currentNode.children[group.name]; + } + currentNode.tests.push(test); } } - return files; + + return root; +} + +// Helper function to group tests by file and build trees +function groupTestsByFile(tests: RegisteredTest[]): Record { + const files: Record = {}; + + // Group tests by file first + for (const test of tests) { + if (!files[test.filepath]) { + files[test.filepath] = []; + } + files[test.filepath].push(test); + } + + // Build tree for each file + const fileTrees: Record = {}; + for (const [filepath, fileTests] of Object.entries(files)) { + fileTrees[filepath] = buildTestTree(fileTests); + } + + return fileTrees; } +// Helper function to recursively generate tree display +function generateTreeDisplay(node: TreeNode, indent: number, output: string[]): void { + // Display tests at this level + for (const test of node.tests) { + const state = currentTestStates[test.id]; + if (state) { + const testLines = generateTestString(test, state, indent); + output.push(...testLines); + } + } + + // Display child groups + for (const [groupName, childNode] of Object.entries(node.children)) { + const groupHeader = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}↳ ${groupName}${ANSI_RESET}`; + output.push(UI_LEFT_PADDING + ' '.repeat(indent) + groupHeader); + + // Recursively display child node with increased indent + generateTreeDisplay(childNode, indent + 2, output); + } +} + /** * Generate the test list portion of the UI */ export function generateTestListString(): string[] { const output: string[] = []; const fileIndent = 0; - const groupIndent = fileIndent + 2; - const testBaseIndent = groupIndent; - const groupedDisplayTests = groupRegisteredTestsForDisplay(allRegisteredTests); + const fileTrees = groupTestsByFile(allRegisteredTests); - for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) { + for (const [filepath, treeRoot] of Object.entries(fileTrees)) { const fileHeader = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}☰ ${filepath}${ANSI_RESET}`; output.push(UI_LEFT_PADDING + ' '.repeat(fileIndent) + fileHeader); - if (ungrouped.length > 0) { - for (const test of ungrouped) { - const state = currentTestStates[test.id]; - if (state) { - const testLines = generateTestString(test, state, testBaseIndent); - output.push(...testLines); - } - } - } - - if (Object.entries(groups).length > 0) { - for (const [groupName, groupTests] of Object.entries(groups)) { - const groupHeader = `${ANSI_BRIGHT_BLUE}${ANSI_BOLD}↳ ${groupName}${ANSI_RESET}`; - output.push(UI_LEFT_PADDING + ' '.repeat(groupIndent) + groupHeader); + // Generate tree display starting from root + generateTreeDisplay(treeRoot, fileIndent + 2, output); - for (const test of groupTests) { - const state = currentTestStates[test.id]; - if (state) { - const testLines = generateTestString(test, state, testBaseIndent + 2); - output.push(...testLines); - } - } - } - } - output.push(UI_LEFT_PADDING); // Blank line between files/main groups + output.push(UI_LEFT_PADDING); // Blank line between files } return output; } @@ -204,7 +233,7 @@ export function generateSummaryString(): string[] { let totalOutputTokens = 0; const statusCounts = { pending: 0, running: 0, passed: 0, failed: 0, cancelled: 0, total: 0 }; const failuresWithContext: { filepath: string; groupName?: string; testTitle: string; failure: TestFailure }[] = []; - + const testContextMap = new Map(); allRegisteredTests.forEach(test => { testContextMap.set(test.id, { filepath: test.filepath, groupName: test.group, testTitle: test.title }); @@ -241,7 +270,7 @@ export function generateSummaryString(): string[] { costDescription = ` (\$${cost.toFixed(2)})`; } let tokenText = `${ANSI_GRAY}tokens: ${totalInputTokens} in, ${totalOutputTokens} out${costDescription}${ANSI_RESET}`; - + output.push(UI_LEFT_PADDING + statusLine.trimEnd() + (statusLine && tokenText ? ' ' : '') + tokenText.trimStart()); if (hasFailures) { @@ -257,60 +286,53 @@ export function generateSummaryString(): string[] { return output; } +// Helper function to calculate tree height recursively +function calculateTreeHeight(node: TreeNode, testStates: AllTestStates, indent: number): number { + let height = 0; + + // Count tests at this level + for (const test of node.tests) { + const state = testStates[test.id]; + if (state) { + height++; // Test title line + if (state.stepsAndChecks) { + state.stepsAndChecks.forEach((item: RunnerStepDescriptor | RunnerCheckDescriptor) => { + height++; // Item description line + if (item.variant === 'step') { + if (renderSettings.showActions) { + height += item.actions?.length ?? 0; + } + if (renderSettings.showThoughts) { + height += item.thoughts?.length ?? 0; + } + } + }); + } + if (state.failure) { + height++; // generateFailureString returns 1 line + } + } + } + + // Count child groups recursively + for (const [groupName, childNode] of Object.entries(node.children)) { + height++; // Group header line + height += calculateTreeHeight(childNode, testStates, indent + 2); + } + + return height; +} + /** * Calculate the height needed for the test list (now just line count) */ export function calculateTestListHeight(tests: RegisteredTest[], testStates: AllTestStates): number { let height = 0; - const groupedDisplayTests = groupRegisteredTestsForDisplay(tests); - - const addStepsAndChecksHeight = (state: RunnerTestState) => { - if (state.stepsAndChecks) { - state.stepsAndChecks.forEach((item: RunnerStepDescriptor | RunnerCheckDescriptor) => { - height++; // Item description line - if (item.variant === 'step') { - if (renderSettings.showActions) { - height += item.actions?.length ?? 0; - } - if (renderSettings.showThoughts) { - height += item.thoughts?.length ?? 0; - } - } - }); - } - }; + const fileTrees = groupTestsByFile(tests); - for (const [filepath, { ungrouped, groups }] of Object.entries(groupedDisplayTests)) { + for (const [filepath, treeRoot] of Object.entries(fileTrees)) { height++; // File header line - - if (ungrouped.length > 0) { - for (const test of ungrouped) { - const state = testStates[test.id]; - if (state) { - height++; // Test title line - addStepsAndChecksHeight(state); - if (state.failure) { - height++; // generateFailureString returns 1 line - } - } - } - } - - if (Object.entries(groups).length > 0) { - for (const [groupName, groupTests] of Object.entries(groups)) { - height++; // Group header line - for (const test of groupTests) { - const state = testStates[test.id]; - if (state) { - height++; // Test title line - addStepsAndChecksHeight(state); - if (state.failure) { - height++; // generateFailureString returns 1 line - } - } - } - } - } + height += calculateTreeHeight(treeRoot, testStates, 2); // Start with indent of 2 height++; // Blank line between files } return height; @@ -347,9 +369,9 @@ export function redraw() { let summaryLineCount = calculateSummaryHeight(currentTestStates); if (Object.values(currentTestStates).length === 0) { // No tests, no summary summaryLineCount = 0; - testListLineCount = 0; + testListLineCount = 0; } - + const outputLines: string[] = []; // outputLines.push(''); // Initial blank line for spacing from prompt - REMOVED @@ -367,7 +389,7 @@ export function redraw() { } const frameContent = outputLines.join('\n'); - + logUpdate.clear(); // Clear previous output before drawing new frame logUpdate(frameContent); diff --git a/packages/magnitude-test/src/worker/localTestRegistry.ts b/packages/magnitude-test/src/worker/localTestRegistry.ts index 97e0b3f8..6bcbb840 100644 --- a/packages/magnitude-test/src/worker/localTestRegistry.ts +++ b/packages/magnitude-test/src/worker/localTestRegistry.ts @@ -1,6 +1,6 @@ -import { TestFunction, TestGroup, TestOptions } from "@/discovery/types"; +import { TestFunction, TestGroup, TestOptions, RegisteredTest } from "@/discovery/types"; import cuid2 from "@paralleldrive/cuid2"; -import { getTestWorkerData, postToParent, testFunctions, messageEmitter, TestWorkerIncomingMessage, hooks, testRegistry, testPromptStack } from "./util"; +import { getTestWorkerData, postToParent, testFunctions, messageEmitter, TestWorkerIncomingMessage, hooks, testRegistry, testPromptStack, getOrInitGroupHookSet } from "./util"; import { TestCaseAgent } from "@/agent"; import { TestResult, TestState, TestStateTracker } from "@/runner/state"; import { buildDefaultBrowserAgentOptions } from "magnitude-core"; @@ -12,17 +12,28 @@ const workerData = getTestWorkerData(); const generateId = cuid2.init({ length: 12 }); +/** Get all prefix keys for a hierarchy in outer → inner order */ +function keysForPrefixes(hierarchy: TestGroup[]): string[] { + const keys: string[] = []; + for (let i = 1; i <= hierarchy.length; i++) { + keys.push(hierarchy.slice(0, i).map(g => g.id).join('>')); + } + return keys; +} + export function registerTest(testFn: TestFunction, title: string, url: string) { const testId = generateId(); testFunctions.set(testId, testFn); + const groupHierarchy = getCurrentGroupHierarchy().map(({ name, id }) => ({ name, id })); testRegistry.set(testId, { + id: testId, title, url, filepath: workerData.relativeFilePath, - group: currentGroup?.name + group: getCurrentGroup()?.name, + groupHierarchy }); - postToParent({ type: 'registered', test: { @@ -30,7 +41,8 @@ export function registerTest(testFn: TestFunction, title: string, url: string) { title, url, filepath: workerData.relativeFilePath, - group: currentGroup?.name + group: getCurrentGroup()?.name, + groupHierarchy } }); } @@ -39,15 +51,96 @@ let beforeAllExecuted = false; let beforeAllError: Error | null = null; let afterAllExecuted = false; let isShuttingDown = false; -let pendingAfterEach: Set = new Set(); +let pendingAfterEach: Map = new Map(); +let groupBeforeAllExecuted: Set = new Set(); +let groupBeforeAllErrors: Map = new Map(); +let executedBeforeAllOrder: string[] = []; // No state reset is needed because each test file is run in a separate worker -let currentGroup: TestGroup | undefined; -export function setCurrentGroup(group?: TestGroup) { - currentGroup = group; +let currentGroupStack: TestGroup[] = []; +export function pushCurrentGroup(group: TestGroup) { + currentGroupStack.push(group); +} +export function popCurrentGroup() { + currentGroupStack.pop(); +} +export function getCurrentGroup(): TestGroup | undefined { + return currentGroupStack.length > 0 ? currentGroupStack[currentGroupStack.length - 1] : undefined; +} +export function getCurrentGroupHierarchy(): TestGroup[] { + return [...currentGroupStack]; } export function currentGroupOptions(): TestOptions { - return structuredClone(currentGroup?.options) ?? {}; + let mergedOptions: TestOptions = {}; + for (const group of currentGroupStack) { + if (group.options) { + mergedOptions = { ...mergedOptions, ...group.options }; + } + } + return structuredClone(mergedOptions); +} + +async function executeAfterEachHooks(test: RegisteredTest) { + // Run group-level afterEach hooks in inner → outer order + if (test.groupHierarchy && test.groupHierarchy.length > 0) { + const prefixKeys = keysForPrefixes(test.groupHierarchy); + for (const key of prefixKeys.reverse()) { + const hookSet = getOrInitGroupHookSet(key); + for (const afterEachHook of hookSet.afterEach) { + try { + await afterEachHook(); + } catch (error) { + const groupNames = test.groupHierarchy.map(g => g.name).join(' > '); + console.error(`Group afterEach hook failed for test '${test.title}' in hierarchy '${groupNames}':`, error); + throw error; + } + } + } + } + + for (const afterEachHook of hooks.afterEach) { + try { + await afterEachHook(); + } catch (error) { + console.error(`afterEach hook failed for test '${test.title}':`, error); + throw error; + } + } +} + +async function executeGroupBeforeAllHooks(test: RegisteredTest) { + if (!test.groupHierarchy || test.groupHierarchy.length === 0) { + return; + } + + const prefixKeys = keysForPrefixes(test.groupHierarchy); + + for (const key of prefixKeys) { + if (groupBeforeAllExecuted.has(key)) { + const error = groupBeforeAllErrors.get(key); + if (error) { + const groupNames = test.groupHierarchy.map(g => g.name).join(' > '); + throw new Error(`Group beforeAll hook failed for hierarchy '${groupNames}': ${error.message}`); + } + continue; + } + + groupBeforeAllExecuted.add(key); + executedBeforeAllOrder.push(key); + + try { + const hookSet = getOrInitGroupHookSet(key); + for (const beforeAllHook of hookSet.beforeAll) { + await beforeAllHook(); + } + } catch (error) { + const hookError = error instanceof Error ? error : new Error(String(error)); + const groupNames = test.groupHierarchy.map(g => g.name).join(' > '); + console.error(`Group beforeAll hook failed for hierarchy '${groupNames}':`, hookError); + groupBeforeAllErrors.set(key, hookError); + throw hookError; + } + } } messageEmitter.removeAllListeners('message'); @@ -55,32 +148,54 @@ messageEmitter.on('message', async (message: TestWorkerIncomingMessage) => { if (message.type === 'graceful_shutdown') { isShuttingDown = true; - if (pendingAfterEach.size > 0) { - try { - await Promise.all( - [...pendingAfterEach].map(async (_testId) => { - for (const afterEachHook of hooks.afterEach) { - await afterEachHook(); - } - }) - ); - } catch (error) { - console.error("afterEach hooks failed during graceful shutdown:", error); + try { + if (pendingAfterEach.size > 0) { + try { + await Promise.all( + [...pendingAfterEach.values()].map(async (test) => { + try { + await executeAfterEachHooks(test); + } catch (error) { + console.error(`afterEach hooks failed during graceful shutdown for test '${test.title}':`, error); + // Don't throw here - we want to continue with other tests + } + }) + ); + } catch (error) { + console.error("afterEach hooks failed during graceful shutdown:", error); + } } - } - if (!afterAllExecuted) { - try { - for (const afterAllHook of hooks.afterAll) { - await afterAllHook(); + if (!afterAllExecuted) { + try { + // Run group afterAll hooks in reverse execution order (inner → outer) + for (const key of executedBeforeAllOrder.reverse()) { + const hookSet = getOrInitGroupHookSet(key); + if (hookSet.afterAll.length > 0) { + try { + for (const afterAllHook of hookSet.afterAll) { + await afterAllHook(); + } + } catch (error) { + console.error(`Group afterAll hook failed during graceful shutdown for hierarchy key '${key}':`, error); + } + } + } + + for (const afterAllHook of hooks.afterAll) { + await afterAllHook(); + } + afterAllExecuted = true; + } catch (error) { + console.error("afterAll hook failed during graceful shutdown:\n", error); } - afterAllExecuted = true; - } catch (error) { - console.error("afterAll hook failed during graceful shutdown:\n", error); } - } - postToParent({ type: 'graceful_shutdown_complete' }); + postToParent({ type: 'graceful_shutdown_complete' }); + } catch (error) { + console.error("Critical error during graceful shutdown:", error); + postToParent({ type: 'graceful_shutdown_complete' }); + } return; } @@ -153,6 +268,7 @@ messageEmitter.on('message', async (message: TestWorkerIncomingMessage) => { let finalState: TestState; let finalResult: TestResult; + try { if (!beforeAllExecuted && hooks.beforeAll.length > 0) { try { @@ -171,6 +287,8 @@ messageEmitter.on('message', async (message: TestWorkerIncomingMessage) => { throw new Error(`beforeAll hook failed: ${beforeAllError.message}`); } + await executeGroupBeforeAllHooks(testMetadata); + for (const beforeEachHook of hooks.beforeEach) { try { await beforeEachHook(); @@ -179,20 +297,29 @@ messageEmitter.on('message', async (message: TestWorkerIncomingMessage) => { throw error; } } - pendingAfterEach.add(testId); + // Run group-level beforeEach hooks in outer → inner order + if (testMetadata.groupHierarchy && testMetadata.groupHierarchy.length > 0) { + const prefixKeys = keysForPrefixes(testMetadata.groupHierarchy); + for (const key of prefixKeys) { + const hookSet = getOrInitGroupHookSet(key); + for (const beforeEachHook of hookSet.beforeEach) { + try { + await beforeEachHook(); + } catch (error) { + const groupNames = testMetadata.groupHierarchy.map(g => g.name).join(' > '); + console.error(`Group beforeEach hook failed for test '${testMetadata.title}' in hierarchy '${groupNames}':`, error); + throw error; + } + } + } + } + pendingAfterEach.set(testId, testMetadata); await testFn(agent); if (!isShuttingDown) { pendingAfterEach.delete(testId); - for (const afterEachHook of hooks.afterEach) { - try { - await afterEachHook(); - } catch (error) { - console.error(`afterEach hook failed for test '${testMetadata.title}':`, error); - throw error; - } - } + await executeAfterEachHooks(testMetadata); } finalState = { @@ -206,9 +333,7 @@ messageEmitter.on('message', async (message: TestWorkerIncomingMessage) => { if (!isShuttingDown) { pendingAfterEach.delete(testId); try { - for (const afterEachHook of hooks.afterEach) { - await afterEachHook(); - } + await executeAfterEachHooks(testMetadata); } catch (afterEachError) { console.error(`afterEach hook failed for failing test '${testMetadata.title}':`, afterEachError); const originalMessage = error instanceof Error ? error.message : String(error); diff --git a/packages/magnitude-test/src/worker/testDeclaration.ts b/packages/magnitude-test/src/worker/testDeclaration.ts index 5c974390..c83bc49a 100644 --- a/packages/magnitude-test/src/worker/testDeclaration.ts +++ b/packages/magnitude-test/src/worker/testDeclaration.ts @@ -1,10 +1,12 @@ import { TestDeclaration, TestOptions, TestFunction, TestGroupFunction } from '../discovery/types'; import { addProtocolIfMissing, processUrl } from '@/util'; -import { getTestWorkerData, hooks, TestHooks, testPromptStack } from '@/worker/util'; -import { currentGroupOptions, registerTest, setCurrentGroup } from '@/worker/localTestRegistry'; +import { getTestWorkerData, hooks, TestHooks, testPromptStack, getOrInitGroupHookSet } from '@/worker/util'; +import { currentGroupOptions, registerTest, pushCurrentGroup, popCurrentGroup, getCurrentGroupHierarchy } from '@/worker/localTestRegistry'; +import cuid2 from "@paralleldrive/cuid2"; const workerData = getTestWorkerData(); +const genGroupId = cuid2.init({ length: 6 }); function testDecl( title: string, optionsOrTestFn: TestOptions | TestFunction, @@ -50,7 +52,7 @@ function testDecl( } testDecl.group = function ( - id: string, + name: string, optionsOrTestFn: TestOptions | TestGroupFunction, testFnOrNothing?: TestGroupFunction ): void { @@ -69,9 +71,12 @@ testDecl.group = function ( testFn = testFnOrNothing; } - setCurrentGroup({ name: id, options }); - testFn(); - setCurrentGroup(undefined); + pushCurrentGroup({ name, id: `grp${genGroupId()}`, options }); + try { + testFn(); + } finally { + popCurrentGroup(); + } } export const test = testDecl as TestDeclaration; @@ -81,7 +86,16 @@ function createHookRegistrar(kind: keyof TestHooks) { if (typeof fn !== "function") { throw new Error(`${kind} expects a function`); } - hooks[kind].push(fn); + + const hierarchy = getCurrentGroupHierarchy(); + if (hierarchy.length > 0) { + const key = hierarchy.map(g => g.id).join('>'); + const hookSet = getOrInitGroupHookSet(key); + hookSet[kind].push(fn); + } else { + // Register as file-level hook + hooks[kind].push(fn); + } }; } diff --git a/packages/magnitude-test/src/worker/util.ts b/packages/magnitude-test/src/worker/util.ts index 52255d8f..f17fdc60 100644 --- a/packages/magnitude-test/src/worker/util.ts +++ b/packages/magnitude-test/src/worker/util.ts @@ -11,7 +11,8 @@ declare global { var __magnitudeMessageEmitter: EventEmitter | undefined; var __magnitudeTestHooks: TestHooks | undefined; var __magnitudeTestPromptStack: Record | undefined; - var __magnitudeTestRegistry: Map | undefined; + var __magnitudeTestRegistry: Map | undefined; + var __magnitudeGroupTestHooks: GroupTestHooks | undefined; } if (!globalThis.__magnitudeTestFunctions) { @@ -30,13 +31,6 @@ export type TestHooks = Record< (() => void | Promise)[] >; -export type TestMetadata = { - title: string; - url: string; - filepath: string; - group?: string; -}; - if (!globalThis.__magnitudeTestHooks) { globalThis.__magnitudeTestHooks = { beforeAll: [], @@ -47,16 +41,36 @@ if (!globalThis.__magnitudeTestHooks) { } export const hooks = globalThis.__magnitudeTestHooks; + + +/** Group-level test hooks keyed by hierarchy key */ +export type GroupTestHooks = Record; + if (!globalThis.__magnitudeTestPromptStack) { globalThis.__magnitudeTestPromptStack = {}; } export const testPromptStack = globalThis.__magnitudeTestPromptStack; if (!globalThis.__magnitudeTestRegistry) { - globalThis.__magnitudeTestRegistry = new Map(); + globalThis.__magnitudeTestRegistry = new Map(); } export const testRegistry = globalThis.__magnitudeTestRegistry; - +if (!globalThis.__magnitudeGroupTestHooks) { + globalThis.__magnitudeGroupTestHooks = {}; +} +export const groupHooks = globalThis.__magnitudeGroupTestHooks; +/** Helper to get or initialize hook set for a hierarchy key */ +export function getOrInitGroupHookSet(key: string): TestHooks { + if (!groupHooks[key]) { + groupHooks[key] = { + beforeAll: [], + afterAll: [], + beforeEach: [], + afterEach: [], + }; + } + return groupHooks[key]; +} export type TestWorkerIncomingMessage = { type: "execute" testId: string;