Skip to content

Commit 84fda0b

Browse files
feat: use IPC for granular test reporting (#43)
Resolves #26
1 parent 7e9d0ea commit 84fda0b

15 files changed

+492
-114
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ module.exports = {
5555
'FOO_BAR': 'baz',
5656
},
5757

58+
// optional, whether or not to filter console output
59+
// if unspecified, defaults to false
60+
// if true, filters all output to stdout and stderr, except for the download
61+
// of the VS Code executable, to only show the output of console.log(),
62+
// console.error(), console.warn(), and console.info() calls made by tests.
63+
//
64+
// NOTE: This will not display output if you require() or import the console
65+
// API in your tests, as only the global console object is overridden. It also
66+
// will not work if you are using lower-level APIs such as
67+
// process.stdout.write().
68+
filterOutput: true,
69+
5870
// optional, additional arguments to pass to VS Code
5971
launchArgs: [
6072
'--new-window',

e2e/__tests__/__snapshots__/passing-tests.ts.snap

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ Object {
55
"numFailedTestSuites": 0,
66
"numFailedTests": 0,
77
"numPassedTestSuites": 1,
8-
"numPassedTests": 2,
8+
"numPassedTests": 3,
99
"numPendingTestSuites": 0,
1010
"numPendingTests": 0,
1111
"numRuntimeErrorTestSuites": 0,
1212
"numTodoTests": 0,
1313
"numTotalTestSuites": 1,
14-
"numTotalTests": 2,
14+
"numTotalTests": 3,
1515
"openHandles": Array [],
1616
"snapshot": Object {
1717
"added": 0,
@@ -54,6 +54,16 @@ Object {
5454
"status": "passed",
5555
"title": "should test async",
5656
},
57+
Object {
58+
"ancestorTitles": Array [
59+
"Describe",
60+
],
61+
"failureMessages": Array [],
62+
"fullName": "Describe should test with console.log",
63+
"location": null,
64+
"status": "passed",
65+
"title": "should test with console.log",
66+
},
5767
],
5868
"endTime": undefined,
5969
"failureMessage": "",

e2e/__tests__/passing-tests.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('Passing tests', () => {
1515
console.error('stdout', stdout)
1616
}
1717
expect(json).toMatchSnapshot()
18+
expect(stdout).toContain('This message was logged from the test file')
1819
expect(exitCode).toBe(0)
1920
}, 30000)
2021
})

e2e/jest-runner-vscode.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
/** @type {import('../src/types').JestVSCodeRunnerOptions} */
1+
/** @type {import('../src/types').RunnerOptions} */
22
const config = {
33
version: '1.56.2',
44
launchArgs: ['--disable-extensions'],
5+
filterOutput: true,
56
}
67

78
module.exports = config

e2e/passing-tests/__tests__/test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ describe('Describe', () => {
66
it('should test async', async () => {
77
expect(true).toBe(true)
88
})
9+
10+
it('should test with console.log', () => {
11+
console.log('This message was logged from the test file')
12+
expect(true).toBe(true)
13+
})
914
})

src/child-process-runner.ts

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import type { RemoteTestOptions, RemoteTestResults } from './types'
1+
import type { RemoteTestOptions } from './types'
22
import * as jest from '@jest/core'
33
import type { buildArgv as buildArgvType } from 'jest-cli/build/cli/index'
44
import vscode from 'vscode'
55
import path from 'path'
66
import process from 'process'
7-
import { IPC } from 'node-ipc'
7+
import IPCClient from './ipc-client'
88

