diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..045b141 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +* text=auto + +*.fig binary +*.mat binary +*.mdl binary diff merge=mlAutoMerge +*.mex* binary +*.mlapp binary +*.mldatx binary merge=mlAutoMerge +*.mlproj binary +*.mlx binary +*.p binary +*.plprj binary +*.psprjx binary merge=mlAutoMerge +*.sbproj binary +*.sfx binary +*.sldd binary +*.slreqx binary merge=mlAutoMerge +*.slmx binary merge=mlAutoMerge +*.sltx binary +*.slxc binary +*.slx binary merge=mlAutoMerge +*.slxp binary + +## MATLAB Project metadata files use LF line endings +/resources/project/**/*.xml text eol=lf + +## Other common binary file types +*.docx binary +*.exe binary +*.jpg binary +*.pdf binary +*.png binary +*.xlsx binary diff --git a/.github/workflows/matlab-tests.yml b/.github/workflows/matlab-tests.yml new file mode 100644 index 0000000..2afaa4d --- /dev/null +++ b/.github/workflows/matlab-tests.yml @@ -0,0 +1,24 @@ +name: MATLAB Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Run MATLAB Tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + + - name: Run tests + uses: matlab-actions/run-tests@v2 + with: + source-folder: src + test-results-junit: test-results.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aac549e --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Autosave files +*.asv +*.m~ +*.autosave +*.slx.r* +*.mdl.r* + +# Derived content-obscured files +*.p + +# Compiled MEX files +*.mex* + +# Packaged app and toolbox files +*.mlappinstall +*.mltbx + +# Deployable archives +*.ctf + +# Generated helpsearch folders +helpsearch*/ + +# Code generation folders +slprj/ +sccprj/ +codegen/ + +# Cache files +*.slxc + +# Cloud based storage dotfile +.MATLABDriveTag + +# buildtool cache folder +.buildtool/ + +# SimBiology backup files +*.sbproj.backup +*.sbproj.bak diff --git a/AGENTS.md b/AGENTS.md index da6845b..53bc489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,21 +32,19 @@ uihtml-console-rerouter/ ├── README.md ├── LICENSE │ -├── src/ -│ ├── ConsoleErrorRerouter.m ← main MATLAB class -│ └── js/ -│ └── consoleShim.js ← JavaScript error interceptor snippet +├── toolbox/ ← packageable toolbox content +│ ├── ConsoleErrorRerouter.m ← main MATLAB class (includes inlined shim) +│ ├── Contents.m ← toolbox summary for 'help' +│ └── examples/ +│ ├── basic_usage.m ← minimal working example +│ ├── custom_formatting.m ← example using formatting options +│ └── html/ +│ └── example_page.html │ -├── examples/ -│ ├── basic_usage.m ← minimal working example -│ ├── custom_formatting.m ← example using formatting options -│ └── html/ -│ └── example_page.html ← sample HTML file used by examples -│ -└── tests/ - ├── tConsoleErrorRerouter.m ← MATLAB unit tests (matlab.unittest) +└── tests/ ← unit tests (infrastructure) + ├── tConsoleErrorRerouter.m └── html/ - └── test_page.html ← HTML fixture used by tests + └── test_page.html ``` --- @@ -72,21 +70,22 @@ uihtml-console-rerouter/ [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 +### 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. -- Keep `consoleShim.js` self-contained with no external dependencies. -- The shim must call `window.sendEventToMATLAB` using the reserved event name - `"ConsoleError"` and pass a plain object `{ level, message, stack }`. +- The shim must be self-contained and is injected into the HTML as a ` + + diff --git a/tests/tConsoleErrorRerouter.m b/tests/tConsoleErrorRerouter.m new file mode 100644 index 0000000..4418d0d --- /dev/null +++ b/tests/tConsoleErrorRerouter.m @@ -0,0 +1,181 @@ +classdef tConsoleErrorRerouter < matlab.unittest.TestCase + + properties + CustomFormatCalled (1,1) logical = false + end + + methods (Test) + function testConstructorValidComponent(testCase) + comp = createMockUihtmlComponent(); + rerouter = ConsoleErrorRerouter(comp); + testCase.verifyClass(rerouter, "ConsoleErrorRerouter"); + end + + function testConstructorInvalidComponent(testCase) + % Should throw error for a component that does not support ... + % HTMLEventReceived + testCase.verifyError(@() ConsoleErrorRerouter(struct()), ... + "uihtmlRerouter:badArgument"); + end + + function testAllLevelsRerouting(testCase) + % We will test that LastMessage is updated correctly for all levels + comp = createMockUihtmlComponent(); + rerouter = ConsoleErrorRerouter(comp); + + % Enable all levels for this test + rerouter.ErrorLevels = ["error", "warn", "info", "log", "debug"]; + + levels = ["error", "info", "log", "debug"]; + for i = 1:length(levels) + lvl = levels(i); + msg = "Test " + lvl + " message"; + + % Fire event + fireConsoleErrorEvent(comp, lvl, msg); + + % Verify it reached LastMessage + testCase.verifyEqual(rerouter.LastMessage, msg, ... + "Failed to route " + lvl); + end + + % Test warning separately using verifyWarning + msg = "Test warn message"; + testCase.verifyWarning(@() fireConsoleErrorEvent(comp, "warn", msg), ... + "uihtmlRerouter:consoleWarn", ... + "Warning was not thrown or ID is wrong."); + testCase.verifyEqual(rerouter.LastMessage, msg, "Failed to route warn"); + end + + function testErrorLevelsFiltering(testCase) + comp = createMockUihtmlComponent(); + rerouter = ConsoleErrorRerouter(comp); + + % Default is ["error"] + testCase.verifyEqual(rerouter.ErrorLevels, "error"); + + % Fire info message - should be ignored (LastMessage remains empty) + fireConsoleErrorEvent(comp, "info", "Ignored info"); + testCase.verifyEqual(rerouter.LastMessage, ""); + + % Fire error message - should be captured + fireConsoleErrorEvent(comp, "error", "Captured error"); + testCase.verifyEqual(rerouter.LastMessage, "Captured error"); + + % Change levels to only warn + rerouter.ErrorLevels = "warn"; + + % Fire error message - should be ignored + % (LastMessage remains 'Captured error') + fireConsoleErrorEvent(comp, "error", "Ignored error"); + testCase.verifyEqual(rerouter.LastMessage, "Captured error"); + + % Fire warn message - should be captured + testCase.verifyWarning(@() fireConsoleErrorEvent(comp, "warn", ... + "Captured warn"), "uihtmlRerouter:consoleWarn"); + testCase.verifyEqual(rerouter.LastMessage, "Captured warn"); + end + + function testEnabledToggle(testCase) + comp = createMockUihtmlComponent(); + rerouter = ConsoleErrorRerouter(comp); + + % Replace format function to suppress output + rerouter.FormatFcn = @(~,~,~) []; + + rerouter.Enabled = false; + fireConsoleErrorEvent(comp, "error", "Hidden message"); + testCase.verifyEqual(rerouter.LastMessage, ""); + + rerouter.Enabled = true; + fireConsoleErrorEvent(comp, "error", "Visible message"); + testCase.verifyEqual(rerouter.LastMessage, "Visible message"); + end + + function testCustomFormatFcn(testCase) + comp = createMockUihtmlComponent(); + rerouter = ConsoleErrorRerouter(comp); + + testCase.CustomFormatCalled = false; + rerouter.FormatFcn = @(lvl, msg, stack) testCase.markCalled(); + + fireConsoleErrorEvent(comp, "error", "Format this"); + testCase.verifyTrue(testCase.CustomFormatCalled, ... + "Custom formatter not called."); + testCase.verifyEqual(rerouter.LastMessage, "Format this"); + end + + function testCleanTeardown(testCase) + comp = createMockUihtmlComponent(); + + % Add an independent listener + comp.addlistener("HTMLEventReceived", @(~, ~) setExternalFired()); + + rerouter = ConsoleErrorRerouter(comp); + % suppress output during teardown test + rerouter.FormatFcn = @(~,~,~) []; + delete(rerouter); % Delete our rerouter + + % Fire event, ensure the rerouter didn't remove ALL listeners, + % just its own + fireConsoleErrorEvent(comp, "error", "Test after teardown"); + end + + function testShimInjection(testCase) + testDir = fileparts(mfilename("fullpath")); + fixtureHtml = fullfile(testDir, "html", "test_page.html"); + + comp = createMockUihtmlComponent(); + comp.HTMLSource = fixtureHtml; + + rerouter = ConsoleErrorRerouter(comp); + + % Verify HTMLSource was updated to a temporary file + testCase.verifyNotEqual(comp.HTMLSource, fixtureHtml, ... + "HTMLSource should be updated."); + testCase.verifySubstring(comp.HTMLSource, "_rerouter_temp", ... + "HTMLSource should point to a temporary file."); + testCase.verifyTrue(isfile(comp.HTMLSource), ... + "The temporary HTML file should exist."); + + % Read the temporary HTML to verify script injection + tempHtmlContent = fileread(comp.HTMLSource); + + % Verify the presence of the inlined script block + testCase.verifySubstring(tempHtmlContent, ... + "id=""console-rerouter-shim""", ... + "Inlined script tag should be present."); + testCase.verifySubstring(tempHtmlContent, ... + "setup = function(htmlComponent)", ... + "Shim should wrap setup function."); + + % Verify Teardown + tempHtmlFile = comp.HTMLSource; + delete(rerouter); + testCase.verifyFalse(isfile(tempHtmlFile), ... + "Temporary HTML file should be deleted on destruction."); + testCase.verifyEqual(comp.HTMLSource, fixtureHtml, ... + "Original HTMLSource should be restored on destruction."); + end + end + + methods + function markCalled(testCase) + testCase.CustomFormatCalled = true; + end + end +end + +% Helper functions +function comp = createMockUihtmlComponent() + comp = MockUihtmlComponent(); +end + +function fireConsoleErrorEvent(comp, level, message) + eventData = MockHTMLEventData(level, message, ""); + comp.notify("HTMLEventReceived", eventData); +end + +function setExternalFired() + % Dummy callback function to act as an external listener +end diff --git a/toolbox/ConsoleErrorRerouter.m b/toolbox/ConsoleErrorRerouter.m new file mode 100644 index 0000000..8cae0a8 --- /dev/null +++ b/toolbox/ConsoleErrorRerouter.m @@ -0,0 +1,252 @@ +classdef ConsoleErrorRerouter < handle + % ConsoleErrorRerouter Intercepts JavaScript console errors and routes them to the MATLAB Command Window. + % + % obj = ConsoleErrorRerouter(uihtmlComp) creates a rerouter for the given + % uihtml component. + + properties + % Toggles rerouting on/off without destroying the object. Default: true. + Enabled (1,1) logical = true + + % Console levels to intercept. Default: ["error"]. + % Allowed values: "error", "warn", "info", "log", "debug". + ErrorLevels (1,:) string = ["error"] + + % Custom formatter f(level, message, stack) -> void. Default: built-in formatter. + FormatFcn (1,1) function_handle = @ConsoleErrorRerouter.defaultFormatter + end + + properties (SetAccess = private) + % Last message received, for unit testing purposes. + LastMessage string = "" + end + + properties (Access = private) + % Reference to the uihtml component. + HtmlComponent + % Internal listener handle for HTMLEventReceived. + EventListener + % Backup of the original HTMLSource. + OriginalHTMLSource string = "" + % Path to the temporary injected HTML file. + TempHTMLPath string = "" + end + + methods + function obj = ConsoleErrorRerouter(uihtmlComp) + % ConsoleErrorRerouter Constructor + % + % obj = ConsoleErrorRerouter(uihtmlComp) attaches the rerouter to + % the provided uihtml component. + arguments + uihtmlComp (1,1) + end + + obj.HtmlComponent = uihtmlComp; + + % Use addlistener to catch events. This doesn't clobber HTMLEventReceivedFcn. + try + obj.EventListener = addlistener(uihtmlComp, "HTMLEventReceived", ... + @(src, event) obj.onHTMLEventReceived(src, event)); + catch + % Fallback: Check if it's a real uihtml component + if ~isprop(uihtmlComp, "HTMLEventReceivedFcn") && ... + ~isprop(uihtmlComp, "HTMLSource") + error("uihtmlRerouter:badArgument", ... + "Provided component must be a matlab.ui.control.HTML object."); + end + end + + % Handle shim delivery if HTMLSource is provided + if isprop(uihtmlComp, "HTMLSource") && ~isempty(string(uihtmlComp.HTMLSource)) + obj.injectShim(); + end + end + + function delete(obj) + % delete Destructor + % + % Cleans up listeners and temporary files. + if ~isempty(obj.EventListener) && isvalid(obj.EventListener) + delete(obj.EventListener); + end + + % Cleanup shim delivery + obj.removeShim(); + end + end + + methods (Access = private) + function injectShim(obj) + % injectShim Injects the JavaScript shim 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://") + return; + end + + % Read original HTML + try + fid = fopen(source, "r", "n", "utf-8"); + if fid == -1 + return; + end + htmlContent = fread(fid, "*char")'; + fclose(fid); + catch + return; + end + + % Prepare the shim script block + % We wrap the existing setup function to capture the htmlComponent. + shimScriptLines = [ ... + "" ... + ]; + shimScript = join(shimScriptLines, newline); + + % Insert just before or at the end + [startIdx, ~] = regexpi(htmlContent, ""); + if ~isempty(startIdx) + insertPos = startIdx(1); + newHtml = [htmlContent(1:insertPos-1), newline, char(shimScript), ... + newline, htmlContent(insertPos:end)]; + else + newHtml = [htmlContent, newline, char(shimScript)]; + end + + % Write injected HTML to a temporary file in the same directory + [targetDir, name, ext] = fileparts(source); + if isempty(targetDir) + targetDir = pwd; + end + obj.TempHTMLPath = fullfile(targetDir, name + "_rerouter_temp" + ext); + + try + fid = fopen(obj.TempHTMLPath, "w", "n", "utf-8"); + if fid == -1 + return; + end + fwrite(fid, newHtml, "char"); + fclose(fid); + catch + obj.TempHTMLPath = ""; + return; + end + + % Update the component's HTMLSource with the temporary file path. + obj.HtmlComponent.HTMLSource = obj.TempHTMLPath; + end + + function removeShim(obj) + % removeShim Restores the original HTML and cleans up the temporary file. + try + if isa(obj.HtmlComponent, "handle") && isvalid(obj.HtmlComponent) && ... + ~isempty(obj.OriginalHTMLSource) + obj.HtmlComponent.HTMLSource = obj.OriginalHTMLSource; + end + catch + end + + % Delete temporary HTML file + if ~isempty(obj.TempHTMLPath) && isfile(obj.TempHTMLPath) + try + delete(obj.TempHTMLPath); + catch + end + end + end + + function onHTMLEventReceived(obj, ~, eventData) + % onHTMLEventReceived Internal callback for uihtml events. + if ~obj.Enabled + return; + end + + % Standard HTMLEventReceivedData properties: HTMLEventName and HTMLEventData + try + eventName = string(eventData.HTMLEventName); + payload = eventData.HTMLEventData; + catch + % Fallback for cases where eventData might be structured differently + try + eventName = string(eventData.Data.HTMLEventName); + payload = eventData.Data.HTMLEventData; + catch + return; + end + end + + if eventName ~= "ConsoleError" + return; + end + + % Extract console message data + if isstruct(payload) || isobject(payload) + try + level = string(payload.level); + + % Filter based on ErrorLevels + if ~any(level == obj.ErrorLevels) + return; + end + + message = string(payload.message); + if isfield(payload, "stack") || isprop(payload, "stack") + stack = string(payload.stack); + else + stack = ""; + end + catch + return; + end + else + return; + end + + obj.LastMessage = message; + + % Format and output + obj.FormatFcn(level, message, stack); + end + end + + methods (Static, Access = private) + function defaultFormatter(level, message, ~) + % defaultFormatter Built-in formatter using fprintf and warning. + if level == "error" + fprintf(2, "[JS error] %s\n", message); + elseif level == "warn" + % Backtrace off to avoid confusing the user with internal Rerouter stack + state = warning("off", "backtrace"); + warning("uihtmlRerouter:consoleWarn", "[JS warn] %s", message); + warning(state); + else + fprintf(1, "[JS %s] %s\n", char(level), message); + end + end + end +end diff --git a/toolbox/Contents.m b/toolbox/Contents.m new file mode 100644 index 0000000..c5044b0 --- /dev/null +++ b/toolbox/Contents.m @@ -0,0 +1,5 @@ +% UIHTML-CONSOLE-REROUTER +% +% Files +% ConsoleErrorRerouter - Intercepts JavaScript console errors and routes +% them to the MATLAB Command Window. diff --git a/toolbox/examples/basic_usage.m b/toolbox/examples/basic_usage.m new file mode 100644 index 0000000..0bfe0ca --- /dev/null +++ b/toolbox/examples/basic_usage.m @@ -0,0 +1,22 @@ +% Minimal example demonstrating how to use the ConsoleErrorRerouter. + +% Create a UI figure +fig = uifigure('Name', 'Console Error Rerouter Example', 'Position', [100, 100, 600, 400]); + +% Create a UIHTML component +htmlComp = uihtml(fig, 'Position', [10, 10, 580, 380]); + +% Get the absolute path to the example HTML file +filePath = fullfile(fileparts(mfilename('fullpath')), 'html', 'example_page.html'); + +% Load the HTML content +htmlComp.HTMLSource = filePath; + +% Create the rerouter, attaching it to the component. +% It intercepts 'error', 'warn', 'info', 'log', and 'debug' messages +% and outputs them to the Command Window natively. +rerouter = ConsoleErrorRerouter(htmlComp); +rerouter.ErrorLevels = ["error", "warn", "info", "log", "debug"]; + +disp('UI figure created. Click the buttons in the UI to generate console messages.'); +disp('Check the MATLAB Command Window for the rerouted output.'); diff --git a/toolbox/examples/custom_formatting.m b/toolbox/examples/custom_formatting.m new file mode 100644 index 0000000..cf9da08 --- /dev/null +++ b/toolbox/examples/custom_formatting.m @@ -0,0 +1,34 @@ +% Example demonstrating how to use a custom formatter with the ConsoleErrorRerouter. + +fig = uifigure('Name', 'Custom Formatting Example', 'Position', [100, 100, 600, 400]); +htmlComp = uihtml(fig, 'Position', [10, 10, 580, 380]); +filePath = fullfile(fileparts(mfilename('fullpath')), 'html', 'example_page.html'); +htmlComp.HTMLSource = filePath; + +rerouter = ConsoleErrorRerouter(htmlComp); +rerouter.ErrorLevels = ["error", "warn", "info", "log", "debug"]; + +% Override the default FormatFcn with a custom function. +% The custom function must accept (level, message, stack) and return void. +rerouter.FormatFcn = @myCustomFormatter; + +disp('UI figure created. Click the buttons in the UI to generate console messages.'); +disp('Check the MATLAB Command Window for the custom rerouted output.'); + +function myCustomFormatter(level, message, stack) + % A custom formatter that prefixes messages with a timestamp and handles + % its own output. + + timestamp = datestr(now, 'HH:MM:SS'); + + if level == "error" || level == "warn" + % Output errors and warnings to standard error (red text) + fprintf(2, '[%s] JS %s: %s\n', timestamp, upper(level), message); + if ~isempty(stack) + fprintf(2, ' Stack: %s\n', stack); + end + else + % Output other levels to standard output + fprintf(1, '[%s] JS %s: %s\n', timestamp, upper(level), message); + end +end diff --git a/toolbox/examples/html/example_page.html b/toolbox/examples/html/example_page.html new file mode 100644 index 0000000..a835ac3 --- /dev/null +++ b/toolbox/examples/html/example_page.html @@ -0,0 +1,33 @@ + + + + + Console Error Rerouter Example + + +

Console Error Rerouter Example

+

Click the buttons below to generate console errors.

+ + + + + + + diff --git a/toolbox/examples/html/example_page_rerouter_temp.html b/toolbox/examples/html/example_page_rerouter_temp.html new file mode 100644 index 0000000..63e6a20 --- /dev/null +++ b/toolbox/examples/html/example_page_rerouter_temp.html @@ -0,0 +1,57 @@ + + + + + Console Error Rerouter Example + + +

Console Error Rerouter Example

+

Click the buttons below to generate console errors.

+ + + + + + + + + diff --git a/uihtml-debugger.prj b/uihtml-debugger.prj new file mode 100644 index 0000000..6b95f98 --- /dev/null +++ b/uihtml-debugger.prj @@ -0,0 +1,2 @@ + +