Skip to content

Commit 3aa8408

Browse files
authored
feat: Add TypeScript/Jest test discovery and execution support (#60)
## Summary Implements TypeScript/Jest test discovery and execution for Bazel projects. ## Features ### Test Discovery - TypeScript test file identification (`.test.ts` files) - Test case discovery using AST-like parsing for `describe()` and `it()`/`test()` blocks - Auto-discovery: tests appear when opening test files ### Test Execution - ✅ **Run Test** - Click to run individual tests or suites - ✅ **Coverage** - Run with coverage support (LCOV format) - ✅ **Debug** - Breakpoint debugging with proper source map support ### Key Implementation Details - `TypeScriptLanguageTools` class for test parsing and result mapping - Lookup key generation for mapping BSP test results to UI test items - Proper handling of Jest XML test reports via BSP server - Build error vs test failure differentiation ## Debug Configuration For breakpoint debugging, projects need: 1. `tsconfig.json` with `sourceMap: true` 2. Bazel flags: `--spawn_strategy=local` (bypasses sandbox for consistent paths) ## Testing - All 179 tests passing - Tested with typescript-jest-test sample project - Verified: Run, Coverage, and Debug all working
1 parent cc3fe6a commit 3aa8408

File tree

12 files changed

+511
-42
lines changed

12 files changed

+511
-42
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ dist
66
.DS_Store
77
.bazelbsp
88
.bsp
9-
vscode-bazel-bsp-*.vsix
9+
vscode-bazel-bsp-*.vsix
10+
.npmrc

src/bsp/bsp-ext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ export namespace TestFinishDataKind {
6666
export interface JUnitStyleTestCaseData {
6767
time: number
6868
className?: string
69-
pkg?: string
70-
fullError?: string
69+
errorMessage?: string
70+
errorContent?: string
7171
errorType?: string
7272
}
7373

src/language-tools/manager.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {BuildTarget, TestFinish} from '../bsp/bsp'
55
import {PythonLanguageTools} from './python'
66
import {BaseLanguageTools} from './base'
77
import {JavaLanguageTools} from './java'
8+
import {TypeScriptLanguageTools} from './typescript'
89
import {TestCaseInfo} from '../test-info/test-info'
910

1011
/**
@@ -45,9 +46,12 @@ export class LanguageToolManager {
4546
private baseLanguageTools = new BaseLanguageTools()
4647
private pythonLanguageTools = new PythonLanguageTools()
4748
private javaLanguageTools = new JavaLanguageTools()
49+
private typescriptLanguageTools = new TypeScriptLanguageTools()
4850

4951
getLanguageTools(target: BuildTarget | undefined): LanguageTools {
50-
if (target?.languageIds.find(val => val === 'python')) {
52+
if (target?.languageIds.find(val => val === 'typescript')) {
53+
return this.typescriptLanguageTools
54+
} else if (target?.languageIds.find(val => val === 'python')) {
5155
return this.pythonLanguageTools
5256
} else if (target?.languageIds.find(val => val === 'java')) {
5357
return this.javaLanguageTools
@@ -56,7 +60,9 @@ export class LanguageToolManager {
5660
}
5761

5862
getLanguageToolsForFile(document: vscode.TextDocument): LanguageTools {
59-
if (document.languageId === 'python') {
63+
if (document.languageId === 'typescript') {
64+
return this.typescriptLanguageTools
65+
} else if (document.languageId === 'python') {
6066
return this.pythonLanguageTools
6167
} else if (document.languageId === 'java') {
6268
return this.javaLanguageTools

src/language-tools/typescript.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import * as vscode from 'vscode'
2+
import * as path from 'path'
3+
4+
import {DocumentTestItem, LanguageTools, TestFileContents} from './manager'
5+
import {TestFinish} from '../bsp/bsp'
6+
import {SourceFileTestCaseInfo, TestCaseInfo} from '../test-info/test-info'
7+
import {BaseLanguageTools} from './base'
8+
import {JUnitStyleTestCaseData, TestFinishDataKind} from '../bsp/bsp-ext'
9+
import * as bsp from '../bsp/bsp'
10+
11+
const TEST_FILE_REGEX = /^.+\.(test|spec)\.ts$/
12+
13+
export class TypeScriptLanguageTools
14+
extends BaseLanguageTools
15+
implements LanguageTools
16+
{
17+
static inferSourcesFromJestTarget(
18+
targetUri: string,
19+
baseDirectory: string | undefined
20+
): bsp.SourcesResult | undefined {
21+
if (!targetUri.endsWith('_jest') || !baseDirectory) {
22+
return undefined
23+
}
24+
25+
const colonIndex = targetUri.lastIndexOf(':')
26+
if (colonIndex === -1) {
27+
return undefined
28+
}
29+
30+
const targetName = targetUri.slice(colonIndex + 1, -5)
31+
const isTestFile = targetName.includes('_test_')
32+
const baseName = targetName
33+
.replace(/_spec_ts_library$/, '')
34+
.replace(/_test_ts_library$/, '')
35+
.replace(/_ts_library$/, '')
36+
.replace(/_spec$/, '')
37+
.replace(/_test$/, '')
38+
const extension = isTestFile ? '.test.ts' : '.spec.ts'
39+
const fileName = baseName.replace(/_/g, '-') + extension
40+
const fileUri = `${baseDirectory}/${fileName}`
41+
42+
return {
43+
items: [
44+
{
45+
target: {uri: targetUri},
46+
sources: [
47+
{
48+
uri: fileUri,
49+
kind: bsp.SourceItemKind.File,
50+
generated: false,
51+
},
52+
],
53+
roots: [],
54+
},
55+
],
56+
}
57+
}
58+
mapTestFinishDataToLookupKey(testFinishData: TestFinish): string | undefined {
59+
if (
60+
testFinishData.dataKind === TestFinishDataKind.JUnitStyleTestCaseData &&
61+
testFinishData.data
62+
) {
63+
const testCaseData = testFinishData.data as JUnitStyleTestCaseData
64+
return testCaseData.className || testFinishData.displayName
65+
}
66+
return undefined
67+
}
68+
69+
mapTestCaseInfoToLookupKey(testCaseInfo: TestCaseInfo): string | undefined {
70+
if (!(testCaseInfo instanceof SourceFileTestCaseInfo)) {
71+
return undefined
72+
}
73+
74+
const data = testCaseInfo.getDocumentTestItem()
75+
return data?.lookupKey
76+
}
77+
78+
async getDocumentTestCases(
79+
document: vscode.Uri,
80+
workspaceRoot: string
81+
): Promise<TestFileContents> {
82+
if (!TEST_FILE_REGEX.test(path.basename(document.fsPath))) {
83+
return {
84+
isTestFile: false,
85+
testCases: [],
86+
}
87+
}
88+
89+
const fileContents = await vscode.workspace.fs.readFile(document)
90+
const text = fileContents.toString()
91+
const lines = text.split('\n')
92+
93+
const testCases: DocumentTestItem[] = []
94+
const documentTest: DocumentTestItem = {
95+
name: path.basename(document.fsPath),
96+
range: new vscode.Range(0, 0, 0, 0),
97+
uri: document,
98+
testFilter: '',
99+
}
100+
101+
const describeStack: DocumentTestItem[] = []
102+
const indentStack: number[] = []
103+
104+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
105+
const line = lines[lineNum]
106+
const indent = line.search(/\S/)
107+
108+
if (indent === -1) continue
109+
110+
while (
111+
indentStack.length > 0 &&
112+
indent <= indentStack[indentStack.length - 1]
113+
) {
114+
describeStack.pop()
115+
indentStack.pop()
116+
}
117+
118+
const describeMatch = line.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/)
119+
if (describeMatch) {
120+
const position = new vscode.Position(lineNum, 0)
121+
const describeName = describeMatch[1]
122+
const parent =
123+
describeStack.length > 0
124+
? describeStack[describeStack.length - 1]
125+
: undefined
126+
127+
const lookupKey = parent?.lookupKey
128+
? `${parent.lookupKey} ${describeName}`
129+
: describeName
130+
131+
const describeItem: DocumentTestItem = {
132+
name: describeName,
133+
range: new vscode.Range(position, position),
134+
uri: document,
135+
testFilter: describeName,
136+
parent: parent,
137+
lookupKey: lookupKey,
138+
}
139+
testCases.push(describeItem)
140+
describeStack.push(describeItem)
141+
indentStack.push(indent)
142+
continue
143+
}
144+
145+
const testMatch = line.match(/(?:it|test)\s*\(\s*['"`]([^'"`]+)['"`]/)
146+
if (testMatch) {
147+
const position = new vscode.Position(lineNum, 0)
148+
const testName = testMatch[1]
149+
const parent =
150+
describeStack.length > 0
151+
? describeStack[describeStack.length - 1]
152+
: undefined
153+
154+
const lookupKey = parent?.lookupKey
155+
? `${parent.lookupKey} ${testName}`
156+
: testName
157+
158+
const testItem: DocumentTestItem = {
159+
name: testName,
160+
range: new vscode.Range(position, position),
161+
uri: document,
162+
testFilter: testName,
163+
parent: parent,
164+
lookupKey: lookupKey,
165+
}
166+
testCases.push(testItem)
167+
}
168+
}
169+
170+
return {
171+
isTestFile: true,
172+
testCases: testCases,
173+
documentTest: documentTest,
174+
}
175+
}
176+
}

src/test-explorer/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {EXTENSION_CONTEXT_TOKEN} from '../custom-providers'
88
import {Deferred, Utils} from '../utils/utils'
99
const pkg = require('../../package.json')
1010

11-
const SUPPORTED_LANGUAGES = ['java', 'scala', 'kotlin', 'python']
11+
const SUPPORTED_LANGUAGES = ['java', 'scala', 'kotlin', 'python', 'typescript']
1212

1313
/**
1414
* To intercept notifications from a specific originId, define custom handlers using this interface.

src/test-explorer/resolver.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ import {
1818
import {getExtensionSetting, SettingName} from '../utils/settings'
1919
import {Utils} from '../utils/utils'
2020
import {TestItemFactory} from '../test-info/test-item-factory'
21-
import {DocumentTestItem, LanguageToolManager} from '../language-tools/manager'
21+
import {
22+
DocumentTestItem,
23+
LanguageToolManager,
24+
TestFileContents,
25+
} from '../language-tools/manager'
2226
import {SyncHintDecorationsManager} from './decorator'
27+
import {TypeScriptLanguageTools} from '../language-tools/typescript'
2328

2429
@Injectable()
2530
export class TestResolver implements OnModuleInit, vscode.Disposable {
@@ -412,6 +417,7 @@ export class TestResolver implements OnModuleInit, vscode.Disposable {
412417
return
413418
}
414419
}
420+
415421
this.syncHint.enable(doc.uri, this.repoRoot ?? '', docInfo)
416422
}
417423

@@ -434,11 +440,33 @@ export class TestResolver implements OnModuleInit, vscode.Disposable {
434440
const params: bsp.SourcesParams = {
435441
targets: [parentTarget.id],
436442
}
437-
const result = await conn.sendRequest(
443+
let result = await conn.sendRequest(
438444
bsp.BuildTargetSources.type,
439445
params,
440446
cancellationToken
441447
)
448+
449+
const hasSources = result.items.some(item => item.sources.length > 0)
450+
451+
if (!hasSources && parentTarget.dependencies.length === 0) {
452+
const inferredResult = TypeScriptLanguageTools.inferSourcesFromJestTarget(
453+
parentTarget.id.uri,
454+
parentTarget.baseDirectory
455+
)
456+
if (inferredResult) {
457+
result = inferredResult
458+
}
459+
} else if (!hasSources && parentTarget.dependencies.length > 0) {
460+
const depParams: bsp.SourcesParams = {
461+
targets: parentTarget.dependencies,
462+
}
463+
result = await conn.sendRequest(
464+
bsp.BuildTargetSources.type,
465+
depParams,
466+
cancellationToken
467+
)
468+
}
469+
442470
this.store.cacheSourcesResult(params, result)
443471
await this.processTargetSourcesResult(parentTest, result)
444472
}

src/test-info/test-info.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,19 @@ export class BuildTargetTestCaseInfo extends TestCaseInfo {
157157
// The executed test item inherits the overall run status.
158158
updateStatus(this.testItem, currentRun, result)
159159

160-
// Remaining items, except those at a level that should execute independently, are marked to inherit the results from their children.
160+
// Update remaining children that are more specific than this item's type.
161+
// Other targets are left in a pending state so they can run on their own.
161162
for (const child of currentRun.pendingChildrenIterator(
162163
this.testItem,
163164
TestItemType.BazelTarget
164165
)) {
165-
// Only update children that are more specific than this item's type.
166-
// This will leave other targets in a pending state so they can run on their own.
167-
currentRun.updateStatus(child.testItem, TestCaseStatus.Inherit)
166+
if (result.statusCode === StatusCode.Error) {
167+
// On error, mark children that didn't receive individual results as failed.
168+
currentRun.updateStatus(child.testItem, TestCaseStatus.Failed)
169+
} else {
170+
// On success, let Test Explorer determine status based on children's outcomes.
171+
currentRun.updateStatus(child.testItem, TestCaseStatus.Inherit)
172+
}
168173
}
169174
}
170175

@@ -173,9 +178,13 @@ export class BuildTargetTestCaseInfo extends TestCaseInfo {
173178
* @param relativeToItem will be ignored in this implementation
174179
*/
175180
setDisplayName(relativeToItem?: TestCaseInfo | undefined) {
176-
this.testItem.label =
177-
this.target.id.uri.split(':').pop() ?? this.target.id.uri
178-
this.testItem.description = this.target.displayName
181+
if (this.target.languageIds?.includes('typescript')) {
182+
this.testItem.label = this.target.displayName ?? this.target.id.uri
183+
} else {
184+
this.testItem.label =
185+
this.target.id.uri.split(':').pop() ?? this.target.id.uri
186+
this.testItem.description = this.target.displayName
187+
}
179188
}
180189
}
181190

src/test-runner/run-tracker.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,11 +527,14 @@ function formatTestResultMessage(
527527
if (result.displayName) {
528528
message += `${ANSI_CODES.RED}[TEST CASE]${ANSI_CODES.RESET} ${result.displayName}\n\n`
529529
}
530-
if (testCaseData.errorType && testCaseData.fullError !== 'null') {
530+
if (testCaseData.errorType && testCaseData.errorType !== 'null') {
531531
message += `${ANSI_CODES.RED}[ERROR TYPE]${ANSI_CODES.RESET} ${testCaseData.errorType}\n\n`
532532
}
533-
if (testCaseData.fullError && testCaseData.fullError !== 'null') {
534-
message += `${ANSI_CODES.RED}[FULL ERROR]${ANSI_CODES.RESET}\n\n${testCaseData.fullError}\n\n`
533+
if (testCaseData.errorMessage && testCaseData.errorMessage !== 'null') {
534+
message += `${ANSI_CODES.RED}[ERROR]${ANSI_CODES.RESET} ${testCaseData.errorMessage}\n\n`
535+
}
536+
if (testCaseData.errorContent && testCaseData.errorContent !== 'null') {
537+
message += `${ANSI_CODES.RED}[FULL ERROR]${ANSI_CODES.RESET}\n\n${testCaseData.errorContent}\n\n`
535538
}
536539
}
537540

0 commit comments

Comments
 (0)