99
// eslint-disable-next-line @typescript-eslint/no-var-requires
1010
const buildArgv: typeof buildArgvType = require(path.resolve(
@@ -17,29 +17,15 @@ const vscodeModulePath = require.resolve('./jest-vscode-module')
1717
const moduleNameMapper = JSON.stringify({ '^vscode$': vscodeModulePath })
1818

1919
export async function run(): Promise<void> {
20-
const { IPC_CHANNEL, PARENT_JEST_OPTIONS } = process.env
21-
22-
if (!IPC_CHANNEL) {
23-
throw new Error('IPC_CHANNEL is not defined')
24-
}
25-
26-
const ipc = new IPC()
27-
28-
ipc.config.silent = true
29-
ipc.config.id = `jest-runner-vscode-client-${process.pid}`
30-
31-
await new Promise<void>(resolve =>
32-
ipc.connectTo(IPC_CHANNEL, () => {
33-
ipc.of[IPC_CHANNEL].on('connect', resolve)
34-
})
35-
)
20+
const ipc = new IPCClient('child')
3621

3722
const disconnected = new Promise<void>(resolve =>
38-
ipc.of[IPC_CHANNEL].on('disconnect', resolve)
23+
ipc.on('disconnect', resolve)
3924
)
4025

41-
let response: RemoteTestResults
4226
try {
27+
const { PARENT_JEST_OPTIONS } = process.env
28+
4329
if (!PARENT_JEST_OPTIONS) {
4430
throw new Error('PARENT_JEST_OPTIONS is not defined')
4531
}
@@ -51,30 +37,20 @@ export async function run(): Promise<void> {
5137
'--runner=jest-runner',
5238
`--env=${vscodeTestEnvPath}`,
5339
`--moduleNameMapper=${moduleNameMapper}`,
40+
`--reporters=${require.resolve('./child-reporter')}`,
5441
...(options.globalConfig.updateSnapshot === 'all' ? ['-u'] : []),
5542
'--runTestsByPath',
5643
...options.testPaths,
5744
])
5845

59-
const { results } =
60-
(await jest.runCLI(jestOptions, [options.globalConfig.rootDir])) ?? {}
61-
62-
response = {
63-
is: 'ok',
64-
results,
65-
}
46+
await jest.runCLI(jestOptions, [options.globalConfig.rootDir])
6647
} catch (error: any) {
6748
const errorObj = JSON.parse(
6849
JSON.stringify(error, Object.getOwnPropertyNames(error))
6950
)
70-
response = {
71-
is: 'error',
72-
error: errorObj,
73-
}
51+
ipc.emit('error', errorObj)
7452
}
7553

76-
ipc.of[IPC_CHANNEL].emit('test-results', response)
77-
ipc.disconnect(IPC_CHANNEL)
78-
await disconnected
54+
await Promise.race([disconnected, ipc.disconnect()])
7955
await vscode.commands.executeCommand('workbench.action.closeWindow')
8056
}

src/child-reporter.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type {
2+
AggregatedResult,
3+
TestCaseResult,
4+
TestResult,
5+
} from '@jest/test-result'
6+
import type {
7+
ReporterOnStartOptions,
8+
Test,
9+
Reporter,
10+
Context,
11+
} from '@jest/reporters'
12+
import IPCClient from './ipc-client'
13+
import wrapIO from './wrap-io'
14+
15+
export default class ChildReporter implements Reporter {
16+
#ipc: IPCClient
17+
#onConnected: Promise<void>
18+
19+
constructor() {
20+
this.#ipc = new IPCClient('reporter')
21+
this.#onConnected = this.#ipc.connect()
22+
23+
wrapIO(this.#ipc)
24+
}
25+
26+
getLastError() {
27+
return undefined
28+
}
29+
30+
onTestResult(
31+
test: Test,
32+
testResult: TestResult,
33+
aggregatedResult: AggregatedResult
34+
): void {
35+
this.#ipc.emit('testResult', {
36+
test,
37+
testResult,
38+
aggregatedResult,
39+
})
40+
}
41+
42+
onTestFileResult(
43+
test: Test,
44+
testResult: TestResult,
45+
aggregatedResult: AggregatedResult
46+
): void {
47+
this.#ipc.emit('testFileResult', {
48+
test,
49+
testResult,
50+
aggregatedResult,
51+
})
52+
}
53+
54+
onTestCaseResult(test: Test, testCaseResult: TestCaseResult): void {
55+
this.#ipc.emit('testCaseResult', {
56+
test,
57+
testCaseResult,
58+
})
59+
}
60+
61+
onRunStart(
62+
aggregatedResult: AggregatedResult,
63+
options: ReporterOnStartOptions
64+
): void {
65+
this.#ipc.emit('runStart', {
66+
aggregatedResult,
67+
options,
68+
})
69+
}
70+
71+
onTestStart(test: Test): void {
72+
this.#ipc.emit('testStart', { test })
73+
}
74+
75+
onTestFileStart(test: Test): void {
76+
this.#ipc.emit('testFileStart', { test })
77+
}
78+
79+
async onRunComplete(
80+
contexts: Set<Context>,
81+
results: AggregatedResult
82+
): Promise<void> {
83+
this.#ipc.emit('runComplete', { contexts, results })
84+
85+
await this.#onConnected
86+
await this.#ipc.disconnect()
87+
}
88+
}

