Skip to content

Commit

Permalink
Test result streaming
Browse files Browse the repository at this point in the history
Tests are now always run through debug adapter (with or without `noDebug` flag). A custom ExUnit formatter is injected and custom DAP output events are used for notifications about test progress and result.
Test controller has been refactored to make it possible to run multiple tests at one go (workspace folder, file, module, describe, doctest granularity is supported).
Doctests rendering has been improved - now individual tests are rendered

Fixes #306
  • Loading branch information
lukaszsamson committed Jan 14, 2024
1 parent 6fbe227 commit 7e939ea
Show file tree
Hide file tree
Showing 4 changed files with 410 additions and 95 deletions.
2 changes: 1 addition & 1 deletion elixir-ls
Submodule elixir-ls updated 36 files
+24 −3 .github/workflows/ci.yml
+238 −0 apps/debug_adapter/lib/debug_adapter/exunit_formatter.ex
+4 −0 apps/debug_adapter/lib/debug_adapter/output.ex
+14 −5 apps/elixir_ls_utils/priv/launch.sh
+2 −0 apps/language_server/lib/language_server.ex
+20 −1 apps/language_server/lib/language_server/build.ex
+10 −2 apps/language_server/lib/language_server/diagnostics.ex
+6 −6 apps/language_server/lib/language_server/dialyzer.ex
+23 −13 apps/language_server/lib/language_server/dialyzer/success_typings.ex
+35 −26 apps/language_server/lib/language_server/ex_unit_test_tracer.ex
+33 −5 apps/language_server/lib/language_server/json_rpc.ex
+1 −0 apps/language_server/lib/language_server/mix_project_cache.ex
+285 −139 apps/language_server/lib/language_server/parser.ex
+2 −2 apps/language_server/lib/language_server/providers/code_lens/type_spec/contract_translator.ex
+68 −40 apps/language_server/lib/language_server/providers/completion.ex
+21 −7 apps/language_server/lib/language_server/providers/document_symbols.ex
+55 −46 apps/language_server/lib/language_server/providers/execute_command/apply_spec.ex
+4 −4 apps/language_server/lib/language_server/providers/execute_command/manipulate_pipes.ex
+8 −3 apps/language_server/lib/language_server/providers/execute_command/mix_clean.ex
+377 −226 apps/language_server/lib/language_server/providers/workspace_symbols.ex
+98 −57 apps/language_server/lib/language_server/server.ex
+2 −12 apps/language_server/lib/language_server/source_file.ex
+13 −0 apps/language_server/lib/language_server/tracer.ex
+0 −0 apps/language_server/test/fixtures/workspace_symbols/lib/workspace_symbols.ex
+15 −0 apps/language_server/test/fixtures/workspace_symbols/mix.exs
+4 −3 apps/language_server/test/providers/completion_test.exs
+103 −44 apps/language_server/test/providers/document_symbols_test.exs
+2 −2 apps/language_server/test/providers/execute_command/manipulate_pipes_test.exs
+14 −6 apps/language_server/test/providers/references_test.exs
+157 −109 apps/language_server/test/providers/workspace_symbols_test.exs
+1 −1 apps/language_server/test/support/fixtures/example_quoted_defs.ex
+1 −1 dep_versions.exs
+3 −3 guides/incomplete-installation.md
+1 −1 mix.lock
+16 −6 scripts/launch.fish
+14 −5 scripts/launch.sh
149 changes: 101 additions & 48 deletions src/commands/runTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ExecOptions, exec } from "child_process";
import * as vscode from "vscode";
import {
DebuggeeOutput,
Expand All @@ -7,49 +6,27 @@ import {
} from "../debugAdapter";
import { reporter } from "../telemetry";

type RunArgs = {
export type RunTestArgs = {
cwd: string;
filePath: string;
filePath?: string;
line?: number;
doctestLine?: number;
module?: string;
workspaceFolder: vscode.WorkspaceFolder;
getTest: (
file: string,
module: string,
describe: string | null,
name: string,
type: string
) => vscode.TestItem | undefined;
};

export default function runTest(
args: RunArgs,
debug: boolean
): Promise<string> {
return debug ? debugTest(args) : runTestWithoutDebug(args);
}

async function runTestWithoutDebug(args: RunArgs): Promise<string> {
reporter.sendTelemetryEvent("run_test", {
"elixir_ls.with_debug": "false",
});

const command = `mix test ${buildTestCommandArgs(args).join(" ")}`;

return new Promise((resolve, reject) => {
const options: ExecOptions = {
cwd: args.cwd,
env: {
...process.env,
MIX_ENV: "test",
},
};
exec(command, options, (error, stdout, stderr) => {
console.log("stdout", stdout);
console.log("stderr", stderr);
if (!error) {
resolve(stdout);
} else {
reject(stdout + (stderr ? "\n" + stderr : ""));
}
});
});
}

// Get the configuration for mix test, if it exists
function getTestConfig(args: RunArgs): vscode.DebugConfiguration | undefined {
function getExistingLaunchConfig(
args: RunTestArgs,
debug: boolean
): vscode.DebugConfiguration | undefined {
const launchJson = vscode.workspace.getConfiguration(
"launch",
args.workspaceFolder
Expand All @@ -70,19 +47,24 @@ function getTestConfig(args: RunArgs): vscode.DebugConfiguration | undefined {
MIX_ENV: "test",
...(testConfig.env ?? {}),
};
testConfig.taskArgs = [...buildTestCommandArgs(args), "--raise"];
// as of vscode 1.78 ANSI is not fully supported
testConfig.taskArgs = buildTestCommandArgs(args);
testConfig.requireFiles = [
"test/**/test_helper.exs",
"apps/*/test/**/test_helper.exs",
args.filePath,
];
testConfig.noDebug = !debug;
return testConfig;
}

// Get the config to use for debugging
function getDebugConfig(args: RunArgs): vscode.DebugConfiguration {
function getLaunchConfig(
args: RunTestArgs,
debug: boolean
): vscode.DebugConfiguration {
const fileConfiguration: vscode.DebugConfiguration | undefined =
getTestConfig(args);
getExistingLaunchConfig(args, debug);

const fallbackConfiguration: vscode.DebugConfiguration = {
type: "mix_task",
Expand All @@ -92,7 +74,7 @@ function getDebugConfig(args: RunArgs): vscode.DebugConfiguration {
env: {
MIX_ENV: "test",
},
taskArgs: [...buildTestCommandArgs(args), "--raise"],
taskArgs: buildTestCommandArgs(args),
startApps: true,
projectDir: args.cwd,
// we need to require all test helpers and only the file we need to test
Expand All @@ -103,6 +85,7 @@ function getDebugConfig(args: RunArgs): vscode.DebugConfiguration {
"apps/*/test/**/test_helper.exs",
args.filePath,
],
noDebug: !debug,
};

const config = fileConfiguration ?? fallbackConfiguration;
Expand All @@ -111,12 +94,19 @@ function getDebugConfig(args: RunArgs): vscode.DebugConfiguration {
return config;
}

async function debugTest(args: RunArgs): Promise<string> {
export async function runTest(
run: vscode.TestRun,
args: RunTestArgs,
debug: boolean
): Promise<string> {
reporter.sendTelemetryEvent("run_test", {
"elixir_ls.with_debug": "true",
});

const debugConfiguration: vscode.DebugConfiguration = getDebugConfig(args);
const debugConfiguration: vscode.DebugConfiguration = getLaunchConfig(
args,
debug
);

return new Promise((resolve, reject) => {
const listeners: Array<vscode.Disposable> = [];
Expand All @@ -133,7 +123,47 @@ async function debugTest(args: RunArgs): Promise<string> {
listeners.push(
trackerFactory.onOutput((outputEvent: DebuggeeOutput) => {
if (outputEvent.sessionId == sessionId) {
output.push(outputEvent.output);
const category = outputEvent.output.body.category;
if (category == "stdout" || category == "stderr") {
output.push(outputEvent.output.body.output);
} else if (category == "ex_unit") {
const exUnitEvent = outputEvent.output.body.data.event;
const data = outputEvent.output.body.data;
const test = args.getTest(
data.file,
data.module,
data.describe,
data.name,
data.type
);
if (test) {
if (exUnitEvent == "test_started") {
run.started(test);
} else if (exUnitEvent == "test_passed") {
run.passed(test, data.time / 1000);
} else if (exUnitEvent == "test_failed") {
run.failed(
test,
new vscode.TestMessage(data.message),
data.time / 1000
);
} else if (exUnitEvent == "test_errored") {
// ex_unit does not report duration for invalid tests
run.errored(test, new vscode.TestMessage(data.message));
} else if (
exUnitEvent == "test_skipped" ||
exUnitEvent == "test_excluded"
) {
run.skipped(test);
}
} else {
if (exUnitEvent != "test_excluded") {
console.warn(
`ElixirLS: Test ${data.file} ${data.module} ${data.describe} ${data.name} not found`
);
}
}
}
}
})
);
Expand Down Expand Up @@ -192,12 +222,35 @@ async function debugTest(args: RunArgs): Promise<string> {
});
}

function buildTestCommandArgs(args: RunArgs): string[] {
// as of vscode 1.78 ANSI is not fully supported
const COMMON_ARGS = [
"--no-color",
"--raise",
"--formatter",
"ElixirLS.DebugAdapter.ExUnitFormatter",
];

function buildTestCommandArgs(args: RunTestArgs): string[] {
let line = "";
if (typeof args.line === "number") {
line = `:${args.line}`;
}

// as of vscode 1.78 ANSI is not fully supported
return [`${args.filePath}${line}`, "--no-color"];
const result = [];

if (args.module) {
result.push("--only");
result.push(`module:${args.module}`);
}

if (args.doctestLine) {
result.push("--only");
result.push(`doctest_line:${args.doctestLine}`);
}

if (args.filePath) {
result.push(`${args.filePath}${line}`);
}

return [...result, ...COMMON_ARGS];
}
11 changes: 4 additions & 7 deletions src/debugAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface DebuggeeExited {

export interface DebuggeeOutput {
sessionId: string;
output: string;
output: DebugProtocol.OutputEvent;
}

class DebugAdapterTrackerFactory
Expand Down Expand Up @@ -169,15 +169,12 @@ class DebugAdapterTrackerFactory
const event = <DebugProtocol.Event>message;
if (event.event == "output") {
const outputEvent = <DebugProtocol.OutputEvent>message;
if (
outputEvent.body.category == "stdout" ||
outputEvent.body.category == "stderr"
) {
if (outputEvent.body.category != "telemetry") {
self._onOutput.fire({
sessionId: session.id,
output: outputEvent.body.output,
output: outputEvent,
});
} else if (outputEvent.body.category == "telemetry") {
} else {
const telemetryData = <TelemetryEvent>outputEvent.body.data;
if (telemetryData.name.endsWith("_error")) {
reporter.sendTelemetryErrorEvent(
Expand Down
Loading

0 comments on commit 7e939ea

Please sign in to comment.