diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e7c40d..285883e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,7 @@ jobs: path: build/coverage/ package: - needs: test - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + needs: [build-go, test] runs-on: ubuntu-latest permissions: contents: write @@ -87,6 +86,9 @@ jobs: name: matlab-http-bridge-binaries path: toolbox/bin/ + - name: Restore Linux execute permission + run: chmod +x toolbox/bin/glnxa64/matlab-http-bridge + - name: Set up MATLAB uses: matlab-actions/setup-matlab@v2 @@ -103,7 +105,7 @@ jobs: path: matlab-http-server.mltbx - name: Create Release - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: files: matlab-http-server.mltbx diff --git a/buildfile.m b/buildfile.m index 56c4cd6..9b753eb 100644 --- a/buildfile.m +++ b/buildfile.m @@ -19,8 +19,6 @@ % Default task is 'test' plan.DefaultTasks = "test"; - % Package depends on test - plan("package").Dependencies = "test"; end function testAction(context) @@ -41,14 +39,16 @@ function testAction(context) % Cobertura and HTML xmlFile = fullfile(covFolder, "coverage.xml"); - % Target only .m files in toolbox/ and its subfolders, excluding doc/ and examples/ + % Target only runtime .m files in toolbox/ and its subfolders, + % excluding documentation and examples. allFiles = dir(fullfile("toolbox", "**", "*.m")); allFiles = allFiles(~[allFiles.isdir]); sourceFiles = fullfile({allFiles.folder}, {allFiles.name}); - % Filter out non-m files and examples/ + % Exclude documentation and examples from runtime coverage checks. isExample = contains(sourceFiles, fullfile("toolbox", "examples")); - sourceFiles = sourceFiles(endsWith(sourceFiles, ".m") & ~isExample); + isDoc = contains(sourceFiles, fullfile("toolbox", "doc")); + sourceFiles = sourceFiles(endsWith(sourceFiles, ".m") & ~isExample & ~isDoc); % Use multiple formats in one plugin call formats = [CoverageReport(covFolder), CoberturaFormat(xmlFile)]; @@ -95,4 +95,4 @@ function packageAction(context) fprintf('Packaging %s into %s...\n', prjFile, outFile); matlab.addons.toolbox.packageToolbox(prjFile, outFile); end -end \ No newline at end of file +end diff --git a/tests/TestGoSidecarTransport.m b/tests/TestGoSidecarTransport.m index f50612c..6606c50 100644 --- a/tests/TestGoSidecarTransport.m +++ b/tests/TestGoSidecarTransport.m @@ -43,6 +43,68 @@ function testBinaryNotFoundErrors(testCase) testCase.verifyTrue(true); end + function testBinaryRelativePathForWindows(testCase) + relativePath = mhs.internal.GoSidecarTransport ... + .binaryRelativePathForPlatform(true, false, "win64"); + + testCase.verifyEqual(string(relativePath), ... + string(fullfile("bin", "win64", "matlab-http-bridge.exe"))); + end + + function testBinaryRelativePathForMacArm(testCase) + relativePath = mhs.internal.GoSidecarTransport ... + .binaryRelativePathForPlatform(false, true, "maca64"); + + testCase.verifyEqual(string(relativePath), ... + string(fullfile("bin", "maca64", "matlab-http-bridge"))); + end + + function testBinaryRelativePathForMacIntel(testCase) + relativePath = mhs.internal.GoSidecarTransport ... + .binaryRelativePathForPlatform(false, true, "maci64"); + + testCase.verifyEqual(string(relativePath), ... + string(fullfile("bin", "maci64", "matlab-http-bridge"))); + end + + function testBinaryRelativePathForLinux(testCase) + relativePath = mhs.internal.GoSidecarTransport ... + .binaryRelativePathForPlatform(false, false, "glnxa64"); + + testCase.verifyEqual(string(relativePath), ... + string(fullfile("bin", "glnxa64", "matlab-http-bridge"))); + end + + function testFindBinaryForPlatformErrorsWhenMissing(testCase) + missingRoot = fullfile(tempdir, "mhs-missing-binary-" + string(java.util.UUID.randomUUID)); + mkdir(missingRoot); + cleanup = onCleanup(@() rmdir(missingRoot, "s")); + + testCase.verifyError(@() ... + mhs.internal.GoSidecarTransport.findBinaryForPlatform( ... + string(missingRoot), false, false, "glnxa64"), ... + "MatlabHttpServer:binaryNotFound"); + clear cleanup; + end + + function testEnsureBinaryExecutableNoOpsForExistingExecutable(testCase) + testCase.Transport = mhs.internal.GoSidecarTransport(testCase.Port); + testCase.verifyWarningFree(@() ... + mhs.internal.GoSidecarTransport.ensureBinaryExecutable( ... + testCase.Transport.BinaryPath)); + end + + function testFindBinaryForPlatformReturnsExistingBinary(testCase) + testCase.Transport = mhs.internal.GoSidecarTransport(testCase.Port); + toolboxRoot = string(fileparts(fileparts(fileparts( ... + testCase.Transport.BinaryPath)))); + + resolved = mhs.internal.GoSidecarTransport.findBinaryForPlatform( ... + toolboxRoot, ispc, ismac, string(computer('arch'))); + + testCase.verifyEqual(resolved, testCase.Transport.BinaryPath); + end + function testStartStop(testCase) testCase.Transport = mhs.internal.GoSidecarTransport(testCase.Port); testCase.Transport.start(); @@ -113,6 +175,30 @@ function testParseResponseBytes(testCase) testCase.verifyEqual(char(body), '{"status":"ok"}'); end + function testWriteResponseSerializesHeadersForJson(testCase) + testCase.Transport = mhs.internal.GoSidecarTransport(testCase.Port); + writer = java.io.StringWriter(); + testCase.Transport.Writer = java.io.PrintWriter(writer, true); + + CRLF = char([13 10]); + responseBytes = uint8([ ... + 'HTTP/1.1 201 Created' CRLF ... + 'Content-Type: application/json' CRLF ... + 'X-Test: value' CRLF ... + CRLF ... + '{"ok":true}']); + + socket = struct("id", "req-123", "transport", testCase.Transport); + testCase.Transport.writeResponse(socket, responseBytes); + + payload = jsondecode(char(writer.toString())); + testCase.verifyEqual(string(payload.id), "req-123"); + testCase.verifyEqual(payload.status, 201); + testCase.verifyEqual(string(payload.headers.Content_Type), "application/json"); + testCase.verifyEqual(string(payload.headers.X_Test), "value"); + testCase.verifyEqual(char(matlab.net.base64decode(payload.body)), '{"ok":true}'); + end + function testBuildRawRequestWithoutQueryOrBody(testCase) req.method = "GET"; req.path = "/status"; diff --git a/toolbox/+mhs/+internal/GoSidecarTransport.m b/toolbox/+mhs/+internal/GoSidecarTransport.m index 9102506..30b3d8e 100644 --- a/toolbox/+mhs/+internal/GoSidecarTransport.m +++ b/toolbox/+mhs/+internal/GoSidecarTransport.m @@ -39,6 +39,8 @@ function start(obj) return; end + mhs.internal.GoSidecarTransport.ensureBinaryExecutable(obj.BinaryPath); + pb = java.lang.ProcessBuilder({char(obj.BinaryPath), ... '--port', char(string(obj.Port))}); pb.redirectErrorStream(true); @@ -110,18 +112,15 @@ function writeResponse(obj, socket, responseBytes) resp.id = char(socket.id); resp.status = status; - % Convert dictionary to struct for jsonencode if needed - if isa(headers, 'dictionary') - hStruct = struct(); - keys = headers.keys(); - for i = 1:numel(keys) - field = matlab.lang.makeValidName(char(keys(i))); - hStruct.(field) = char(headers(keys(i))); - end - resp.headers = hStruct; - else - resp.headers = headers; + % parseResponseBytes always returns a dictionary; convert it to + % a struct because jsonencode does not support dictionary. + hStruct = struct(); + keys = headers.keys(); + for i = 1:numel(keys) + field = matlab.lang.makeValidName(char(keys(i))); + hStruct.(field) = char(headers(keys(i))); end + resp.headers = hStruct; resp.body = char(matlab.net.base64encode(body)); @@ -178,27 +177,73 @@ function onLineFromGo(obj, line) % Locate the pre-compiled binary based on current platform. toolboxRoot = fileparts(fileparts(fileparts( ... mfilename('fullpath')))); - if ispc - path = fullfile(toolboxRoot, 'bin', 'win64', ... + path = mhs.internal.GoSidecarTransport.findBinaryForPlatform( ... + toolboxRoot, ispc, ismac, string(computer('arch'))); + end + + function ensureBinaryExecutable(path) + arguments + path (1,1) string + end + + if ispc || ~isfile(path) + return; + end + + [isOk, attributes] = fileattrib(path); + if isOk && isfield(attributes, "UserExecute") && attributes.UserExecute + return; + end + + [status, output] = system(sprintf('chmod +x "%s"', char(path))); + if status ~= 0 + error("MatlabHttpServer:BinaryPermissionDenied", ... + "Unable to mark Go sidecar binary as executable: %s", ... + strtrim(output)); + end + end + + function path = findBinaryForPlatform(toolboxRoot, isWindows, isMac, arch) + arguments + toolboxRoot (1,1) string + isWindows (1,1) logical + isMac (1,1) logical + arch (1,1) string + end + + relativePath = mhs.internal.GoSidecarTransport ... + .binaryRelativePathForPlatform(isWindows, isMac, arch); + path = fullfile(toolboxRoot, relativePath); + + if ~isfile(path) + error('MatlabHttpServer:binaryNotFound', ... + ['Go sidecar binary not found: %s\n' ... + 'Build with: cd sidecar && make build-all'], path); + end + end + + function relativePath = binaryRelativePathForPlatform(isWindows, isMac, arch) + arguments + isWindows (1,1) logical + isMac (1,1) logical + arch (1,1) string + end + + if isWindows + relativePath = fullfile('bin', 'win64', ... 'matlab-http-bridge.exe'); - elseif ismac - if strcmp(computer('arch'), 'maca64') - path = fullfile(toolboxRoot, 'bin', 'maca64', ... + elseif isMac + if arch == "maca64" + relativePath = fullfile('bin', 'maca64', ... 'matlab-http-bridge'); else - path = fullfile(toolboxRoot, 'bin', 'maci64', ... + relativePath = fullfile('bin', 'maci64', ... 'matlab-http-bridge'); end else - path = fullfile(toolboxRoot, 'bin', 'glnxa64', ... + relativePath = fullfile('bin', 'glnxa64', ... 'matlab-http-bridge'); end - - if ~isfile(path) - error('MatlabHttpServer:binaryNotFound', ... - ['Go sidecar binary not found: %s\n' ... - 'Build with: cd sidecar && make build-all'], path); - end end function raw = buildRawRequest(req)