Compare commits

..
12 changed files with 397 additions and 255 deletions
+8
View File
@@ -44,6 +44,14 @@ jobs:
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: ${{ inputs.artifact }} name: ${{ inputs.artifact }}
# run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages
# beside them; the Windows arm64 build bundles none, so put one on PATH there.
- name: Install uv
if: runner.os == 'Windows' && runner.arch == 'ARM64'
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt
enable-cache: false
- uses: lukka/get-cmake@latest - uses: lukka/get-cmake@latest
with: with:
cmakeVersion: "~4.3.0" # use most recent 4.3.x version cmakeVersion: "~4.3.0" # use most recent 4.3.x version
+2
View File
@@ -52,4 +52,6 @@ internal_docs/
__pycache__/ __pycache__/
*.pyc *.pyc
*.opc *.opc
/.test/
docs/superpowers/ docs/superpowers/
ctest_results.xml
+30 -129
View File
@@ -214,9 +214,9 @@ function ShowModelInfo( pModel )
SendWXDebugInfo("Model Name: "+sModelName); SendWXDebugInfo("Model Name: "+sModelName);
$('#ModelName').text(sModelName); $('#ModelName').html(sModelName);
$('#ModelName').attr('title',sModelName); $('#ModelName').attr('title',sModelName);
$('#ModelAuthorName').text(sModelAuthor); $('#ModelAuthorName').html(sModelAuthor);
switch(UploadType) switch(UploadType)
{ {
@@ -268,7 +268,7 @@ function ShowModelInfo( pModel )
break; break;
} }
$('#Model_Desc').empty().append( SanitizeDescHtml( html_decode(sModelDesc) ) ); $('#Model_Desc').html( html_decode(sModelDesc) );
let ModelPreviewList=pModel.preview_img; let ModelPreviewList=pModel.preview_img;
let TotalPreview=ModelPreviewList.length; let TotalPreview=ModelPreviewList.length;
@@ -281,15 +281,16 @@ function ShowModelInfo( pModel )
if(TotalPreview>0) if(TotalPreview>0)
{ {
$('#ModelPreviewList').empty(); let htmlPreview='';
for(let pn=0;pn<TotalPreview;pn++) for(let pn=0;pn<TotalPreview;pn++)
{ {
//let FTmpPath=decodeURIComponent(ModelPreviewList[pn]); //let FTmpPath=decodeURIComponent(ModelPreviewList[pn]);
let FTmpPath=ModelPreviewList[pn]['filepath']; let FTmpPath=ModelPreviewList[pn]['filepath'];
$('#ModelPreviewList').append( $('<div class="swiper-slide"></div>').append( $('<img class="Model_PrevImg" />').attr('src',FTmpPath) ) ); htmlPreview+='<div class="swiper-slide"><img class="Model_PrevImg" src="'+FTmpPath+'" /></div>';
} }
$('#ModelPreviewList').html(htmlPreview);
$('#Model_Preview_Image').viewer({ $('#Model_Preview_Image').viewer({
title: false, title: false,
fullsreen: false, fullsreen: false,
@@ -409,8 +410,7 @@ function ConstructFileHtml( ID, pItem )
{ {
let fTotal=pItem.length; let fTotal=pItem.length;
let pBoard=$('#'+ID+' .FileListBoard'); let strHtml='';
pBoard.empty();
for( let f=0;f<fTotal;f++ ) for( let f=0;f<fTotal;f++ )
{ {
let pOne=pItem[f]; let pOne=pItem[f];
@@ -443,139 +443,39 @@ function ConstructFileHtml( ID, pItem )
ImgPath='img/default.png'; ImgPath='img/default.png';
} }
//Add html. File names come from the 3MF, so build the nodes rather than concatenating markup. //Add html
let pIconImg=$('<img />').attr('src',ImgPath);
let pMenu=$('<div class="FileMenu"><img src="img/s.svg" /></div>');
if( strClass!='ImageIcon' ) if( strClass!='ImageIcon' )
{ {
pMenu.on('click', function(){ OnClickOpenFile(tPath); }); strHtml+='<div class="FileItem">'+
' <div class="'+strClass+'"><img src="'+ImgPath+'" /></div>'+
' <div class="FileText">'+
' <div class="FileName">'+tName+'</div>'+
' </div>'+
' <div class="FileMenu" onClick="OnClickOpenFile(\''+tPath+'\')"><img src="img/s.svg" /></div>'+
'</div>';
} }
else else
{ {
ImgID++; ImgID++;
let TmpImgID="AF"+ImgID; let TmpImgID="AF"+ImgID;
pIconImg.attr('id',TmpImgID); strHtml+='<div class="FileItem">'+
pMenu.on('click', function(){ OnClickOpenImage(TmpImgID); }); ' <div class="'+strClass+'"><img id="'+TmpImgID+'" src="'+ImgPath+'" /></div>'+
' <div class="FileText">'+
' <div class="FileName">'+tName+'</div>'+
' </div>'+
' <div class="FileMenu" onClick="OnClickOpenImage(\''+TmpImgID+'\')"><img src="img/s.svg" /></div>'+
'</div>';
} }
let pFileItem=$('<div class="FileItem"></div>');
pFileItem.append( $('<div></div>').addClass(strClass).append(pIconImg) );
pFileItem.append( $('<div class="FileText"></div>').append( $('<div class="FileName"></div>').text(tName).attr('title',tName) ) );
pFileItem.append( pMenu );
pBoard.append( pFileItem );
} }
$('#'+ID+' .FileListBoard').html(strHtml);
if( fTotal>0 ) if( fTotal>0 )
$('#'+ID).show(); $('#'+ID).show();
} }
// Descriptions are untrusted 3MF metadata that may carry rich-text HTML (e.g. from MakerWorld).
// Rebuild them from an inert parse, keeping only plain formatting tags and http(s) links and images.
var DescAllowedTags=['P','BR','B','STRONG','I','EM','U','S','STRIKE','DEL','INS','SUB','SUP','SMALL','MARK',
'Q','ABBR','KBD','WBR','H1','H2','H3','H4','H5','H6','UL','OL','LI','DL','DT','DD','BLOCKQUOTE','PRE','CODE',
'HR','SPAN','DIV','FIGURE','FIGCAPTION','TABLE','CAPTION','THEAD','TBODY','TFOOT','TR','TH','TD','A','IMG'];
// Plain attributes kept per tag; the numeric ones must be plain non-negative integers.
var DescAllowedAttrs={'IMG':['alt','title','width','height'],'TD':['colspan','rowspan'],'TH':['colspan','rowspan'],'OL':['start']};
var DescNumericAttrs=['width','height','colspan','rowspan','start'];
// Dropped together with their content; any other unknown tag is unwrapped to its children.
var DescDroppedTags=['SCRIPT','STYLE','TEMPLATE','NOSCRIPT','TEXTAREA','TITLE','IFRAME','FRAME','OBJECT','EMBED','SVG','MATH'];
function IsHttpUrl( strUrl )
{
// The scheme must be written as is, so nothing the URL parser would strip can precede or split it.
if( typeof strUrl!='string' || !/^https?:/i.test(strUrl) )
return false;
try
{
let sProtocol=new URL(strUrl).protocol;
return sProtocol=='http:' || sProtocol=='https:';
}
catch(e)
{
return false;
}
}
// Images load as soon as the page opens, so only https sources are kept: no plain-http requests to the local network.
function IsHttpsUrl( strUrl )
{
return IsHttpUrl(strUrl) && new URL(strUrl).protocol=='https:';
}
// Embedded YouTube players become a plain link to the video.
function GetYouTubeEmbedUrl( pNode )
{
let sSrc=pNode.getAttribute('src');
if( !IsHttpUrl(sSrc) )
return null;
let pUrl=new URL(sSrc);
return ( pUrl.origin=='https://www.youtube.com' && pUrl.pathname.indexOf('/embed/')==0 ) ? pUrl.href : null;
}
function CopyDescNodes( pSrc, pDst )
{
for( let pNode=pSrc.firstChild;pNode!=null;pNode=pNode.nextSibling )
{
if( pNode.nodeType==Node.TEXT_NODE )
{
pDst.appendChild( document.createTextNode(pNode.nodeValue) );
continue;
}
if( pNode.nodeType!=Node.ELEMENT_NODE )
continue;
let sTag=pNode.nodeName.toUpperCase();
if( sTag=='IFRAME' )
{
let sVideoUrl=GetYouTubeEmbedUrl(pNode);
if( sVideoUrl!=null )
{
let pLink=document.createElement('A');
pLink.setAttribute('href',sVideoUrl);
pLink.textContent=sVideoUrl;
pDst.appendChild(pLink);
}
continue;
}
if( $.inArray(sTag,DescDroppedTags)>=0 )
continue;
if( $.inArray(sTag,DescAllowedTags)<0 )
{
CopyDescNodes(pNode,pDst);
continue;
}
let pElem=document.createElement(sTag);
if( sTag=='A' && IsHttpUrl(pNode.getAttribute('href')) )
pElem.setAttribute('href',pNode.getAttribute('href'));
else if( sTag=='IMG' )
{
if( !IsHttpsUrl(pNode.getAttribute('src')) )
continue;
pElem.setAttribute('src',pNode.getAttribute('src'));
}
$.each( DescAllowedAttrs[sTag]||[], function(i,sAttr){
let sValue=pNode.getAttribute(sAttr);
if( sValue!=null && ( $.inArray(sAttr,DescNumericAttrs)<0 || /^\d+$/.test(sValue) ) )
pElem.setAttribute(sAttr,sValue);
});
CopyDescNodes(pNode,pElem);
pDst.appendChild(pElem);
}
}
function SanitizeDescHtml( strHtml )
{
let pFragment=document.createDocumentFragment();
// A DOMParser document is inert: it runs no scripts and loads no resources.
let pDoc=new DOMParser().parseFromString(strHtml,'text/html');
if( pDoc && pDoc.body )
CopyDescNodes(pDoc.body,pFragment);
return pFragment;
}
function ShowProfilelInfo( pProfile ) function ShowProfilelInfo( pProfile )
{ {
//==========Profile Info========== //==========Profile Info==========
@@ -583,10 +483,10 @@ function ShowProfilelInfo( pProfile )
let sProfileAuthor=decodeURIComponent(pProfile.author); let sProfileAuthor=decodeURIComponent(pProfile.author);
let sProfileDesc=decodeURIComponent(pProfile.description); let sProfileDesc=decodeURIComponent(pProfile.description);
$('#ProfileName').text(sProfileName); $('#ProfileName').html(sProfileName);
$('#ProfileAuthor').text(sProfileAuthor); $('#ProfileAuthor').html(sProfileAuthor);
$('#Profile_Desc').empty().append( SanitizeDescHtml( html_decode(sProfileDesc) ) ); $('#Profile_Desc').html( html_decode(sProfileDesc) );
let ProfilePreviewList=pProfile.preview_img; let ProfilePreviewList=pProfile.preview_img;
let TotalPreview=ProfilePreviewList.length; let TotalPreview=ProfilePreviewList.length;
@@ -599,14 +499,15 @@ function ShowProfilelInfo( pProfile )
if(TotalPreview>0) if(TotalPreview>0)
{ {
$('#ProfilePreviewList').empty(); let htmlPreview='';
for(let pn=0;pn<TotalPreview;pn++) for(let pn=0;pn<TotalPreview;pn++)
{ {
let FTmpPath=ProfilePreviewList[pn]['filepath']; let FTmpPath=ProfilePreviewList[pn]['filepath'];
$('#ProfilePreviewList').append( $('<div class="swiper-slide"></div>').append( $('<img class="Model_PrevImg" />').attr('src',FTmpPath) ) ); htmlPreview+='<div class="swiper-slide"><img class="Model_PrevImg" src="'+FTmpPath+'" /></div>';
} }
$('#ProfilePreviewList').html(htmlPreview);
$('#Profile_Preview_Image').viewer({ $('#Profile_Preview_Image').viewer({
title: false, title: false,
fullsreen: false, fullsreen: false,
+48 -1
View File
@@ -2,7 +2,8 @@
# This file is made to support the unit tests workflow. # This file is made to support the unit tests workflow.
# It should only require the directories build/tests, scripts/, and tests/ to function, # It should only require the directories build/tests, scripts/, and tests/ to function,
# and cmake (with ctest) installed. # and cmake (with ctest) installed -- plus network access to PyPI whenever numpy has to
# be installed into a freshly built test tree (see below).
# (otherwise, update the workflow too, but try to avoid to keep things self-contained) # (otherwise, update the workflow too, but try to avoid to keep things self-contained)
# #
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG] # Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
@@ -18,6 +19,52 @@ cd "${ROOT_DIR}" || exit 1
TEST_DIR="${1:-build/tests}" TEST_DIR="${1:-build/tests}"
BUILD_CONFIG="${2:-}" BUILD_CONFIG="${2:-}"
# The slic3rutils plugin-host tests build numpy arrays through the CPython copied next
# to the test binary (see tests/slic3rutils/CMakeLists.txt), which ships no numpy.
# Install it with the uv staged beside that runtime -- the tool the app installs plugin
# dependencies with -- straight into the interpreter's own site-packages: no pip needed
# in the runtime, no PYTHONPATH. Re-checked every run because a rebuild of the test
# target re-copies the runtime; needs network whenever it installs. Pinned so a numpy
# release cannot change results on its own.
NUMPY_VERSION="2.5.3"
# Without numpy those tests assert the numpy-absent error path instead, so a local run
# only warns. Under CI it fails the run, which would otherwise stay green while silently
# dropping the array coverage. (The Flatpak leg runs this inside `flatpak build`, whose
# minimal environment has no CI, and its offline build stages no uv, so numpy stays
# best-effort there.)
numpy_unavailable() {
if [ -n "${CI:-}" ]; then
echo "error: $1" >&2
exit 1
fi
echo "warning: $1; the numpy-backed binding tests will cover only the numpy-absent path."
}
has_pinned_numpy() {
"${python_exe}" -c "import sys, numpy; sys.exit(numpy.__version__ != '${NUMPY_VERSION}')" >/dev/null 2>&1
}
find_args=("${TEST_DIR}" \( -path '*/python/bin/python3' -o -path '*/python/python.exe' \))
# Multi-config trees hold one copy per configuration; only bootstrap the one being run.
[ -n "${BUILD_CONFIG}" ] && find_args+=(-path "*/${BUILD_CONFIG}/*")
python_exe="$(find "${find_args[@]}" -print -quit 2>/dev/null)"
if [ -z "${python_exe}" ]; then
numpy_unavailable "no bundled Python under ${TEST_DIR}"
elif ! has_pinned_numpy; then
uv_exe="${python_exe%/python/*}/tools/uv/uv"
# Builds that bundle no uv (Windows arm64) fall back to one on PATH.
[ -x "${uv_exe}" ] || uv_exe="$(command -v uv)"
echo "Installing numpy ${NUMPY_VERSION} into the embedded test interpreter (${python_exe})..."
if [ -z "${uv_exe}" ]; then
numpy_unavailable "no uv staged beside the tests or on PATH"
elif ! "${uv_exe}" pip install --python "${python_exe}" --only-binary :all: "numpy==${NUMPY_VERSION}" \
|| ! has_pinned_numpy; then
numpy_unavailable "could not install numpy ${NUMPY_VERSION} into ${python_exe}"
fi
fi
# Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp, # Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp,
# which run the built orca-slicer binary that this directory does not contain. # which run the built orca-slicer binary that this directory does not contain.
# --no-tests=error fails the job if the filter matches nothing (instead of passing green). # --no-tests=error fails the job if the filter matches nothing (instead of passing green).
-5
View File
@@ -260,11 +260,6 @@ extern bool is_json_file(const std::string& path);
// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS // Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS
// is rejected on all of them. // is rejected on all of them.
extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root); extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root);
// True if path names an entry strictly inside root: it must be spelled with root as its prefix,
// and must still resolve inside root once symlinks are followed.
extern bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root);
// True if opening a file with this name through the desktop would run it as a program or script.
extern bool is_executable_file_name(const std::string &file_name);
// Orca: custom protocal support utils // Orca: custom protocal support utils
inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); }
-29
View File
@@ -70,7 +70,6 @@
#include <boost/shared_ptr.hpp> #include <boost/shared_ptr.hpp>
#include <boost/algorithm/string/predicate.hpp> #include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/nowide/fstream.hpp> #include <boost/nowide/fstream.hpp>
@@ -1113,34 +1112,6 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p
} }
} }
bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root)
{
const boost::filesystem::path rel = path.lexically_relative(root);
return !rel.empty() && rel != "." && is_path_within_root(rel.string(), root);
}
bool is_executable_file_name(const std::string &file_name)
{
static const std::vector<std::string> executable_extensions = {
// Windows
"exe", "com", "bat", "cmd", "scr", "pif", "msi", "msp", "msc", "cpl", "lnk", "url", "hta", "js", "jse", "vbs",
"vbe", "wsf", "wsh", "ps1", "psm1", "reg", "jar", "appref-ms", "scf", "inf", "py", "pyw",
// macOS
"app", "command", "pkg", "terminal", "workflow",
// Linux
"sh", "bash", "run", "desktop", "appimage"};
// Windows ignores trailing dots and spaces, so "setup.exe." still runs as an .exe.
const size_t end = file_name.find_last_not_of(". ");
if (end == std::string::npos)
return false;
const std::string name = file_name.substr(0, end + 1);
const size_t dot = name.find_last_of('.');
if (dot == std::string::npos || name.find_first_of("/\\", dot) != std::string::npos)
return false;
const std::string extension = boost::algorithm::to_lower_copy(name.substr(dot + 1));
return std::find(executable_extensions.begin(), executable_extensions.end(), extension) != executable_extensions.end();
}
bool is_img_file(const std::string &path) bool is_img_file(const std::string &path)
{ {
return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg");
+2 -18
View File
@@ -27,7 +27,6 @@
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "GUI_ObjectList.hpp" #include "GUI_ObjectList.hpp"
#include "MainFrame.hpp" #include "MainFrame.hpp"
#include "MsgDialog.hpp"
#include <slic3r/GUI/Widgets/WebView.hpp> #include <slic3r/GUI/Widgets/WebView.hpp>
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
@@ -294,24 +293,9 @@ void ProjectPanel::OnScriptMessage(wxWebViewEvent& evt)
if (!accessory_path.empty()) { if (!accessory_path.empty()) {
std::string decode_path = wxGetApp().url_decode(accessory_path.ToStdString()); std::string decode_path = wxGetApp().url_decode(accessory_path.ToStdString());
fs::path path(decode_path); fs::path path(decode_path);
// Only open the project's own extracted auxiliary files, with the root built as in Reload().
fs::path aux_root(encode_path(wxGetApp().plater()->model().get_auxiliary_file_temp_path().c_str()));
if (is_absolute_path_within_root(path, aux_root) && fs::is_regular_file(path)) { if (fs::exists(path)) {
// Attachments come with the project, so ask before running one that is a program or script. wxLaunchDefaultApplication(path.wstring(), 0);
bool open = true;
if (is_executable_file_name(path.filename().string())) {
MessageDialog dlg(this,
wxString::Format(_L("\"%s\" is a program or script. Opening it will run it on this computer.\n\n"
"Only open attachments from projects you trust. Open it anyway?"),
from_path(path.filename())),
_L("Open attachment"), wxICON_WARNING | wxYES_NO);
open = dlg.ShowModal() == wxID_YES;
}
if (open)
wxLaunchDefaultApplication(path.wstring(), 0);
} else {
BOOST_LOG_TRIVIAL(warning) << "open_3mf_accessory: ignoring path outside the project auxiliary directory: " << decode_path;
} }
} }
} }
+59 -1
View File
@@ -6,6 +6,7 @@
#include <boost/optional.hpp> #include <boost/optional.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <wx/string.h> #include <wx/string.h>
#include <wx/app.h> #include <wx/app.h>
@@ -115,10 +116,67 @@ std::string PrintHost::get_print_host_webui(DynamicPrintConfig* config)
return webui_url; return webui_url;
} }
namespace {
// Moonraker (Klipper's API server) reports a raised exception as { "error": { "code", "message", "traceback" } }
// under every host type that connects to it, often with the cause only in the traceback. Returns the reason to show,
// or empty for any other body.
std::string moonraker_error_reason(const std::string &body)
{
const auto root = nlohmann::json::parse(body, nullptr, false);
const auto err = root.find("error");
if (err == root.end())
return {};
const auto message = err->find("message");
const auto traceback = err->find("traceback");
if (message == err->end() || traceback == err->end() || !message->is_string() || !traceback->is_string())
return {};
const auto &msg = message->get_ref<const std::string &>();
const auto &tb = traceback->get_ref<const std::string &>();
if (msg.empty())
return {};
const auto end = tb.find_last_not_of(" \t\r\n");
if (end == std::string::npos)
return msg;
// Chained exceptions each start a new traceback; the one that failed the request is the last.
const auto header = tb.rfind("Traceback (most recent call last):", end);
// Tornado renders a raised HTTPError as "HTTP <code>: <reason>[ (<detail>)]", and the detail may span lines.
const auto code = err->find("code");
if (code != err->end() && code->is_number_integer()) {
const std::string marker = "HTTP " + std::to_string(code->get<int>()) + ": ";
const auto pos = tb.rfind(marker, end);
if (pos != std::string::npos && (header == std::string::npos || pos > header) && pos + marker.size() <= end) {
const std::string reason = tb.substr(pos + marker.size(), end + 1 - pos - marker.size());
// An HTTPError whose detail equals its reason, like HTTPError(401, "Unauthorized"), renders the phrase twice.
return reason == msg + " (" + msg + ")" ? msg : reason;
}
}
// Any other exception's type and message are everything from the first unindented line after its frames.
auto begin = (header == std::string::npos) ? std::string::npos : tb.find('\n', header);
while (begin != std::string::npos && begin < end) {
++begin;
if (tb[begin] != ' ' && tb[begin] != '\r' && tb[begin] != '\n')
break;
begin = tb.find('\n', begin);
}
if (begin == std::string::npos || begin > end) {
const auto nl = tb.rfind('\n', end);
begin = (nl == std::string::npos) ? 0 : nl + 1;
}
return msg + " (" + tb.substr(begin, end + 1 - begin) + ")";
}
} // namespace
wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const
{ {
if (status != 0) { if (status != 0) {
auto wxbody = wxString::FromUTF8(body.data()); const std::string reason = moonraker_error_reason(body);
auto wxbody = wxString::FromUTF8(reason.empty() ? body : reason);
return wxString::Format("HTTP %u: %s", status, wxbody); return wxString::Format("HTTP %u: %s", status, wxbody);
} else { } else {
if (error.find("curl:Timeout was reached") != std::string::npos) { if (error.find("curl:Timeout was reached") != std::string::npos) {
+10 -5
View File
@@ -345,16 +345,21 @@ boost::filesystem::path find_bundled_python_home()
fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python"; fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python";
if (valid_python_home(bundle_python)) if (valid_python_home(bundle_python))
return bundle_python; return bundle_python;
#elif defined(_WIN32) #elif !defined(_WIN32)
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
if (valid_python_home(exe_python))
return exe_python;
#else
fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python"; fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python";
if (valid_python_home(linux_python)) if (valid_python_home(linux_python))
return linux_python; return linux_python;
#endif #endif
// Next to the executable: the Windows install layout, and the runtime copied
// beside every platform's unit-test binary (tests/slic3rutils/CMakeLists.txt).
// The CI test runner only receives the build/tests tree, so the candidates
// below -- all of which point into the deps or install trees -- never resolve
// there.
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
if (valid_python_home(exe_python))
return exe_python;
fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT; fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT;
if (!configured_python.empty() && valid_python_home(configured_python)) if (!configured_python.empty() && valid_python_home(configured_python))
return configured_python; return configured_python;
-62
View File
@@ -152,65 +152,3 @@ TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed uncha
REQUIRE(resolve_cli_input_path("").empty()); REQUIRE(resolve_cli_input_path("").empty());
} }
} }
TEST_CASE("is_absolute_path_within_root accepts only entries inside the root", "[utils]") {
namespace fs = boost::filesystem;
ScopedTemporaryDir outer;
const fs::path root = outer.path() / "Auxiliaries";
fs::create_directories(root / "Others");
const fs::path inside = root / "Others" / "note.txt";
const fs::path outside = outer.path() / "secret.txt";
std::ofstream(inside.string()) << "inside";
std::ofstream(outside.string()) << "outside";
SECTION("a file inside the root") {
REQUIRE(is_absolute_path_within_root(inside, root));
}
SECTION("a path inside the root whose file does not exist yet") {
REQUIRE(is_absolute_path_within_root(root / "Others" / "missing.txt", root));
}
SECTION("the root itself") {
REQUIRE_FALSE(is_absolute_path_within_root(root, root));
}
SECTION("a parent-directory escape spelled under the root") {
REQUIRE_FALSE(is_absolute_path_within_root(root / "Others" / ".." / ".." / "secret.txt", root));
}
SECTION("an absolute path elsewhere") {
REQUIRE_FALSE(is_absolute_path_within_root(outside, root));
}
SECTION("a sibling directory sharing the root's name as a prefix") {
const fs::path sibling = outer.path() / "Auxiliaries2" / "note.txt";
REQUIRE_FALSE(is_absolute_path_within_root(sibling, root));
}
SECTION("a relative path") {
REQUIRE_FALSE(is_absolute_path_within_root(fs::path("Others") / "note.txt", root));
}
SECTION("an empty path") {
REQUIRE_FALSE(is_absolute_path_within_root(fs::path(), root));
}
#ifndef _WIN32
// Creating symlinks on Windows needs elevated rights or developer mode.
SECTION("a symlink inside the root that points outside") {
const fs::path link = root / "Others" / "link.txt";
fs::create_symlink(outside, link);
REQUIRE_FALSE(is_absolute_path_within_root(link, root));
}
#endif
}
TEST_CASE("is_executable_file_name flags attachments that would run as programs", "[utils]") {
const std::string executable = GENERATE(as<std::string>{},
"setup.exe", "SETUP.EXE", "Manual.pdf.exe", "run.bat", "start.cmd", "shortcut.lnk", "site.url", "script.ps1",
"macro.vbs", "tool.jar", "script.py", "Install.command", "Tool.app", "installer.pkg", "install.sh",
"launcher.desktop", "Printer.AppImage", "setup.exe.", "setup.exe ", "setup.exe. .", ".exe");
INFO(executable);
CHECK(is_executable_file_name(executable));
}
TEST_CASE("is_executable_file_name leaves documents and models alone", "[utils]") {
const std::string document = GENERATE(as<std::string>{},
"Manual.pdf", "BOM.xlsx", "notes.txt", "photo.JPG", "assembly.step", "part.stl", "project.3mf", "readme",
"exe", "setup.exe.pdf", "", "...", "dir.exe/readme");
INFO(document);
CHECK_FALSE(is_executable_file_name(document));
}
+24 -4
View File
@@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_install.cpp test_plugin_install.cpp
test_plugin_lifecycle.cpp test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_config.cpp test_slicing_pipeline_config.cpp
test_plugin_sort.cpp test_plugin_sort.cpp
@@ -48,9 +49,16 @@ if (WIN32)
COMMENT "Copying Python runtime for slic3rutils plugin host API tests" COMMENT "Copying Python runtime for slic3rutils plugin host API tests"
VERBATIM VERBATIM
) )
elseif (APPLE) elseif (NOT FLATPAK)
target_link_options(${_TEST_NAME}_tests PRIVATE # The CI unit-test runner only receives the build/tests tree, so both the
"LINKER:-rpath,@executable_path/python/lib") # interpreter and the libpython the test binary links have to travel next to
# the executable; find_bundled_python_home() picks the copy up from there.
if (APPLE)
target_link_options(${_TEST_NAME}_tests PRIVATE
"LINKER:-rpath,@executable_path/python/lib")
else ()
set_property(TARGET ${_TEST_NAME}_tests APPEND PROPERTY BUILD_RPATH "$ORIGIN/python/lib")
endif ()
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E rm -rf COMMAND ${CMAKE_COMMAND} -E rm -rf
@@ -58,7 +66,7 @@ elseif (APPLE)
COMMAND ${CMAKE_COMMAND} -E copy_directory COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_PREFIX_PATH}/libpython" "${CMAKE_PREFIX_PATH}/libpython"
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python" "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
COMMENT "Copying Python runtime for macOS plugin host API tests" COMMENT "Copying Python runtime for the plugin host API tests"
VERBATIM VERBATIM
) )
elseif (FLATPAK) elseif (FLATPAK)
@@ -75,4 +83,16 @@ elseif (FLATPAK)
) )
endif() endif()
# scripts/run_unit_tests.sh installs the tests' numpy into that runtime with this uv,
# staged where the app build tree keeps it (<exe dir>/tools/uv, see src/CMakeLists.txt).
if (ORCA_BUNDLED_UV_EXECUTABLE)
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/tools/uv"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ORCA_BUNDLED_UV_EXECUTABLE}"
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/tools/uv/${ORCA_BUNDLED_UV_FILENAME}"
COMMENT "Copying uv for the plugin host API tests"
VERBATIM
)
endif()
orcaslicer_discover_tests(${_TEST_NAME}_tests) orcaslicer_discover_tests(${_TEST_NAME}_tests)
+213
View File
@@ -0,0 +1,213 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/PrintHost.hpp"
using namespace Slic3r;
namespace {
class TestPrintHost : public PrintHost
{
public:
using PrintHost::format_error;
const char* get_name() const override { return "Test"; }
bool test(wxString&) const override { return true; }
wxString get_test_ok_msg() const override { return {}; }
wxString get_test_failed_msg(wxString&) const override { return {}; }
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { return true; }
bool has_auto_discovery() const override { return false; }
bool can_test() const override { return false; }
PrintHostPostUploadActions get_post_upload_actions() const override { return {}; }
std::string get_host() const override { return {}; }
};
std::string format_error(const std::string& body, const std::string& error, unsigned status)
{
return TestPrintHost().format_error(body, error, status).ToStdString();
}
std::string envelope(int code, const std::string& message, const std::string& traceback)
{
return nlohmann::json{{"error", {{"code", code}, {"message", message}, {"traceback", traceback}}}}.dump();
}
std::string moonraker_error(int code, const std::string& message, const std::string& detail = {})
{
std::string line = "tornado.web.HTTPError: HTTP " + std::to_string(code) + ": " + message;
if (!detail.empty())
line += " (" + detail + ")";
return envelope(code, message, "Traceback (most recent call last):\n ...\n" + line + "\n");
}
// A real Moonraker body for uploading a file that is being printed.
constexpr const char* k_busy_file_403 =
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
} // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
{
const std::string msg = format_error(k_busy_file_403, "", 403);
INFO("actual: " << msg);
CHECK(msg == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("Traceback"));
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("file_manager.py"));
}
TEST_CASE("The specific cause is recovered from a file endpoint's traceback", "[PrintHost]")
{
SECTION("a plain detail")
{
const std::string body = moonraker_error(403, "Forbidden", "File is loaded, upload not permitted");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
}
SECTION("a detail whose own parentheses nest (a filename)")
{
const std::string detail = "Directory does not exist (/home/pi/gcodes/plate (1).gcode)";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that contains the reason phrase")
{
const std::string body = moonraker_error(403, "Forbidden", "Forbidden zone: access denied");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (Forbidden zone: access denied)");
}
SECTION("a detail that spans lines")
{
const std::string detail = "Move out of range\nX=250.000 Y=10.000";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that only repeats the reason phrase is dropped")
{
const std::string body = moonraker_error(401, "Unauthorized", "Unauthorized");
CHECK(format_error(body, "", 401) == "HTTP 401: Unauthorized");
}
}
TEST_CASE("An unhandled exception shows its type and message", "[PrintHost]")
{
const std::string frame = "Traceback (most recent call last):\n"
" File \"/home/pi/moonraker/moonraker/components/file_manager/file_manager.py\", line 1, in write\n"
" self._write(data)\n";
SECTION("a one-line message")
{
const std::string body = envelope(500, "Internal Server Error", frame + "OSError: [Errno 28] No space left on device\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 28] No space left on device)");
}
SECTION("a message that spans lines")
{
const std::string body = envelope(500, "Internal Server Error", frame + "ServerError: Klippy request failed\n see klippy.log\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (ServerError: Klippy request failed\n see klippy.log)");
}
SECTION("raised while handling an HTTPError with the same code")
{
const std::string traceback = frame + "tornado.web.HTTPError: HTTP 500: Internal Server Error (Database locked)\n\n"
"During handling of the above exception, another exception occurred:\n\n" +
frame + "OSError: [Errno 5] Input/output error\n";
const std::string body = envelope(500, "Internal Server Error", traceback);
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
SECTION("a traceback with no header")
{
const std::string body = envelope(500, "Internal Server Error", "OSError: [Errno 5] Input/output error");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
}
TEST_CASE("A reason already complete in message is shown unchanged", "[PrintHost]")
{
SECTION("message is the whole reason, no trailing detail")
{
const std::string body = moonraker_error(503, "Klippy is not ready");
CHECK(format_error(body, "", 503) == "HTTP 503: Klippy is not ready");
}
SECTION("a message that itself contains parentheses is not duplicated")
{
const std::string reason = "Requested blocks (0-5) are unavailable";
const std::string body = moonraker_error(400, reason);
CHECK(format_error(body, "", 400) == "HTTP 400: " + reason);
}
}
TEST_CASE("A Moonraker error with no usable detail shows just the reason phrase", "[PrintHost]")
{
struct Case
{
const char* name;
const char* body;
unsigned status;
const char* expected;
};
const auto c = GENERATE(
Case{"an empty traceback", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": ""}})JSON", 500,
"HTTP 500: Internal Server Error"},
Case{"a traceback of only whitespace", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": "\n \n"}})JSON",
500, "HTTP 500: Internal Server Error"});
DYNAMIC_SECTION(c.name) { CHECK(format_error(c.body, "", c.status) == c.expected); }
}
TEST_CASE("A percent sign in the reason is not a format specifier", "[PrintHost]")
{
const std::string body = moonraker_error(507, "Insufficient Storage", "disk 100% full");
CHECK(format_error(body, "", 507) == "HTTP 507: Insufficient Storage (disk 100% full)");
}
TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "[PrintHost]")
{
SECTION("OctoPrint's string-valued error member")
{
const std::string body = R"JSON({"error": "File not found"})JSON";
CHECK(format_error(body, "", 404) == "HTTP 404: " + body);
}
SECTION("PrusaLink's top-level message, not under error")
{
const std::string body = R"JSON({"title": "Conflict", "message": "Printer is printing"})JSON";
CHECK(format_error(body, "", 409) == "HTTP 409: " + body);
}
SECTION("a body that is not JSON")
{
const std::string html = "<html><head><title>502 Bad Gateway</title></head></html>";
CHECK(format_error(html, "", 502) == "HTTP 502: " + html);
}
SECTION("an error object with no traceback")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error"}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an error object whose traceback is null")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": null}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an envelope whose reason phrase is empty")
{
const std::string body = envelope(403, "", "Traceback (most recent call last):\nOSError: denied\n");
CHECK(format_error(body, "", 403) == "HTTP 403: " + body);
}
SECTION("a transport error with no HTTP status")
{
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
}
}