Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/testController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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("#");

Expand Down Expand Up @@ -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[]
Expand Down
59 changes: 49 additions & 10 deletions src/testParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -80,21 +82,52 @@ 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) {
return 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;
}

Expand All @@ -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 ||
Expand All @@ -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;
}

/**
Expand Down
189 changes: 189 additions & 0 deletions test/testParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading