mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-29 03:41:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7e8e784c | ||
|
|
c09902aa44 | ||
|
|
bd3140d202 | ||
|
|
5298e49dd2 | ||
|
|
88586eea44 |
@@ -44,6 +44,14 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
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
|
||||
with:
|
||||
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
|
||||
|
||||
+3
-1
@@ -52,4 +52,6 @@ internal_docs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.opc
|
||||
docs/superpowers/
|
||||
/.test/
|
||||
docs/superpowers/
|
||||
ctest_results.xml
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
# This file is made to support the unit tests workflow.
|
||||
# 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)
|
||||
#
|
||||
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
|
||||
@@ -18,6 +19,52 @@ cd "${ROOT_DIR}" || exit 1
|
||||
TEST_DIR="${1:-build/tests}"
|
||||
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,
|
||||
# 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).
|
||||
|
||||
@@ -285,21 +285,6 @@ inline std::string sanitize_filename(const std::string &filename){
|
||||
const std::regex special_chars("[/\\\\:*?\"<>|]");
|
||||
return std::regex_replace(filename, special_chars, "_");
|
||||
}
|
||||
// Reduce an untrusted, possibly path-qualified name to a single sanitized file name.
|
||||
// Returns an empty string when nothing usable remains.
|
||||
inline std::string sanitize_file_basename(const std::string &name){
|
||||
const size_t sep = name.find_last_of("/\\");
|
||||
const std::string base = sanitize_filename(sep == std::string::npos ? name : name.substr(sep + 1));
|
||||
// Names made only of dots and spaces refer to the folder or its parent, or are stripped to nothing on Windows.
|
||||
return base.find_first_not_of(". ") == std::string::npos ? std::string() : base;
|
||||
}
|
||||
// Marker file a download of this process writes to before it is renamed to filename.
|
||||
boost::filesystem::path download_marker_path(const boost::filesystem::path &dest_folder, const std::string &filename);
|
||||
// Finds a sanitized variant of filename, "name(N).ext" if needed, that neither an entry of dest_folder
|
||||
// nor the download marker of another download uses. The marker at ignored_marker does not count.
|
||||
// Returns true and the name in result, or false and the last name tried.
|
||||
bool find_unused_filename(const boost::filesystem::path &dest_folder, const std::string &filename,
|
||||
const boost::filesystem::path &ignored_marker, std::string &result);
|
||||
// File path / name / extension splitting utilities, working with UTF-8,
|
||||
// to be published to Perl.
|
||||
namespace PerlUtils {
|
||||
|
||||
@@ -1321,31 +1321,6 @@ unsigned get_current_pid()
|
||||
#endif
|
||||
}
|
||||
|
||||
boost::filesystem::path download_marker_path(const boost::filesystem::path &dest_folder, const std::string &filename)
|
||||
{
|
||||
return dest_folder / (filename + "." + std::to_string(get_current_pid()) + ".download");
|
||||
}
|
||||
|
||||
bool find_unused_filename(const boost::filesystem::path &dest_folder, const std::string &filename,
|
||||
const boost::filesystem::path &ignored_marker, std::string &result)
|
||||
{
|
||||
// Probe the name that will be written, so a name the sanitizing maps onto an existing file is versioned too.
|
||||
const std::string sanitized = sanitize_filename(filename);
|
||||
const std::string extension = boost::filesystem::path(sanitized).extension().string();
|
||||
const std::string stem = sanitized.substr(0, sanitized.size() - extension.size());
|
||||
auto is_used = [&](const std::string &name) {
|
||||
const boost::filesystem::path marker = download_marker_path(dest_folder, name);
|
||||
return boost::filesystem::exists(dest_folder / name) || (marker != ignored_marker && boost::filesystem::exists(marker));
|
||||
};
|
||||
result = sanitized;
|
||||
for (size_t version = 1; is_used(result); ++version) {
|
||||
if (version > 999)
|
||||
return false;
|
||||
result = stem + "(" + std::to_string(version) + ")" + extension;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string per_user_temp_id()
|
||||
{
|
||||
#ifdef WIN32
|
||||
|
||||
@@ -71,6 +71,17 @@ bool FileGet::is_subdomain(const std::string& url, const std::string& domain)
|
||||
return false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
unsigned get_current_pid()
|
||||
{
|
||||
#ifdef WIN32
|
||||
return GetCurrentProcessId();
|
||||
#else
|
||||
return ::getpid();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// int = DOWNLOAD ID; string = file path
|
||||
wxDEFINE_EVENT(EVT_DWNLDR_FILE_COMPLETE, wxCommandEvent);
|
||||
// int = DOWNLOAD ID; string = error msg
|
||||
@@ -133,10 +144,25 @@ void FileGet::priv::get_perform()
|
||||
std::string extension;
|
||||
if (m_written == 0)
|
||||
{
|
||||
std::string final_filename;
|
||||
bool found = false;
|
||||
boost::filesystem::path dest_path = m_dest_folder / m_filename;
|
||||
extension = dest_path.extension().string();
|
||||
std::string just_filename = m_filename.substr(0, m_filename.size() - extension.size());
|
||||
std::string final_filename = just_filename;
|
||||
// Find unsed filename
|
||||
try {
|
||||
found = find_unused_filename(m_dest_folder, m_filename, m_tmp_path, final_filename);
|
||||
size_t version = 0;
|
||||
while (boost::filesystem::exists(m_dest_folder / (final_filename + extension)) || boost::filesystem::exists(m_dest_folder / (final_filename + extension + "." + std::to_string(get_current_pid()) + ".download")))
|
||||
{
|
||||
++version;
|
||||
if (version > 999) {
|
||||
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_ERROR);
|
||||
evt->SetString(GUI::format_wxstr(L"Failed to find suitable filename. Last name: %1%." , (m_dest_folder / (final_filename + extension)).string()));
|
||||
evt->SetInt(m_id);
|
||||
m_evt_handler->QueueEvent(evt);
|
||||
return;
|
||||
}
|
||||
final_filename = GUI::format("%1%(%2%)", just_filename, std::to_string(version));
|
||||
}
|
||||
} catch (const boost::filesystem::filesystem_error& e)
|
||||
{
|
||||
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_ERROR);
|
||||
@@ -145,18 +171,10 @@ void FileGet::priv::get_perform()
|
||||
m_evt_handler->QueueEvent(evt);
|
||||
return;
|
||||
}
|
||||
if (!found) {
|
||||
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_ERROR);
|
||||
evt->SetString(GUI::format_wxstr(L"Failed to find suitable filename. Last name: %1%." , (m_dest_folder / final_filename).string()));
|
||||
evt->SetInt(m_id);
|
||||
m_evt_handler->QueueEvent(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
m_filename = final_filename;
|
||||
extension = boost::filesystem::path(m_filename).extension().string();
|
||||
m_filename = sanitize_filename(final_filename + extension);
|
||||
|
||||
m_tmp_path = download_marker_path(m_dest_folder, m_filename);
|
||||
m_tmp_path = m_dest_folder / (m_filename + "." + std::to_string(get_current_pid()) + ".download");
|
||||
|
||||
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_NAME_CHANGE);
|
||||
evt->SetString(boost::nowide::widen(m_filename));
|
||||
@@ -203,32 +221,7 @@ void FileGet::priv::get_perform()
|
||||
if(dest_path.empty()) {
|
||||
std::string filename = extract_remote_filename(header);
|
||||
if (!filename.empty()) {
|
||||
// The name comes from the server: keep it inside the destination folder and never
|
||||
// replace an existing file. Keep the current name if nothing usable remains.
|
||||
filename = sanitize_file_basename(filename);
|
||||
std::string unused;
|
||||
try {
|
||||
if (filename.empty() || !find_unused_filename(m_dest_folder, filename, m_tmp_path, unused))
|
||||
unused.clear();
|
||||
} catch (const boost::filesystem::filesystem_error&) {
|
||||
unused.clear();
|
||||
}
|
||||
const boost::filesystem::path tmp_path = unused.empty() ? m_tmp_path : download_marker_path(m_dest_folder, unused);
|
||||
if (tmp_path != m_tmp_path) {
|
||||
// Move the marker to the adopted name so that other downloads see the name as taken.
|
||||
// Only before anything is written, so that no downloaded data has to be carried over.
|
||||
FILE* tmp_file = m_written == 0 ? fopen(wxString(tmp_path.wstring()).c_str(), "wb") : nullptr;
|
||||
if (tmp_file != nullptr) {
|
||||
fclose(file);
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::remove(m_tmp_path, ec);
|
||||
file = tmp_file;
|
||||
m_tmp_path = tmp_path;
|
||||
} else
|
||||
unused.clear();
|
||||
}
|
||||
if (!unused.empty())
|
||||
m_filename = unused;
|
||||
m_filename = filename;
|
||||
dest_path = m_dest_folder / m_filename;
|
||||
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_NAME_CHANGE);
|
||||
evt->SetString(boost::nowide::widen(m_filename));
|
||||
@@ -334,18 +327,6 @@ void FileGet::priv::get_perform()
|
||||
m_evt_handler->QueueEvent(evt);
|
||||
}
|
||||
fclose(file);
|
||||
// Another file may have taken the name while downloading.
|
||||
if (!dest_path.empty() && boost::filesystem::exists(dest_path)) {
|
||||
std::string unused;
|
||||
if (!find_unused_filename(m_dest_folder, m_filename, m_tmp_path, unused))
|
||||
throw std::runtime_error("No unused file name.");
|
||||
m_filename = unused;
|
||||
dest_path = m_dest_folder / m_filename;
|
||||
wxCommandEvent* evt = new wxCommandEvent(EVT_DWNLDR_FILE_NAME_CHANGE);
|
||||
evt->SetString(boost::nowide::widen(m_filename));
|
||||
evt->SetInt(m_id);
|
||||
m_evt_handler->QueueEvent(evt);
|
||||
}
|
||||
boost::filesystem::rename(m_tmp_path, dest_path);
|
||||
}
|
||||
catch (const std::exception& /*e*/)
|
||||
|
||||
+47
-38
@@ -15681,11 +15681,6 @@ void Plater::import_model_id(wxString download_info)
|
||||
//wxString sError = error.what();
|
||||
}
|
||||
|
||||
// The name comes from the link: reduce it to a plain file name inside the download folder.
|
||||
filename = from_u8(sanitize_file_basename(into_u8(filename)));
|
||||
if (filename.empty())
|
||||
filename = "untitled.3mf";
|
||||
|
||||
bool download_ok = false;
|
||||
int retry_count = 0;
|
||||
const int max_retries = 3;
|
||||
@@ -15727,28 +15722,51 @@ void Plater::import_model_id(wxString download_info)
|
||||
|
||||
msg = _L("Preparing 3MF file...");
|
||||
|
||||
//gets the number of files with the same name
|
||||
std::vector<wxString> vecFiles;
|
||||
bool is_already_exist = false;
|
||||
|
||||
|
||||
target_path = fs::path(wxGetApp().app_config->get("download_path"));
|
||||
|
||||
//check file suffix
|
||||
wxString extension = fs::path(filename.wx_str()).extension().c_str();
|
||||
if (!extension.Contains(".3mf")) {
|
||||
msg = _L("Download failed; unknown file format.");
|
||||
return;
|
||||
try
|
||||
{
|
||||
vecFiles.clear();
|
||||
wxString extension = fs::path(filename.wx_str()).extension().c_str();
|
||||
|
||||
|
||||
//check file suffix
|
||||
if (!extension.Contains(".3mf")) {
|
||||
msg = _L("Download failed; unknown file format.");
|
||||
return;
|
||||
}
|
||||
|
||||
auto name = filename.substr(0, filename.length() - extension.length() - 1);
|
||||
|
||||
for (const auto& iter : boost::filesystem::directory_iterator(target_path))
|
||||
{
|
||||
if (boost::filesystem::is_directory(iter.path()))
|
||||
continue;
|
||||
|
||||
wxString sFile = iter.path().filename().string().c_str();
|
||||
if (strstr(sFile.c_str(), name.c_str()) != NULL) {
|
||||
vecFiles.push_back(sFile);
|
||||
}
|
||||
|
||||
if (sFile == filename) is_already_exist = true;
|
||||
}
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
//wxString sError = error.what();
|
||||
}
|
||||
|
||||
//never replace an existing file
|
||||
std::string unused_filename;
|
||||
try {
|
||||
if (!find_unused_filename(target_path, into_u8(filename), {}, unused_filename))
|
||||
unused_filename.clear();
|
||||
} catch (const std::exception&) {
|
||||
unused_filename.clear();
|
||||
//update filename
|
||||
if (is_already_exist && vecFiles.size() >= 1) {
|
||||
wxString extension = fs::path(filename.wx_str()).extension().c_str();
|
||||
wxString name = filename.substr(0, filename.length() - extension.length());
|
||||
filename = wxString::Format("%s(%d)%s", name, vecFiles.size() + 1, extension).ToStdString();
|
||||
}
|
||||
if (unused_filename.empty()) {
|
||||
msg = _L("Importing to Orca Slicer failed. Please download the file and manually import it.");
|
||||
return;
|
||||
}
|
||||
filename = from_u8(unused_filename);
|
||||
|
||||
|
||||
msg = _L("Downloading project...");
|
||||
@@ -15760,6 +15778,10 @@ void Plater::import_model_id(wxString download_info)
|
||||
boost::uuids::uuid uuid = boost::uuids::random_generator()();
|
||||
std::string unique = to_string(uuid).substr(0, 6);
|
||||
|
||||
if (filename.empty()) {
|
||||
filename = "untitled.3mf";
|
||||
}
|
||||
|
||||
//target_path /= (boost::format("%1%_%2%.3mf") % filename % unique).str();
|
||||
target_path /= fs::path(filename.wc_str());
|
||||
|
||||
@@ -15808,26 +15830,13 @@ void Plater::import_model_id(wxString download_info)
|
||||
cont = false;
|
||||
}
|
||||
})
|
||||
.on_complete([&cont, &download_ok, &msg, tmp_path, &target_path](std::string body, unsigned /* http_status */) {
|
||||
.on_complete([&cont, &download_ok, tmp_path, target_path](std::string body, unsigned /* http_status */) {
|
||||
fs::fstream file(tmp_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
file.write(body.c_str(), body.size());
|
||||
file.close();
|
||||
fs::rename(tmp_path, target_path);
|
||||
cont = false;
|
||||
try {
|
||||
// Another file may have taken the name while downloading.
|
||||
std::string unused_filename;
|
||||
if (find_unused_filename(target_path.parent_path(), target_path.filename().string(), {}, unused_filename)) {
|
||||
target_path = target_path.parent_path() / unused_filename;
|
||||
fs::rename(tmp_path, target_path);
|
||||
download_ok = true;
|
||||
return;
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
BOOST_LOG_TRIVIAL(error) << "import_model_id: failed to move the download into place: " << e.what();
|
||||
}
|
||||
boost::system::error_code ec;
|
||||
fs::remove(tmp_path, ec);
|
||||
msg = _L("Importing to Orca Slicer failed. Please download the file and manually import it.");
|
||||
download_ok = true;
|
||||
}).perform_sync();
|
||||
|
||||
// for break while
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <boost/optional.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <wx/string.h>
|
||||
#include <wx/app.h>
|
||||
@@ -115,10 +116,67 @@ std::string PrintHost::get_print_host_webui(DynamicPrintConfig* config)
|
||||
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
|
||||
{
|
||||
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);
|
||||
} else {
|
||||
if (error.find("curl:Timeout was reached") != std::string::npos) {
|
||||
|
||||
@@ -345,16 +345,21 @@ boost::filesystem::path find_bundled_python_home()
|
||||
fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python";
|
||||
if (valid_python_home(bundle_python))
|
||||
return bundle_python;
|
||||
#elif defined(_WIN32)
|
||||
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
|
||||
if (valid_python_home(exe_python))
|
||||
return exe_python;
|
||||
#else
|
||||
#elif !defined(_WIN32)
|
||||
fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python";
|
||||
if (valid_python_home(linux_python))
|
||||
return linux_python;
|
||||
#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;
|
||||
if (!configured_python.empty() && valid_python_home(configured_python))
|
||||
return configured_python;
|
||||
|
||||
@@ -152,94 +152,3 @@ TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed uncha
|
||||
REQUIRE(resolve_cli_input_path("").empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("sanitize_file_basename keeps only a plain file name from an untrusted name", "[Utils]") {
|
||||
const std::string unicode = "\xe6\xa8\xa1\xe5\x9e\x8b \xc3\xa9t\xc3\xa9.3mf"; // UTF-8 CJK and accented Latin
|
||||
const auto [input, expected] = GENERATE_COPY(table<std::string, std::string>({
|
||||
{"normal.3mf", "normal.3mf"},
|
||||
{"../../x.3mf", "x.3mf"},
|
||||
{"..\\..\\x.3mf", "x.3mf"},
|
||||
{"C:\\x.3mf", "x.3mf"},
|
||||
{"C:x.3mf", "C_x.3mf"},
|
||||
{"/etc/x", "x"},
|
||||
{"a/b\\c.gcode", "c.gcode"},
|
||||
{"x:stream", "x_stream"}, // no NTFS alternate data stream
|
||||
{"x.", "x."},
|
||||
{".3mf", ".3mf"},
|
||||
{unicode, unicode},
|
||||
}));
|
||||
CAPTURE(input);
|
||||
CHECK(sanitize_file_basename(input) == expected);
|
||||
}
|
||||
|
||||
TEST_CASE("sanitize_file_basename rejects names that do not name a file", "[Utils]") {
|
||||
const std::string input = GENERATE(as<std::string>{}, "", ".", "..", "../..", "dir/", "..\\", " ", ". .", "...");
|
||||
CAPTURE(input);
|
||||
CHECK(sanitize_file_basename(input).empty());
|
||||
}
|
||||
|
||||
namespace {
|
||||
void touch(const boost::filesystem::path &path) { std::ofstream(path.string()) << "existing"; }
|
||||
std::string file_contents(const boost::filesystem::path &path)
|
||||
{
|
||||
std::ifstream file(path.string());
|
||||
return std::string(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("find_unused_filename keeps a name nothing uses", "[Utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
std::string name;
|
||||
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
||||
CHECK(name == "model.3mf");
|
||||
}
|
||||
|
||||
TEST_CASE("find_unused_filename versions a name an existing file uses", "[Utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
touch(dir.path() / "model.3mf");
|
||||
std::string name;
|
||||
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
||||
CHECK(name == "model(1).3mf");
|
||||
}
|
||||
|
||||
TEST_CASE("find_unused_filename versions a name that maps onto an existing file once sanitized", "[Utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
touch(dir.path() / "my_model.3mf");
|
||||
const std::string input = GENERATE(as<std::string>{}, "my?model.3mf", "my:model.3mf", "my*model.3mf");
|
||||
CAPTURE(input);
|
||||
std::string name;
|
||||
REQUIRE(find_unused_filename(dir.path(), input, {}, name));
|
||||
CHECK(name == "my_model(1).3mf");
|
||||
CHECK(file_contents(dir.path() / "my_model.3mf") == "existing");
|
||||
}
|
||||
|
||||
TEST_CASE("find_unused_filename treats the marker of another download as used", "[Utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
touch(download_marker_path(dir.path(), "model.3mf"));
|
||||
std::string name;
|
||||
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
||||
CHECK(name == "model(1).3mf");
|
||||
}
|
||||
|
||||
TEST_CASE("find_unused_filename ignores the marker of the download asking", "[Utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
const boost::filesystem::path own_marker = download_marker_path(dir.path(), "model.3mf");
|
||||
touch(own_marker);
|
||||
std::string name;
|
||||
REQUIRE(find_unused_filename(dir.path(), "model.3mf", own_marker, name));
|
||||
CHECK(name == "model.3mf");
|
||||
}
|
||||
|
||||
TEST_CASE("find_unused_filename gives up after 999 versions", "[Utils]") {
|
||||
ScopedTemporaryDir dir;
|
||||
touch(dir.path() / "model.3mf");
|
||||
for (int version = 1; version < 999; ++version)
|
||||
touch(dir.path() / ("model(" + std::to_string(version) + ").3mf"));
|
||||
std::string name;
|
||||
REQUIRE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
||||
CHECK(name == "model(999).3mf");
|
||||
|
||||
touch(dir.path() / name);
|
||||
REQUIRE_FALSE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
||||
CHECK(name == "model(999).3mf");
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_plugin_printer_agent.cpp
|
||||
test_printhost.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_slicing_pipeline_config.cpp
|
||||
test_plugin_sort.cpp
|
||||
@@ -48,9 +49,16 @@ if (WIN32)
|
||||
COMMENT "Copying Python runtime for slic3rutils plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
elseif (APPLE)
|
||||
target_link_options(${_TEST_NAME}_tests PRIVATE
|
||||
"LINKER:-rpath,@executable_path/python/lib")
|
||||
elseif (NOT FLATPAK)
|
||||
# The CI unit-test runner only receives the build/tests tree, so both the
|
||||
# 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
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf
|
||||
@@ -58,7 +66,7 @@ elseif (APPLE)
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_PREFIX_PATH}/libpython"
|
||||
"$<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
|
||||
)
|
||||
elseif (FLATPAK)
|
||||
@@ -75,4 +83,16 @@ elseif (FLATPAK)
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user