diff --git a/src/testController.ts b/src/testController.ts index 2f4fb3e..597fa82 100644 --- a/src/testController.ts +++ b/src/testController.ts @@ -238,6 +238,15 @@ export class LabTestController { // Lock in source code order using line number to prevent VS Code from reordering by status testItem.sortText = test.range.start.line.toString().padStart(6, "0"); + // Add visual indicator for .only and .skip modifiers + if (test.modifier === 'only') { + testItem.tags = [new vscode.TestTag('only')]; + testItem.description = '(only)'; + } else if (test.modifier === 'skip') { + testItem.tags = [new vscode.TestTag('skip')]; + testItem.description = '(skip)'; + } + this.testItemMap.set(testItem, test); parent.children.add(testItem); @@ -305,6 +314,17 @@ export class LabTestController { return; } + // Check if this test has a .skip modifier - if so, skip it without running + const parsedTest = this.testItemMap.get(item); + if (parsedTest?.modifier === 'skip') { + run.skipped(item); + // If it's a container (describe/experiment), skip all children too + if (item.children.size > 0) { + this.skipAllDescendants(item, run); + } + return; + } + // Check if this is a file item (no parent test, just contains tests) const isFileItem = !item.id.includes("#"); @@ -360,6 +380,21 @@ export class LabTestController { } } + /** + * Marks all descendant test items as skipped. + */ + private skipAllDescendants( + item: vscode.TestItem, + run: vscode.TestRun + ): void { + for (const [, child] of item.children) { + run.skipped(child); + if (child.children.size > 0) { + this.skipAllDescendants(child, run); + } + } + } + private collectTestItems( item: vscode.TestItem, collected: vscode.TestItem[] diff --git a/src/testParser.ts b/src/testParser.ts index fb86ff9..2a004cd 100644 --- a/src/testParser.ts +++ b/src/testParser.ts @@ -17,12 +17,14 @@ import * as vscode from 'vscode'; * @property type - The type of test function that was called * @property range - The source location range for displaying gutter icons * @property children - Nested tests (for describe/experiment blocks) + * @property modifier - Optional modifier applied to the test ('only' | 'skip') */ export interface ParsedTest { name: string; type: 'describe' | 'it' | 'experiment' | 'test'; range: vscode.Range; children: ParsedTest[]; + modifier?: 'only' | 'skip'; } /** Set of recognized test function names from @hapi/lab */ @@ -80,6 +82,7 @@ function extractTestsFromStatements(statements: TSESTree.Statement[]): ParsedTes /** * Extracts test info from an expression node if it's a test function call. * For describe/experiment blocks, recursively extracts children from the callback. + * Supports both simple calls (it(), test()) and modifier calls (it.only(), it.skip()). */ function extractTestFromExpression(node: TSESTree.Expression): ParsedTest | null { if (node.type !== AST_NODE_TYPES.CallExpression) { @@ -87,14 +90,44 @@ function extractTestFromExpression(node: TSESTree.Expression): ParsedTest | null } const callNode = node; + let functionName: string; + let modifier: 'only' | 'skip' | undefined; - // Check if this is a test function call - if ( - callNode.callee.type !== AST_NODE_TYPES.Identifier || - !TEST_FUNCTIONS.has(callNode.callee.name) || - callNode.arguments.length < 2 || - !callNode.loc - ) { + // Check if this is a simple test function call (e.g., it(), test()) + if (callNode.callee.type === AST_NODE_TYPES.Identifier) { + if (!TEST_FUNCTIONS.has(callNode.callee.name)) { + return null; + } + functionName = callNode.callee.name; + } + // Check if this is a member expression call (e.g., it.only(), it.skip()) + else if (callNode.callee.type === AST_NODE_TYPES.MemberExpression) { + const memberExpr = callNode.callee; + + // Check if the object is a test function identifier + if ( + memberExpr.object.type !== AST_NODE_TYPES.Identifier || + !TEST_FUNCTIONS.has(memberExpr.object.name) + ) { + return null; + } + + // Check if the property is 'only' or 'skip' + if ( + memberExpr.property.type !== AST_NODE_TYPES.Identifier || + (memberExpr.property.name !== 'only' && memberExpr.property.name !== 'skip') + ) { + return null; + } + + functionName = memberExpr.object.name; + modifier = memberExpr.property.name as 'only' | 'skip'; + } else { + return null; + } + + // Check if this call has the required arguments + if (callNode.arguments.length < 2 || !callNode.loc) { return null; } @@ -121,11 +154,11 @@ function extractTestFromExpression(node: TSESTree.Expression): ParsedTest | null callNode.loc.end.column ); - const testType = callNode.callee.name as ParsedTest['type']; + const testType = functionName as ParsedTest['type']; let children: ParsedTest[] = []; // For describe/experiment blocks, extract children from the callback - if (CONTAINER_FUNCTIONS.has(callNode.callee.name)) { + if (CONTAINER_FUNCTIONS.has(functionName)) { const callback = callNode.arguments[1]; if ( (callback.type === AST_NODE_TYPES.ArrowFunctionExpression || @@ -136,12 +169,18 @@ function extractTestFromExpression(node: TSESTree.Expression): ParsedTest | null } } - return { + const result: ParsedTest = { name: testName, type: testType, range, children, }; + + if (modifier) { + result.modifier = modifier; + } + + return result; } /** diff --git a/test/testParser.test.ts b/test/testParser.test.ts index e54e821..db47d7a 100644 --- a/test/testParser.test.ts +++ b/test/testParser.test.ts @@ -321,6 +321,195 @@ describe('testParser', () => { expect(tests[2].name).toBe('another standalone'); expect(tests[2].children).toHaveLength(0); }); + + // Test .only and .skip modifiers + describe('test modifiers (.only and .skip)', () => { + it('should parse it.only() tests', () => { + const code = ` + it.only('should run only this test', () => { + expect(true).toBe(true); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('should run only this test'); + expect(tests[0].type).toBe('it'); + expect(tests[0].modifier).toBe('only'); + }); + + it('should parse it.skip() tests', () => { + const code = ` + it.skip('should skip this test', () => { + expect(true).toBe(true); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('should skip this test'); + expect(tests[0].type).toBe('it'); + expect(tests[0].modifier).toBe('skip'); + }); + + it('should parse test.only() tests', () => { + const code = ` + test.only('should run only this test', () => { + expect(true).toBe(true); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('should run only this test'); + expect(tests[0].type).toBe('test'); + expect(tests[0].modifier).toBe('only'); + }); + + it('should parse test.skip() tests', () => { + const code = ` + test.skip('should skip this test', () => { + expect(true).toBe(true); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('should skip this test'); + expect(tests[0].type).toBe('test'); + expect(tests[0].modifier).toBe('skip'); + }); + + it('should parse describe.only() tests', () => { + const code = ` + describe.only('MyModule', () => { + it('should work', () => {}); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('MyModule'); + expect(tests[0].type).toBe('describe'); + expect(tests[0].modifier).toBe('only'); + expect(tests[0].children).toHaveLength(1); + expect(tests[0].children[0].modifier).toBeUndefined(); + }); + + it('should parse describe.skip() tests', () => { + const code = ` + describe.skip('MyModule', () => { + it('should work', () => {}); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('MyModule'); + expect(tests[0].type).toBe('describe'); + expect(tests[0].modifier).toBe('skip'); + expect(tests[0].children).toHaveLength(1); + }); + + it('should parse experiment.only() tests', () => { + const code = ` + experiment.only('My Experiment', () => { + test('should pass', () => {}); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('My Experiment'); + expect(tests[0].type).toBe('experiment'); + expect(tests[0].modifier).toBe('only'); + expect(tests[0].children).toHaveLength(1); + }); + + it('should parse experiment.skip() tests', () => { + const code = ` + experiment.skip('My Experiment', () => { + test('should pass', () => {}); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('My Experiment'); + expect(tests[0].type).toBe('experiment'); + expect(tests[0].modifier).toBe('skip'); + }); + + it('should parse mixed tests with and without modifiers', () => { + const code = ` + describe('MyModule', () => { + it('normal test', () => {}); + it.only('only this one', () => {}); + it.skip('skip this one', () => {}); + it('another normal test', () => {}); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(1); + expect(tests[0].name).toBe('MyModule'); + expect(tests[0].modifier).toBeUndefined(); + expect(tests[0].children).toHaveLength(4); + + expect(tests[0].children[0].name).toBe('normal test'); + expect(tests[0].children[0].modifier).toBeUndefined(); + + expect(tests[0].children[1].name).toBe('only this one'); + expect(tests[0].children[1].modifier).toBe('only'); + + expect(tests[0].children[2].name).toBe('skip this one'); + expect(tests[0].children[2].modifier).toBe('skip'); + + expect(tests[0].children[3].name).toBe('another normal test'); + expect(tests[0].children[3].modifier).toBeUndefined(); + }); + + it('should not parse invalid modifiers', () => { + const code = ` + it.focus('should not parse this', () => {}); + it.todo('should not parse this either', () => {}); + `; + + const tests = parseTestFile(code); + + // Should not parse tests with unsupported modifiers + expect(tests).toHaveLength(0); + }); + + it('should handle async tests with modifiers', () => { + const code = ` + it.only('async only test', async () => { + await Promise.resolve(); + }); + + it.skip('async skip test', async () => { + await Promise.resolve(); + }); + `; + + const tests = parseTestFile(code); + + expect(tests).toHaveLength(2); + expect(tests[0].name).toBe('async only test'); + expect(tests[0].modifier).toBe('only'); + expect(tests[1].name).toBe('async skip test'); + expect(tests[1].modifier).toBe('skip'); + }); + }); }); describe('escapeRegExp', () => {