src/ipc-client.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import console from 'console'
2+
import EventEmitter from 'events'
3+
import { IPC } from 'node-ipc'
4+
import MessageWriter from './message-writer'
5+
6+
export default class IPCClient {
7+
#ipc: InstanceType<typeof IPC>
8+
#ipcChannel: string
9+
#messageQueue: Array<[string, unknown]> = []
10+
#writer: MessageWriter
11+
#connected = false
12+
#promises: Set<Promise<void>> = new Set()
13+
#emitter: EventEmitter = new EventEmitter()
14+
15+
get on(): EventEmitter['on'] {
16+
return this.#emitter.on.bind(this.#emitter)
17+
}
18+
19+
get once(): EventEmitter['once'] {
20+
return this.#emitter.once.bind(this.#emitter)
21+
}
22+
23+
get off(): EventEmitter['off'] {
24+
return this.#emitter.off.bind(this.#emitter)
25+
}
26+
27+
get removeListener(): EventEmitter['removeListener'] {
28+
return this.#emitter.removeListener.bind(this.#emitter)
29+
}
30+
31+
get removeAllListeners(): EventEmitter['removeAllListeners'] {
32+
return this.#emitter.removeAllListeners.bind(this.#emitter)
33+
}
34+
35+
constructor(id: string) {
36+
const { IPC_CHANNEL, DEBUG_VSCODE_IPC } = process.env
37+
38+
if (!IPC_CHANNEL) {
39+
throw new Error('IPC_CHANNEL is not defined')
40+
}
41+
42+
this.#ipcChannel = IPC_CHANNEL
43+
44+
this.#ipc = new IPC()
45+
this.#ipc.config.silent = !DEBUG_VSCODE_IPC
46+
this.#ipc.config.id = `jest-runner-vscode-${id}-${process.pid}`
47+
this.#ipc.config.logger = (message: string) => {
48+
// keep message no longer than 500 characters
49+
const truncatedMessage =
50+
message.length > 500 ? `${message.slice(0, 500)}...\u001b[0m` : message
51+
52+
console.log(truncatedMessage)
53+
}
54+
55+
this.#writer = new MessageWriter(this.#ipc, this.#ipcChannel)
56+
}
57+
58+
async #flush(): Promise<void> {
59+
while (this.#messageQueue.length) {
60+
const message = this.#messageQueue.shift()
61+
62+
if (message) {
63+
const [type, data] = message
64+
65+
await this.#writer.write(type, data)
66+
67+
this.#emitter.emit(type, data)
68+
}
69+
}
70+
}
71+
72+
async connect(): Promise<void> {
73+
return new Promise<void>(resolve => {
74+
this.#ipc.connectTo(this.#ipcChannel, async () => {
75+
this.#connected = true
76+
77+
await this.#flush()
78+
79+
this.#emitter.emit('connect')
80+
81+
this.#ipc.of[this.#ipcChannel].on('disconnect', () => {
82+
this.#connected = false
83+
this.#emitter.emit('disconnect')
84+
})
85+
86+
resolve()
87+
})
88+
})
89+
}
90+
91+
async disconnect(): Promise<void> {
92+
if (!this.#connected) {
93+
return
94+
}
95+
96+
await Promise.all(this.#promises)
97+
98+
const disconnected = new Promise<void>(resolve => {
99+
this.#emitter.once('disconnect', resolve)
100+
})
101+
102+
this.#ipc.disconnect(this.#ipcChannel)
103+
104+
return disconnected
105+
}
106+
107+
emit(type: string, data: unknown): void {
108+
if (!this.#connected) {
109+
this.#messageQueue.push([type, data])
110+
} else {
111+
const promise = this.#writer.write(type, data)
112+
113+
this.#promises.add(promise)
114+
promise.then(() => this.#promises.delete(promise))
115+
}
116+
}
117+
}

src/jest-vscode-env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import NodeEnvironment from 'jest-environment-node'
22
import vscode from 'vscode'
3+
import IPCClient from './ipc-client'
4+
import wrapIO from './wrap-io'
5+
6+
const ipc = new IPCClient('env')
37

48
class VSCodeEnvironment extends NodeEnvironment {
59
async setup() {
610
await super.setup()
711
this.global.vscode = vscode
12+
await ipc.connect()
13+
await wrapIO(ipc, this.global)
814
}
915

1016
async teardown() {
1117
this.global.vscode = {}
18+
await ipc.disconnect()
1219
await super.teardown()
1320
}
1421
}

src/js-message.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
declare module 'js-message' {
2+
export default class Message {
3+
type: string
4+
data: unknown
5+
}
6+
}

0 commit comments

Comments
 (0)