diff --git a/.github/workflows/bat.yml b/.github/workflows/bat.yml index 8e0ea9f5..317a51d5 100644 --- a/.github/workflows/bat.yml +++ b/.github/workflows/bat.yml @@ -116,17 +116,16 @@ jobs: name: built-action - name: Perform 'setup-matlab' without MATLAB Test uses: matlab-actions/setup-matlab@a0180c939fb1a28de13f44f7b778b912384ced1f # v3.0.1 - - name: Run MATLAB Tests without MATLAB Test # Test Case 1: Without MATLAB Test, no view is shown + - name: Run MATLAB Tests without MATLAB Test # Test Case 1: Without MATLAB Test, statement coverage view is shown uses: ./ with: source-folder: sample - code-coverage-metric-level: mcdc - name: Perform 'setup-matlab' with MATLAB Test uses: matlab-actions/setup-matlab@a0180c939fb1a28de13f44f7b778b912384ced1f # v3.0.1 with: products: | MATLAB_Test - - name: Run MATLAB Tests with other options # Test Case 2: With MATLAB Test, and other code-coverage options, no view is shown. + - name: Run MATLAB Tests with other options # Test Case 2: With MATLAB Test, and other code-coverage report options, no view is shown. uses: ./ with: model-coverage-cobertura: test-results/modelcoverage.xml @@ -134,7 +133,7 @@ jobs: test-results-junit: test-results/results.xml code-coverage-cobertura: test-results/coverage.xml code-coverage-html: test-results/coverageHTML - code-coverage-metric-level: mcdc + code-coverage-metrics: mcdc model-coverage-html: test-results/modelcoverageHTML test-results-html: test-results/resultsHTML select-by-folder: sample @@ -142,8 +141,17 @@ jobs: use-parallel: true output-detail: Detailed logging-level: Detailed - - name: Run MATLAB Tests # Test Case 3: With MATLAB Test, and only code-coverage-metric-level option, view is shown. + - name: Run MATLAB Tests # Test Case 3: With MATLAB Test and code-coverage-metrics option (w/o coverage report options), view is shown. uses: ./ with: source-folder: sample - code-coverage-metric-level: mcdc + code-coverage-metrics: decision + - name: Run MATLAB Tests with default metrics # Test Case 4: With MATLAB Test, and no code-coverage-metrics option ('auto' default), mcdc view is shown. + uses: ./ + with: + source-folder: sample + - name: Run MATLAB Tests with empty metrics # Test Case 5: With MATLAB Test, and empty code-coverage-metrics option, no view is shown. + uses: ./ + with: + source-folder: sample + code-coverage-metrics: "" diff --git a/action.yml b/action.yml index fb5242b8..7da74c2e 100644 --- a/action.yml +++ b/action.yml @@ -89,11 +89,11 @@ inputs: Option to generate a summary for the GitHub job summary required: false default: true - code-coverage-metric-level: + code-coverage-metrics: description: >- - Level of coverage metrics to collect + Code coverage metrics to collect required: false - default: mcdc + default: auto runs: using: node24 main: dist/index.js diff --git a/plugins/+matlab/+unittest/+internal/+services/+plugins/CodeCoverageSummaryPluginService.m b/plugins/+matlab/+unittest/+internal/+services/+plugins/CodeCoverageSummaryPluginService.m index abf3868a..92a42ee3 100644 --- a/plugins/+matlab/+unittest/+internal/+services/+plugins/CodeCoverageSummaryPluginService.m +++ b/plugins/+matlab/+unittest/+internal/+services/+plugins/CodeCoverageSummaryPluginService.m @@ -3,38 +3,53 @@ methods function plugins = providePlugins(~, ~) - verInfo = ver; - productNames = string({verInfo.Name}); - productName = 'MATLAB Test'; - isProductInstalled = any(productNames.matches(productName)); hasCoverageHTML = ~isempty(getenv('MW_INPUT_CODE_COVERAGE_HTML')); hasCoverageCobertura = ~isempty(getenv('MW_INPUT_CODE_COVERAGE_COBERTURA')); hasCoverageRequest = hasCoverageHTML || hasCoverageCobertura; - % Check if MATLAB Test license is available and MATLAB Test is installed - if strcmpi(getenv("MW_INPUT_GENERATE_SUMMARY"), "true") && ~hasCoverageRequest && license('test', 'matlab_test') && isProductInstalled - % Get metric level from environment variable - metricLevel = getenv('MW_INPUT_CODE_COVERAGE_METRIC_LEVEL'); + metricsStr = strtrim(getenv('MW_INPUT_CODE_COVERAGE_METRICS')); + if strcmpi(getenv("MW_INPUT_GENERATE_SUMMARY"), "true") && ~hasCoverageRequest && ~isempty(metricsStr) + % Parse metrics from environment variable (space-separated) + metrics = strsplit(strtrim(metricsStr)); + + % Resolve 'auto' to appropriate metrics + if isscalar(metrics) && strcmpi(metrics{1}, 'auto') + if any(strcmp({ver().Name}, 'MATLAB Test')) && license('test', 'MATLAB_Test') + if ~isMATLABReleaseOlderThan("R2023a") + metrics = {'mcdc'}; + else + metrics = {'statement'}; + end + else + metrics = {'statement'}; + end + end % Create a shared CoverageResult format object format = matlab.unittest.plugins.codecoverage.CoverageResult; - + % Create an array to hold multiple plugins plugins = matlab.unittest.plugins.TestRunnerPlugin.empty(0); - + % Get source folder from environment variable sourceFolder = getenv('MW_INPUT_SOURCE_FOLDER'); if isempty(sourceFolder) sourceFolder = pwd; end - - coveragePlugin = matlab.unittest.plugins.CodeCoveragePlugin.forFolder(... - sourceFolder, 'Producing', format, 'MetricLevel', metricLevel); + + if isMATLABReleaseOlderThan("R2026b") + metric = metrics{1}; + coveragePlugin = matlab.unittest.plugins.CodeCoveragePlugin.forFolder(... + sourceFolder, 'Producing', format, 'MetricLevel', metric); + else + coveragePlugin = matlab.unittest.plugins.CodeCoveragePlugin.forFolder(... + sourceFolder, 'Producing', format, 'Metrics', metrics); + end plugins(end+1) = coveragePlugin; - + % Add the summary plugin with the same format object - summaryPlugin = testframework.CodeCoverageSummaryPlugin(format, metricLevel); + summaryPlugin = testframework.CodeCoverageSummaryPlugin(format, metrics); plugins(end+1) = summaryPlugin; else plugins = matlab.unittest.plugins.TestRunnerPlugin.empty(1,0); diff --git a/plugins/+testframework/CodeCoverageSummaryPlugin.m b/plugins/+testframework/CodeCoverageSummaryPlugin.m index 6ba5f5be..efd61115 100644 --- a/plugins/+testframework/CodeCoverageSummaryPlugin.m +++ b/plugins/+testframework/CodeCoverageSummaryPlugin.m @@ -3,21 +3,18 @@ properties (Access=private) CoverageFormat - MetricLevel + Metrics end methods - function plugin = CodeCoverageSummaryPlugin(coverageFormat, metricLevel) + function plugin = CodeCoverageSummaryPlugin(coverageFormat, metrics) plugin.CoverageFormat = coverageFormat; - plugin.MetricLevel = metricLevel; + plugin.Metrics = metrics; end end methods (Access=protected) function runSession(plugin, pluginData) - % Checkout MATLAB Test license - license('checkout', 'matlab_test'); - % Run the session first (this ensures coverage data is collected) runSession@matlab.unittest.plugins.TestRunnerPlugin(plugin, pluginData); @@ -31,29 +28,28 @@ function runSession(plugin, pluginData) % Create coverage summary structure coverageDetails = struct(); - coverageDetails.MetricLevel = plugin.MetricLevel; - - % Always get function and statement coverage (available for all levels) + + % Always get function and statement coverage functionCoverage = coverageSummary(result, "function"); statementCoverage = coverageSummary(result, "statement"); - + coverageDetails.FunctionCoverage = sumCoverage(functionCoverage); coverageDetails.StatementCoverage = sumCoverage(statementCoverage); - - % Get decision coverage if metric level is decision, condition, or mcdc - if ismember(plugin.MetricLevel, {'decision', 'condition', 'mcdc'}) + + % Get decision coverage if metrics contains decision, condition, or mcdc + if any(ismember({'decision', 'condition', 'mcdc'}, plugin.Metrics)) decisionCoverage = coverageSummary(result, "decision"); coverageDetails.DecisionCoverage = sumCoverage(decisionCoverage); end - - % Get condition coverage if metric level is condition or mcdc - if ismember(plugin.MetricLevel, {'condition', 'mcdc'}) + + % Get condition coverage if metrics contains condition or mcdc + if any(ismember({'condition', 'mcdc'}, plugin.Metrics)) conditionCoverage = coverageSummary(result, "condition"); coverageDetails.ConditionCoverage = sumCoverage(conditionCoverage); end - - % Get MC/DC coverage if metric level is mcdc - if strcmp(plugin.MetricLevel, 'mcdc') + + % Get MC/DC coverage if metrics contains mcdc + if any(ismember({'mcdc'}, plugin.Metrics)) mcdcCoverage = coverageSummary(result, "mcdc"); coverageDetails.MCDCCoverage = sumCoverage(mcdcCoverage); end diff --git a/src/index.ts b/src/index.ts index 93b8af4c..840e0286 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ async function run() { const architecture = process.arch; const workspaceDir = process.cwd(); + const codeCoverageMetrics = core.getInput("code-coverage-metrics").toLowerCase().trim(); + const options: scriptgen.RunTestsOptions = { JUnitTestResults: core.getInput("test-results-junit"), CoberturaCodeCoverage: core.getInput("code-coverage-cobertura"), @@ -29,19 +31,10 @@ async function run() { UseParallel: core.getBooleanInput("use-parallel"), OutputDetail: core.getInput("output-detail"), LoggingLevel: core.getInput("logging-level"), + CodeCoverageMetrics: codeCoverageMetrics, }; const generateSummary = core.getBooleanInput("generate-summary"); - var codeCoverageMetricLevel = core.getInput("code-coverage-metric-level").toLowerCase(); - - // Validate metric level - const validMetricLevels = ["statement", "decision", "condition", "mcdc"]; - if (!validMetricLevels.includes(codeCoverageMetricLevel)) { - core.warning( - `Invalid metric level '${codeCoverageMetricLevel}'. Using the default value ('mcdc') instead.`, - ); - codeCoverageMetricLevel = "mcdc"; - } const command = scriptgen.generateCommand(options); const startupOptions = core.getInput("startup-options").split(" "); @@ -51,7 +44,7 @@ async function run() { env: { ...process.env, MW_BATCH_LICENSING_ONLINE: "true", // Remove when online batch licensing is the default - MW_INPUT_CODE_COVERAGE_METRIC_LEVEL: codeCoverageMetricLevel, + MW_INPUT_CODE_COVERAGE_METRICS: options.CodeCoverageMetrics!, MW_INPUT_SOURCE_FOLDER: options.SourceFolder!, // Add source folder to environment MW_INPUT_CODE_COVERAGE_HTML: options.HTMLCodeCoverage!, MW_INPUT_CODE_COVERAGE_COBERTURA: options.CoberturaCodeCoverage!, diff --git a/src/scriptgen.ts b/src/scriptgen.ts index c62a9439..d86d73ff 100644 --- a/src/scriptgen.ts +++ b/src/scriptgen.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2022 The MathWorks, Inc. +// Copyright 2020-2026 The MathWorks, Inc. import * as path from "path"; @@ -22,6 +22,19 @@ export interface RunTestsOptions { UseParallel?: boolean; OutputDetail?: string; LoggingLevel?: string; + CodeCoverageMetrics?: string; +} + +function formatMetricsCellArray(metrics: string | undefined): string { + if (!metrics || metrics.trim() === "") { + return "{}"; + } + const items = metrics + .trim() + .split(/\s+/) + .map((m) => `'${m}'`) + .join(","); + return `{${items}}`; } /** @@ -30,6 +43,7 @@ export interface RunTestsOptions { * @param options scriptgen options for running tests. */ export function generateCommand(options: RunTestsOptions): string { + const metricsCellArray = formatMetricsCellArray(options.CodeCoverageMetrics); const command = ` addpath('${path.join(import.meta.dirname, "scriptgen")}'); testScript = genscript('Test', @@ -47,7 +61,8 @@ export function generateCommand(options: RunTestsOptions): string { 'Strict',${options.Strict || false}, 'UseParallel',${options.UseParallel || false}, 'OutputDetail','${options.OutputDetail || ""}', - 'LoggingLevel','${options.LoggingLevel || ""}' + 'LoggingLevel','${options.LoggingLevel || ""}', + 'Metrics',${metricsCellArray} ); disp('Running MATLAB script with contents:'); disp(testScript.Contents); diff --git a/src/scriptgen.unit.test.ts b/src/scriptgen.unit.test.ts index 1f8c5816..d55e1c3c 100644 --- a/src/scriptgen.unit.test.ts +++ b/src/scriptgen.unit.test.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2022 The MathWorks, Inc. +// Copyright 2020-2026 The MathWorks, Inc. import * as scriptgen from "./scriptgen.js"; @@ -20,6 +20,7 @@ describe("command generation", () => { UseParallel: false, OutputDetail: "", LoggingLevel: "", + CodeCoverageMetrics: "", }; const actual = scriptgen.generateCommand(options); @@ -40,18 +41,27 @@ describe("command generation", () => { expect(actual.includes("'UseParallel',false")).toBeTruthy(); expect(actual.includes("'OutputDetail',''")).toBeTruthy(); expect(actual.includes("'LoggingLevel',''")).toBeTruthy(); + expect(actual.includes("'Metrics',{}")).toBeTruthy(); const expected = - `genscript('Test', 'JUnitTestResults','', 'CoberturaCodeCoverage','', 'HTMLCodeCoverage','', - 'SourceFolder','', 'PDFTestReport','', 'HTMLTestReport','', 'SimulinkTestResults','', - 'CoberturaModelCoverage','', 'HTMLModelCoverage','', 'SelectByTag','', 'SelectByFolder','', - 'Strict',false, 'UseParallel',false, 'OutputDetail','', 'LoggingLevel','')`.replace( - /\s+/g, - "", - ); + `genscript('Test', 'JUnitTestResults','', 'CoberturaCodeCoverage','', 'HTMLCodeCoverage','', + 'SourceFolder','', 'PDFTestReport','', 'HTMLTestReport','', 'SimulinkTestResults','', + 'CoberturaModelCoverage','', 'HTMLModelCoverage','', 'SelectByTag','', 'SelectByFolder','', + 'Strict',false, 'UseParallel',false, 'OutputDetail','', 'LoggingLevel','', + 'Metrics',{})`.replace(/\s+/g, ""); expect(actual.replace(/\s+/g, "").includes(expected)).toBeTruthy(); }); + it("contains genscript invocation with single metrics value", () => { + const options: scriptgen.RunTestsOptions = { + CodeCoverageMetrics: "statement", + }; + + const actual = scriptgen.generateCommand(options); + + expect(actual.includes("'Metrics',{'statement'}")).toBeTruthy(); + }); + it("contains genscript invocation with all options specified", () => { const options: scriptgen.RunTestsOptions = { JUnitTestResults: "test-results/results.xml", @@ -69,6 +79,7 @@ describe("command generation", () => { UseParallel: true, OutputDetail: "Detailed", LoggingLevel: "Detailed", + CodeCoverageMetrics: "mcdc type-size", }; const actual = scriptgen.generateCommand(options); @@ -97,23 +108,25 @@ describe("command generation", () => { expect(actual.includes("'UseParallel',true")).toBeTruthy(); expect(actual.includes("'OutputDetail','Detailed'")).toBeTruthy(); expect(actual.includes("'LoggingLevel','Detailed'")).toBeTruthy(); + expect(actual.includes("'Metrics',{'mcdc','type-size'}")).toBeTruthy(); - const expected = `genscript('Test', - 'JUnitTestResults','test-results/results.xml', + const expected = `genscript('Test', + 'JUnitTestResults','test-results/results.xml', 'CoberturaCodeCoverage','code-coverage/coverage.xml', - 'HTMLCodeCoverage','code-coverage/coverage.html', + 'HTMLCodeCoverage','code-coverage/coverage.html', 'SourceFolder','source', - 'PDFTestReport','test-results/pdf-results.pdf', - 'HTMLTestReport','test-results/html-results.html', + 'PDFTestReport','test-results/pdf-results.pdf', + 'HTMLTestReport','test-results/html-results.html', 'SimulinkTestResults','test-results/simulinkTest.mldatx', - 'CoberturaModelCoverage','test-results/modelcoverage.xml', - 'HTMLModelCoverage','test-results/modelcoverage.html', - 'SelectByTag','FeatureA', - 'SelectByFolder','test/tools;test/toolbox', - 'Strict',true, - 'UseParallel',true, - 'OutputDetail','Detailed', - 'LoggingLevel','Detailed' )`.replace(/\s+/g, ""); + 'CoberturaModelCoverage','test-results/modelcoverage.xml', + 'HTMLModelCoverage','test-results/modelcoverage.html', + 'SelectByTag','FeatureA', + 'SelectByFolder','test/tools;test/toolbox', + 'Strict',true, + 'UseParallel',true, + 'OutputDetail','Detailed', + 'LoggingLevel','Detailed', + 'Metrics',{'mcdc','type-size'})`.replace(/\s+/g, ""); expect(actual.replace(/\s+/g, "").includes(expected)).toBeTruthy(); }); });