Skip to content

Commit c65784c

Browse files
committed
Add test results data viz
1 parent 31fe0cb commit c65784c

5 files changed

Lines changed: 346 additions & 2 deletions

File tree

.github/workflows/bat.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ jobs:
1818
uses: matlab-actions/setup-matlab@v2
1919
with:
2020
products: |
21+
MATLAB_Test
2122
Simulink
2223
Simulink_Test
2324
Simulink_Coverage
2425
26+
- name: Run MATLAB Command for test results summary
27+
uses: matlab-actions/run-command@v2
28+
with:
29+
command: "import matlab.unittest.TestRunner; addpath(genpath('code')); suite = testsuite(pwd, 'IncludeSubfolders', true); runner = TestRunner.withDefaultPlugins(); results = runner.run(suite); display(results); assertSuccess(results);"
30+
2531
- name: Run MATLAB Tests
2632
uses: ./
2733
with:

src/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as core from "@actions/core";
44
import * as exec from "@actions/exec";
55
import { matlab } from "run-matlab-command-action";
66
import * as scriptgen from "./scriptgen";
7+
import * as testResultsSummary from "./testResultsSummary";
8+
import * as path from "path";
79

810
/**
911
* Gather action inputs and then run action.
@@ -28,7 +30,9 @@ async function run() {
2830
LoggingLevel: core.getInput("logging-level"),
2931
};
3032

31-
const command = scriptgen.generateCommand(options);
33+
// const command = scriptgen.generateCommand(options);
34+
const pluginsPath = path.join(__dirname, "src/resources").replace("'","''");
35+
const command = "addpath('"+ pluginsPath +"');" + "import matlab.unittest.TestRunner; addpath(genpath('code')); suite = testsuite(pwd, 'IncludeSubfolders', true); runner = TestRunner.withDefaultPlugins(); results = runner.run(suite); display(results); assertSuccess(results);";
3236
const startupOptions = core.getInput("startup-options").split(" ");
3337

3438
const helperScript = await core.group("Generate script", async () => {
@@ -38,7 +42,11 @@ async function run() {
3842
});
3943

4044
await core.group("Run command", async () => {
41-
await matlab.runCommand(helperScript, platform, architecture, exec.exec, startupOptions);
45+
await matlab.runCommand(helperScript, platform, architecture, exec.exec, startupOptions).finally(() => {
46+
// buildSummary.processAndDisplayBuildSummary();
47+
const { testResults, counts } = testResultsSummary.getTestResults("");
48+
testResultsSummary.writeSummary(testResults, counts);
49+
});
4250
});
4351
}
4452

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
classdef TestResultsSummaryPlugin < matlab.unittest.plugins.TestRunnerPlugin
2+
% Copyright 2025 The MathWorks, Inc.
3+
4+
methods (Access=protected)
5+
function reportFinalizedSuite(plugin, pluginData)
6+
% Checkout MATLAB Test license
7+
license('checkout', 'matlab_test');
8+
9+
testDetails = struct([]);
10+
for idx = 1:numel(pluginData.TestResult)
11+
testDetails(idx).TestResult = pluginData.TestResult(idx);
12+
testDetails(idx).BaseFolder = pluginData.TestSuite(idx).BaseFolder;
13+
end
14+
15+
% If test results artifact exists, update the same file
16+
testArtifactFile = fullfile(getenv("RUNNER_TEMP"),"matlabTestResults" + getenv("GITHUB_RUN_ID") + ".json");
17+
if isfile(testArtifactFile)
18+
testResults = {jsondecode(fileread(testArtifactFile))};
19+
else
20+
testResults = {};
21+
end
22+
testResults{end+1} = testDetails;
23+
JsonTestResults = jsonencode(testResults, "PrettyPrint", true);
24+
25+
[fID, msg] = fopen(testArtifactFile, "w");
26+
if fID == -1
27+
warning("ciplugins:github:TestResultsSummaryPlugin:UnableToOpenFile","Could not open a file for GitHub tests result table due to: %s", msg);
28+
else
29+
closeFile = onCleanup(@()fclose(fID));
30+
fprintf(fID, '%s', JsonTestResults);
31+
end
32+
33+
% Invoke the superclass method
34+
reportFinalizedSuite@matlab.unittest.plugins.TestRunnerPlugin(plugin, pluginData);
35+
end
36+
end
37+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
classdef TestResultsSummaryPluginService < matlab.buildtool.internal.services.ciplugins.CITestRunnerPluginService
2+
% Copyright 2025 The MathWorks, Inc.
3+
4+
methods
5+
function plugins = providePlugins(~, ~)
6+
% Check if MATLAB Test license is available
7+
if license('test', 'matlab_test')
8+
plugins = ciplugins.github.TestResultsSummaryPlugin();
9+
else
10+
plugins = matlab.unittest.plugins.TestRunnerPlugin.empty(1,0);
11+
end
12+
end
13+
end
14+
end

src/testResultsSummary.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Copyright 2025 The MathWorks, Inc.
2+
import { readFileSync, unlinkSync, existsSync } from 'fs';
3+
import * as path from 'path';
4+
import * as core from "@actions/core";
5+
6+
// doesn't have id
7+
// number instead of float
8+
9+
export enum MatlabTestStatus {
10+
PASSED = 'PASSED',
11+
FAILED = 'FAILED',
12+
INCOMPLETE = 'INCOMPLETE',
13+
NOT_RUN = 'NOT_RUN'
14+
}
15+
16+
interface MatlabTestDiagnostics {
17+
event: string;
18+
report: string;
19+
}
20+
21+
interface MatlabTestCase {
22+
name: string;
23+
duration: number;
24+
status: MatlabTestStatus;
25+
diagnostics: MatlabTestDiagnostics[];
26+
}
27+
28+
interface MatlabTestFile {
29+
name: string;
30+
path: string;
31+
testCases: MatlabTestCase[];
32+
duration: number;
33+
status: MatlabTestCase['status'];
34+
}
35+
36+
interface TestCounts {
37+
total: number;
38+
passed: number;
39+
failed: number;
40+
incomplete: number;
41+
notRun: number;
42+
}
43+
44+
interface TestResultsData {
45+
testResults: MatlabTestFile[][];
46+
counts: TestCounts;
47+
}
48+
49+
export function writeSummary(testResults: MatlabTestFile[][], counts: TestCounts) {
50+
try {
51+
const header = getTestHeader(testResults, counts);
52+
const detailedResults = getDetailedResults(testResults);
53+
const failedTests = getFailedTests(testResults);
54+
55+
core.summary
56+
.addHeading('MATLAB Test Results')
57+
.addRaw(header, true)
58+
.addRaw(detailedResults, true)
59+
.addRaw(failedTests, true)
60+
.write();
61+
} catch (e) {
62+
console.error('An error occurred while adding the test results to the summary:', e);
63+
}
64+
}
65+
66+
function getTestHeader(testResults: MatlabTestFile[][], counts: TestCounts): string {
67+
return `<table>
68+
<tr align="center">
69+
<th>Total tests</th>
70+
<th>Passed ✅</th>
71+
<th>Failed ❌</th>
72+
<th>Incomplete ⚠️</th>
73+
<th>Not Run 🚫</th>
74+
<th>Duration(s) ⌛</th>
75+
</tr>
76+
<tr align="center">
77+
<td>${counts.total}</td>
78+
<td>${counts.passed}</td>
79+
<td>${counts.failed}</td>
80+
<td>${counts.incomplete}</td>
81+
<td>${counts.notRun}</td>
82+
// <td>${calculateTotalDuration(testResults)}</td>
83+
</tr>
84+
</table>`;
85+
}
86+
87+
function getDetailedResults(testResults: MatlabTestFile[][]): string {
88+
return `<details><summary><h3>All tests</h3></summary>
89+
<table>
90+
<tr>
91+
<th>Test</th>
92+
<th>Duration(s)</th>
93+
</tr>
94+
${testResults.flat().map(file => generateTestFileRow(file)).join('\n')}
95+
</table>
96+
</details>`;
97+
}
98+
99+
function generateTestFileRow(file: MatlabTestFile): string {
100+
const statusEmoji = getStatusEmoji(file.status);
101+
return `
102+
<tr>
103+
<td>
104+
<details>
105+
<summary><b>${statusEmoji} ${file.name}</b></summary>
106+
<ul style="list-style-type: none;">
107+
${file.testCases.map(tc => `<li>${getStatusEmoji(tc.status)} ${tc.name}</li>`).join('\n')}
108+
</ul>
109+
</details>
110+
<td align="center" valign="top"><b>${file.duration.toFixed(2)}</b>
111+
</td>
112+
</tr>`;
113+
}
114+
115+
function getFailedTests(testResults: MatlabTestFile[][]): string {
116+
const failedTests = testResults.flat()
117+
.flatMap(file => file.testCases)
118+
.filter(test => test.status === MatlabTestStatus.FAILED);
119+
120+
if (failedTests.length === 0) return '';
121+
122+
return `<details><summary><h3>Failed tests</h3></summary>
123+
${failedTests.map(test => generateFailedTestDetails(test)).join('\n')}
124+
</details>`;
125+
}
126+
127+
function generateFailedTestDetails(test: MatlabTestCase): string {
128+
return `<h4><b>❌ <u>${test.name} failed</u></b></h4>
129+
<details><summary>View stack trace</summary></br>
130+
<pre>${test.diagnostics.map(d => d.report).join('\n')}</pre>
131+
</details>`;
132+
}
133+
134+
function getStatusEmoji(status: MatlabTestStatus): string {
135+
switch (status) {
136+
case MatlabTestStatus.PASSED: return '✅';
137+
case MatlabTestStatus.FAILED: return '❌';
138+
case MatlabTestStatus.INCOMPLETE: return '⚠️';
139+
case MatlabTestStatus.NOT_RUN: return '🚫';
140+
}
141+
}
142+
143+
export function getTestResults(workspace: string): TestResultsData {
144+
const testResults: MatlabTestFile[][] = [];
145+
const counts: TestCounts = { total: 0, passed: 0, failed: 0, incomplete: 0, notRun: 0 };
146+
const runId = process.env.GITHUB_RUN_ID || '';
147+
const runnerTemp = process.env.RUNNER_TEMP || '';
148+
const resultsPath = path.join(runnerTemp, `matlabTestResults${runId}.json`);
149+
150+
if (existsSync(resultsPath)) {
151+
try {
152+
const testArtifact = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
153+
154+
for (const jsonTestSessionResults of testArtifact) {
155+
const testSessionResults: MatlabTestFile[] = [];
156+
const map = new Map<string, MatlabTestFile>();
157+
158+
const testCases = Array.isArray(jsonTestSessionResults) ?
159+
jsonTestSessionResults : [jsonTestSessionResults];
160+
161+
for (const jsonTestCase of testCases) {
162+
processTestCase(testSessionResults, jsonTestCase, map, workspace, counts);
163+
}
164+
165+
testResults.push(testSessionResults);
166+
}
167+
} catch (e) {
168+
console.error('An error occurred while reading the test results summary file ${resultsPath}:', e);
169+
// return;
170+
} finally {
171+
try {
172+
unlinkSync(resultsPath);
173+
} catch (e) {
174+
console.error(`An error occurred while trying to delete the test results summary file ${resultsPath}:`, e);
175+
}
176+
}
177+
}
178+
179+
return { testResults, counts };
180+
}
181+
182+
function processTestCase(
183+
testSessionResults: MatlabTestFile[],
184+
jsonTestCase: any,
185+
map: Map<string, MatlabTestFile>,
186+
workspace: string,
187+
counts: TestCounts
188+
): void {
189+
const baseFolder = jsonTestCase.BaseFolder;
190+
const testResult = jsonTestCase.TestResult;
191+
192+
const [testFileName, testCaseName] = testResult.Name.split('/');
193+
const filePath = path.join(baseFolder, testFileName);
194+
195+
let testFile = map.get(filePath);
196+
if (!testFile) {
197+
testFile = {
198+
name: testFileName,
199+
// path: getRelativePath(workspace, baseFolder, testFileName),
200+
path: "",
201+
testCases: [],
202+
duration: 0,
203+
status: MatlabTestStatus.NOT_RUN
204+
};
205+
map.set(filePath, testFile);
206+
testSessionResults.push(testFile);
207+
}
208+
209+
const testCase: MatlabTestCase = {
210+
name: testCaseName,
211+
duration: Number(testResult.Duration.toFixed(2)),
212+
status: determineTestStatus(testResult),
213+
diagnostics: processDiagnostics(testResult.Details.DiagnosticRecord)
214+
};
215+
216+
testFile.testCases.push(testCase);
217+
incrementDuration(testFile, testCase.duration);
218+
updateFileStatus(testFile, testCase);
219+
updateCount(testCase, counts);
220+
}
221+
222+
function incrementDuration(testFile: MatlabTestFile, testCaseDuration: number): void {
223+
testFile.duration = (testFile.duration || 0) + testCaseDuration;
224+
}
225+
226+
function updateFileStatus(testFile: MatlabTestFile, testCase: MatlabTestCase): void {
227+
if (testFile.status !== MatlabTestStatus.FAILED) {
228+
if (testCase.status === MatlabTestStatus.FAILED) {
229+
testFile.status = MatlabTestStatus.FAILED;
230+
} else if (testFile.status !== MatlabTestStatus.INCOMPLETE) {
231+
if (testCase.status === MatlabTestStatus.INCOMPLETE) {
232+
testFile.status = MatlabTestStatus.INCOMPLETE;
233+
} else if (testCase.status === MatlabTestStatus.PASSED) {
234+
testFile.status = MatlabTestStatus.PASSED;
235+
}
236+
}
237+
}
238+
}
239+
240+
function determineTestStatus(testResult: any): MatlabTestStatus {
241+
if (testResult.Failed) return MatlabTestStatus.FAILED;
242+
if (testResult.Incomplete) return MatlabTestStatus.INCOMPLETE;
243+
if (testResult.Passed) return MatlabTestStatus.PASSED;
244+
return MatlabTestStatus.NOT_RUN;
245+
}
246+
247+
function processDiagnostics(diagnostics: any): MatlabTestDiagnostics[] {
248+
const results: MatlabTestDiagnostics[] = [];
249+
250+
if (!diagnostics) return results;
251+
252+
const diagnosticItems = Array.isArray(diagnostics) ? diagnostics : [diagnostics];
253+
254+
for (const item of diagnosticItems) {
255+
if (item.Event && item.Report) {
256+
results.push({
257+
event: item.Event,
258+
report: item.Report
259+
});
260+
}
261+
}
262+
263+
return results;
264+
}
265+
266+
function getRelativePath(workspace: string, baseFolder: string, fileName: string): string {
267+
const relativePath = path.relative(workspace, baseFolder);
268+
return path.join(workspace, relativePath, fileName);
269+
}
270+
271+
function updateCount(testCase: MatlabTestCase, counts: TestCounts): void {
272+
counts.total++;
273+
switch (testCase.status) {
274+
case 'PASSED': counts.passed++; break;
275+
case 'FAILED': counts.failed++; break;
276+
case 'INCOMPLETE': counts.incomplete++; break;
277+
case 'NOT_RUN': counts.notRun++; break;
278+
}
279+
}

0 commit comments

Comments
 (0)