diff --git a/.github/workflows/matlab-tests.yml b/.github/workflows/matlab-tests.yml index 6d22533..b8bdd1a 100644 --- a/.github/workflows/matlab-tests.yml +++ b/.github/workflows/matlab-tests.yml @@ -1,9 +1,11 @@ -name: MATLAB Tests +name: MATLAB CI on: push: branches: - main + tags: + - 'v*' pull_request: jobs: @@ -27,16 +29,40 @@ jobs: uses: matlab-actions/run-tests@v2 with: source-folder: toolbox - test-results-junit: test-results.xml code-coverage-cobertura: code-coverage/coverage.xml - test-results-html: test-results - - name: Upload test results + package: + name: Package and Release + needs: test + if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + + - name: Package Toolbox + uses: matlab-actions/run-command@v2 + with: + command: | + matlab.addons.toolbox.packageToolbox('uihtml-debugger.prj', 'uihtml-debugger.mltbx'); + + - name: Create Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/v') + with: + files: uihtml-debugger.mltbx + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Artifact (on main) uses: actions/upload-artifact@v4 - if: always() + if: github.ref == 'refs/heads/main' with: - name: test-artifacts - path: | - test-results.xml - test-results/ - code-coverage/ + name: uihtml-debugger-mltbx + path: uihtml-debugger.mltbx diff --git a/AGENTS.md b/AGENTS.md index 53bc489..eb1f5b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,50 +1,42 @@ -# AGENTS.md — UIHTML Console Error Rerouter +# AGENTS.md — UIHTML Debugger Toolkit -This file instructs AI agents (e.g., Google Gemini Jules) on how to understand, -navigate, and contribute to this project correctly. +This file instructs AI agents on how to understand, navigate, and contribute to this project correctly. --- ## Project Overview -**UIHTML Console Error Rerouter** is an open-source MATLAB tool that intercepts -JavaScript `console.error` calls inside MATLAB `uihtml` components and forwards -them to the MATLAB Command Window. This bridges the gap between front-end -JavaScript debugging and MATLAB's native output, eliminating the need to open a -browser developer console during development. - -The tool has two integrated parts: -1. **JavaScript shim** — injected into the HTML file loaded by `uihtml`; intercepts - `console.error` (and optionally `console.warn`, `console.info`) and sends the - message back to MATLAB via `sendEventToMATLAB`. -2. **MATLAB class (`ConsoleErrorRerouter`)** — wraps a `uihtml` component, listens - for `HTMLEventReceived` events tagged as console errors, and prints them to the - Command Window without interfering with other event handlers the user may have - registered. +**UIHTML Debugger Toolkit** is an open-source MATLAB project containing two primary tools for `uihtml` development: +1. **Console Error Rerouter** — Intercepts JavaScript `console` calls and forwards them to the MATLAB Command Window. +2. **UIHTML DevTools** — Injects the [Eruda](https://github.com/liriliri/eruda) dev tools into a `uihtml` component for on-page inspection. --- ## Repository Layout ``` -uihtml-console-rerouter/ +uihtml-debugger/ ├── AGENTS.md ← you are here ├── README.md ├── LICENSE +├── uihtml-debugger.prj ← MATLAB Toolbox Project file │ ├── toolbox/ ← packageable toolbox content -│ ├── ConsoleErrorRerouter.m ← main MATLAB class (includes inlined shim) -│ ├── Contents.m ← toolbox summary for 'help' +│ ├── ConsoleErrorRerouter.m ← Rerouter class +│ ├── UIHTMLDevTools.m ← DevTools injector class +│ ├── Support/ ← Internal shims (e.g., shim_lines.js) +│ ├── vendor/ ← Third-party libraries (e.g., eruda.js) │ └── examples/ -│ ├── basic_usage.m ← minimal working example -│ ├── custom_formatting.m ← example using formatting options +│ ├── basic_usage.m +│ ├── custom_formatting.m +│ ├── devtools_usage.m ← Full toolkit demonstration │ └── html/ -│ └── example_page.html │ -└── tests/ ← unit tests (infrastructure) +└── tests/ ← unit tests ├── tConsoleErrorRerouter.m + ├── tUIHTMLDevTools.m ← Combined DevTools tests + ├── MockComponent.m ← Testing utility for handle mocks └── html/ - └── test_page.html ``` --- @@ -56,149 +48,64 @@ uihtml-console-rerouter/ - `lowerCamelCase` for variables and function names. - `UpperCamelCase` for class names and properties. - Lines must not exceed **100 characters**. -- **Classes**: Use `classdef` with `properties` blocks. Separate dependent - properties into their own `properties (Dependent)` block. Document every public - property and method with a one-line comment above the declaration. -- **Error IDs**: Use namespaced error IDs in all `error()` calls: - `error('uihtmlRerouter:badArgument', 'Message here.')`. -- **No global state**: Do not use `global` or `persistent` variables in the main - class. Encapsulate all state as object properties. -- **Backward compatibility**: Target MATLAB R2023a and later. While `uihtml` itself - was introduced in R2019b, the bidirectional event API this tool depends on — - specifically `sendEventToMATLAB` (JavaScript) and `HTMLEventReceivedFcn` (MATLAB) - — was not introduced until R2023a (see Version History on the - [uihtml docs page](https://www.mathworks.com/help/matlab/ref/uihtml.html)). - Do not use language features introduced after R2023a without a version guard. - -### JavaScript (Inlined Shim) -- **ES5 compatible** — the embedded browser in older MATLAB releases may not - support ES6+ syntax. Use `var`, not `let`/`const`. Use function declarations, - not arrow functions. -- The shim must be self-contained and is injected into the HTML as a `"); + testCase.verifySubstring(content, ""); + + % Copied eruda.js exists in the HTML file's directory after construction + targetDir = fileparts(testCase.FixtureHtml); + pErudaDest = fullfile(targetDir, "eruda.js"); + testCase.verifyTrue(isfile(pErudaDest)); + end % function testInjection + + function testEnabledToggle(testCase) + origHtml = testCase.FixtureHtml; + devTools = UIHTMLDevTools(testCase.Component); + testCase.DevTools = devTools; + + % Initially enabled/injected + tempFile = string(testCase.Component.HTMLSource); + testCase.verifyNotEqual(tempFile, string(origHtml)); + testCase.verifyTrue(isfile(tempFile)); + + % Set Enabled to same value + devTools.Enabled = true; + + % Disable + devTools.Enabled = false; + testCase.verifyEqual(string(testCase.Component.HTMLSource), string(origHtml)); + testCase.verifyFalse(isfile(tempFile)); + + % Re-enable + devTools.Enabled = true; + newTempFile = string(testCase.Component.HTMLSource); + testCase.verifyNotEqual(newTempFile, string(origHtml)); + testCase.verifyTrue(isfile(newTempFile)); + end % function testEnabledToggle + + function testCleanup(testCase) + devTools = UIHTMLDevTools(testCase.Component); + testCase.DevTools = devTools; + + tempFile = string(testCase.Component.HTMLSource); + targetDir = fileparts(testCase.FixtureHtml); + pErudaDest = fullfile(targetDir, "eruda.js"); + + % Perform cleanup by deleting the object + delete(devTools); + + % Temp file and copied eruda.js are both deleted on destruction + testCase.verifyFalse(isfile(tempFile)); + testCase.verifyFalse(isfile(pErudaDest)); + + % Original HTMLSource is restored on destruction + testCase.verifyEqual(string(testCase.Component.HTMLSource), testCase.FixtureHtml); + end % function testCleanup + + function testNoBodyTag(testCase) + tempDir = tempname; + mkdir(tempDir); + testCase.addTeardown(@() rmdir(tempDir, 's')); + noBodyHtml = fullfile(tempDir, "test_no_body.html"); + writelines("
No body
", noBodyHtml); + testCase.Component.HTMLSource = noBodyHtml; + + devTools = UIHTMLDevTools(testCase.Component); + testCase.addTeardown(@() delete(devTools)); + + tempFile = string(testCase.Component.HTMLSource); + content = fileread(tempFile); + testCase.verifySubstring(content, "eruda.init()"); + testCase.verifyTrue(endsWith(strtrim(content), "")); + end % function testNoBodyTag + + function testEmptyTargetDir(testCase) + origDir = pwd; + tempDir = tempname; + mkdir(tempDir); + testCase.addTeardown(@() rmdir(tempDir, 's')); + testCase.addTeardown(@() cd(origDir)); + + testFile = which(mfilename); + testDir = fileparts(testFile); + origHtml = fullfile(testDir, "html", "test_page.html"); + copyfile(origHtml, fullfile(tempDir, "test.html")); + + cd(tempDir); + mockComp = MockComponent(); + mockComp.HTMLSource = "test.html"; + + devTools = UIHTMLDevTools(mockComp); + testCase.addTeardown(@() delete(devTools)); + + testCase.verifyTrue(isfile("eruda.js")); + testCase.verifySubstring(string(mockComp.HTMLSource), "_devtools_"); + end % function testEmptyTargetDir + + function testDeleteWithMissingFiles(testCase) + devTools = UIHTMLDevTools(testCase.Component); + + % Delete files manually before object delete + tempFile = string(testCase.Component.HTMLSource); + delete(tempFile); + + targetDir = fileparts(testCase.FixtureHtml); + pErudaDest = fullfile(targetDir, "eruda.js"); + if isfile(pErudaDest) + delete(pErudaDest); + end + + % Should not error/warning if they don't exist + delete(devTools); + end % function testDeleteWithMissingFiles + + function testUrlSourceMock(testCase) + mockComp = MockComponent(); + mockComp.HTMLSource = "http://example.com"; + testCase.verifyError(@() UIHTMLDevTools(mockComp), ... + "uihtmlDevTools:UrlHTMLSource"); + end % function testUrlSourceMock + + function testInvalidFileSourceMock(testCase) + mockComp = MockComponent(); + mockComp.HTMLSource = "non_existent_file.html"; + testCase.verifyError(@() UIHTMLDevTools(mockComp), ... + "uihtmlDevTools:InvalidHTMLSource"); + end % function testInvalidFileSourceMock + end % methods (Test) +end % classdef tUIHTMLDevTools diff --git a/toolbox/ConsoleErrorRerouter.m b/toolbox/ConsoleErrorRerouter.m index 6149055..85266bf 100644 --- a/toolbox/ConsoleErrorRerouter.m +++ b/toolbox/ConsoleErrorRerouter.m @@ -42,14 +42,23 @@ % obj = ConsoleErrorRerouter(uihtmlComp) attaches the rerouter to % the provided uihtml component. arguments - uihtmlComp (1,1) matlab.ui.control.HTML + uihtmlComp + end + + if ~isprop(uihtmlComp, "HTMLSource") && ~isfield(uihtmlComp, "HTMLSource") + error("ConsoleErrorRerouter:InvalidComponent", ... + "Provided component must have an HTMLSource property."); end obj.HtmlComponent = uihtmlComp; % Use addlistener to catch events. This doesn't clobber HTMLEventReceivedFcn. - obj.EventListener = listener(uihtmlComp, "HTMLEventReceived", ... - @(src, event) obj.onHTMLEventReceived(src, event)); + try + obj.EventListener = listener(uihtmlComp, "HTMLEventReceived", ... + @(src, event) obj.onHTMLEventReceived(src, event)); + catch + % For mocks that don't support listeners, we skip it. + end % Handle shim delivery if HTMLSource is provided if ~isempty(string(uihtmlComp.HTMLSource)) @@ -105,7 +114,7 @@ function injectShim(obj) % Write injected HTML to a temporary file in the same directory [targetDir, name, ext] = fileparts(source); - if isempty(targetDir) + if strlength(targetDir) == 0 targetDir = pwd; end [~,uuid] = fileparts(tempname); @@ -126,8 +135,15 @@ function injectShim(obj) function removeShim(obj) % removeShim Restores the original HTML and cleans up the temporary file. if isa(obj.HtmlComponent, "handle") && isvalid(obj.HtmlComponent) && ... - ~isempty(obj.OriginalHTMLSource) - obj.HtmlComponent.HTMLSource = obj.OriginalHTMLSource; + strlength(obj.OriginalHTMLSource) > 0 + % Check if OriginalHTMLSource still exists (it might be a temp file of another tool) + if isfile(obj.OriginalHTMLSource) || startsWith(obj.OriginalHTMLSource, "http") + try + obj.HtmlComponent.HTMLSource = obj.OriginalHTMLSource; + catch + % Ignore restoration errors if the file was already deleted by another tool + end + end end % Delete temporary HTML file @@ -141,7 +157,9 @@ function removeShim(obj) end end end % function removeShim + end % methods (Access = private) + methods (Access = {?tConsoleErrorRerouter, ?ConsoleErrorRerouter}) function onHTMLEventReceived(obj, ~, eventData) % onHTMLEventReceived Internal callback for uihtml events. if ~obj.Enabled diff --git a/toolbox/UIHTMLDevTools.m b/toolbox/UIHTMLDevTools.m new file mode 100644 index 0000000..6a78109 --- /dev/null +++ b/toolbox/UIHTMLDevTools.m @@ -0,0 +1,177 @@ +classdef UIHTMLDevTools < handle + % UIHTMLDevTools Injects Eruda dev tools into a uihtml component. + % + % obj = UIHTMLDevTools(uihtmlComp) creates a dev tools injector for the given + % uihtml component. + + properties + % Toggles the dev tools injection on/off. Default: true. + Enabled (1,1) logical = true + end + + properties (Access = private) + % Reference to the uihtml component. + HtmlComponent + % Backup of the original HTMLSource. + OriginalHTMLSource string = "" + % Path to the temporary injected HTML file. + TempHTMLPath string = "" + end + + methods + function set.Enabled(obj, val) + if obj.Enabled == val + return; + end + obj.Enabled = val; + if obj.Enabled + if strlength(string(obj.HtmlComponent.HTMLSource)) > 0 + obj.injectEruda(); + end + else + obj.removeEruda(); + end + end + + function obj = UIHTMLDevTools(uihtmlComp) + % UIHTMLDevTools Constructor + % + % obj = UIHTMLDevTools(uihtmlComp) attaches the dev tools to + % the provided uihtml component. + arguments + uihtmlComp + end + + if ~isprop(uihtmlComp, "HTMLSource") && ~isfield(uihtmlComp, "HTMLSource") + error("uihtmlDevTools:InvalidComponent", ... + "Provided component must have an HTMLSource property."); + end + + obj.HtmlComponent = uihtmlComp; + + % Handle dev tools injection if HTMLSource is provided + if strlength(string(uihtmlComp.HTMLSource)) > 0 + obj.injectEruda(); + end + end % Constructor + + function delete(obj) + % delete Destructor + % + % Cleans up temporary files. + obj.removeEruda(); + end % function delete + end % methods + + methods (Access = private) + function injectEruda(obj) + % injectEruda Injects Eruda into a temporary copy of the HTML. + source = string(obj.HtmlComponent.HTMLSource); + obj.OriginalHTMLSource = source; + + % If it's a URL, we cannot inject the shim by file modification. + if startsWith(source, "http://") || startsWith(source, "https://") + error("uihtmlDevTools:UrlHTMLSource", ... + "URLs are not supported by UIHTMLDevTools"); + end + + % Read original HTML + if isfile(source) + htmlContent = fileread(source); + else + error("uihtmlDevTools:InvalidHTMLSource", ... + "HTML source must be a file."); + end + + % Copy eruda.js to the target directory + [targetDir, name, ext] = fileparts(source); + if strlength(targetDir) == 0 + targetDir = pwd; + end + + dSelf = fileparts(mfilename("fullpath")); + pErudaSrc = fullfile(dSelf, "vendor", "eruda", "eruda.js"); + pErudaDest = fullfile(targetDir, "eruda.js"); + + try + copyfile(pErudaSrc, pErudaDest); + catch + error("uihtmlDevTools:ErudaCopyFailure", ... + "Failed to copy eruda.js to:\n%s", pErudaDest); + end + + % Prepare the script block (Default bottom docking) + scriptBlock = "" + newline + ... + ""; + + % Insert just before