mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-30 23:04:13 +02:00
Merge pull request #1183 from ispyisail/fix/headless-settings-dir
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 3m54s
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 3m54s
Fix the MCP server's elements_dir on Windows (QET_SETTINGS_DIR)
This commit is contained in:
@@ -161,6 +161,10 @@ python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}'
|
||||
echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check -
|
||||
```
|
||||
|
||||
Pass long arguments, such as a large `qet_edit` operation list, on stdin
|
||||
with `-`: Windows refuses a command line over 32,767 characters
|
||||
(`WinError 206`).
|
||||
|
||||
It prints the tool's JSON result and exits 0, or 1 if the tool reported an
|
||||
error, or 2 if the call itself was malformed. The workspace rule applies as
|
||||
it does in a server. The sandbox has no QElectroTech in it, so only the
|
||||
@@ -472,7 +476,10 @@ Python, plus the hang guard on `addConductor` and the database refresh in
|
||||
collection path, which on a machine that never ran `make install` does not
|
||||
exist. The only symptom is `addElement` reporting that a file plainly
|
||||
present "does not resolve to an element". An absolute `.elmt` path works
|
||||
without it.
|
||||
without it. On Windows and macOS, `elements_dir` needs a QElectroTech that
|
||||
reads `QET_SETTINGS_DIR` (#1178): an older one keeps its settings in the
|
||||
registry or the system preferences, never sees the path written for the
|
||||
run, and uses the collection it was installed with.
|
||||
- **`set_conductor` changes the whole potential, not one segment.** That is
|
||||
what the application does — a wire number describes a potential — so name
|
||||
a terminal carrying exactly one conductor and the change reaches every
|
||||
|
||||
+32
-13
@@ -64,7 +64,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
SERVER_NAME = "qet-mcp"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
@@ -906,22 +906,40 @@ def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path:
|
||||
def _launch_env(env: dict, home: Path, windows: bool) -> dict:
|
||||
"""The environment _run_qet() starts QElectroTech in.
|
||||
|
||||
A private HOME and XDG directories, and, except on Windows, Qt's
|
||||
offscreen platform so no display is needed. The Windows packages ship
|
||||
only the qwindows platform plugin: asked for "offscreen", Qt finds no
|
||||
plugin and stops at a message box nobody can close, so every call hung
|
||||
until its timeout. The export flags and --run open no window there, so
|
||||
the default platform is what they need.
|
||||
A private HOME and XDG directories, and QET_SETTINGS_DIR pointing into
|
||||
them. HOME and XDG move QElectroTech's settings only on Linux: Qt keeps
|
||||
them in the registry on Windows and in the user's preferences on
|
||||
macOS, so without QET_SETTINGS_DIR every run there read the user's own
|
||||
settings and never saw a collection path written for it
|
||||
(qelectrotech-source-mirror#1178). A QElectroTech that knows the
|
||||
variable keeps its settings in an INI file in that folder instead.
|
||||
|
||||
Except on Windows, also Qt's offscreen platform so no display is
|
||||
needed. The Windows packages ship only the qwindows platform plugin:
|
||||
asked for "offscreen", Qt finds no plugin and stops at a message box
|
||||
nobody can close, so every call hung until its timeout. The export
|
||||
flags and --run open no window there, so the default platform is what
|
||||
they need.
|
||||
"""
|
||||
env = dict(env,
|
||||
HOME=str(home),
|
||||
XDG_CONFIG_HOME=str(home / ".config"),
|
||||
XDG_DATA_HOME=str(home / ".local" / "share"))
|
||||
XDG_DATA_HOME=str(home / ".local" / "share"),
|
||||
QET_SETTINGS_DIR=str(home / ".config"))
|
||||
if not windows:
|
||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||
return env
|
||||
|
||||
|
||||
def _collection_setting(collection: PurePath) -> str:
|
||||
"""The settings file that points QElectroTech at @p collection.
|
||||
|
||||
Forward slashes: Qt reads a backslash in these files as an escape, so a
|
||||
Windows path written as-is arrives mangled."""
|
||||
return ("[elements-collections]\n"
|
||||
f"common-collection-path={collection.as_posix()}\n")
|
||||
|
||||
|
||||
def _run_qet(binary: str, args: list[str], timeout: int = 180,
|
||||
elements_dir: str | None = None,
|
||||
script: str | None = None, tail: int = 4000) -> dict:
|
||||
@@ -941,8 +959,10 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180,
|
||||
reporting "does not resolve to an element" for a file that is plainly
|
||||
there. elements_dir writes the one setting that fixes it. The file name
|
||||
is not free-choice: QSettings derives it from the organisation and
|
||||
application names main.cpp sets before this branch runs, so it must be
|
||||
QElectroTech/QElectroTech.conf and nothing else.
|
||||
application names main.cpp sets before this branch runs. It is written
|
||||
twice: QElectroTech/QElectroTech.ini is what a QElectroTech that knows
|
||||
QET_SETTINGS_DIR reads, on every system (see _launch_env()), and
|
||||
QElectroTech/QElectroTech.conf is what an older one reads on Linux.
|
||||
|
||||
script, when given, is written into the sandbox and passed to --run.
|
||||
It lives inside the temporary directory so it cannot collide with a
|
||||
@@ -973,9 +993,8 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180,
|
||||
raise ValueError(f"no such elements directory: {coll}")
|
||||
cfg = home / ".config" / "QElectroTech"
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "QElectroTech.conf").write_text(
|
||||
"[elements-collections]\n"
|
||||
f"common-collection-path={coll}\n", encoding="utf-8")
|
||||
for name in ("QElectroTech.ini", "QElectroTech.conf"):
|
||||
(cfg / name).write_text(_collection_setting(coll), encoding="utf-8")
|
||||
if script is not None:
|
||||
script_path = sandbox / "qet-mcp-edit.js"
|
||||
script_path.write_text(script, encoding="utf-8")
|
||||
|
||||
@@ -2632,6 +2632,13 @@ class LaunchExecutable(unittest.TestCase):
|
||||
self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen")
|
||||
self.assertEqual(env["HOME"], str(self.sandbox))
|
||||
|
||||
def test_settings_stay_in_the_sandbox_everywhere(self):
|
||||
"""HOME and XDG move the settings only on Linux; QET_SETTINGS_DIR
|
||||
moves them on Windows and macOS too (#1178)."""
|
||||
for windows in (True, False):
|
||||
env = m._launch_env({}, self.sandbox, windows)
|
||||
self.assertEqual(env["QET_SETTINGS_DIR"], str(self.sandbox / ".config"))
|
||||
|
||||
def test_elsewhere_runs_a_private_copy(self):
|
||||
exe = m._launch_executable(self.src, self.sandbox, False)
|
||||
self.assertEqual(exe.parent, self.sandbox)
|
||||
@@ -2639,6 +2646,35 @@ class LaunchExecutable(unittest.TestCase):
|
||||
self.assertEqual(exe.read_text(), self.src.read_text())
|
||||
|
||||
|
||||
class ElementsDirSetting(unittest.TestCase):
|
||||
"""elements_dir reaches QElectroTech as a settings file in the sandbox
|
||||
(#1178): the .ini that a QElectroTech knowing QET_SETTINGS_DIR reads on
|
||||
every system, and the .conf an older one reads on Linux."""
|
||||
|
||||
def test_both_files_carry_the_collection(self):
|
||||
seen = {}
|
||||
|
||||
def run(argv, **kwargs):
|
||||
cfg = Path(kwargs["env"]["QET_SETTINGS_DIR"]) / "QElectroTech"
|
||||
for f in cfg.iterdir():
|
||||
seen[f.name] = f.read_text(encoding="utf-8")
|
||||
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||
|
||||
with tempfile.TemporaryDirectory() as coll, \
|
||||
mock.patch.object(m.subprocess, "run", run):
|
||||
m._run_qet("/bin/true", ["x.qet"], elements_dir=coll)
|
||||
want = m._collection_setting(Path(coll))
|
||||
self.assertEqual(seen, {"QElectroTech.ini": want, "QElectroTech.conf": want})
|
||||
self.assertIn(f"common-collection-path={Path(coll).as_posix()}", want)
|
||||
|
||||
def test_a_windows_path_has_forward_slashes(self):
|
||||
"""Qt reads a backslash in the file as an escape."""
|
||||
from pathlib import PureWindowsPath
|
||||
text = m._collection_setting(PureWindowsPath(r"C:\x\custom"))
|
||||
self.assertIn("common-collection-path=C:/x/custom\n", text)
|
||||
self.assertNotIn("\\", text)
|
||||
|
||||
|
||||
class ScriptingDisabledHint(unittest.TestCase):
|
||||
"""QElectroTech may refuse to run scripts at all, and says so in French.
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include <QDomImplementation>
|
||||
#include <QFont>
|
||||
|
||||
#include <QSettings>
|
||||
#include <QStyleFactory>
|
||||
#include <QtConcurrentRun>
|
||||
|
||||
@@ -103,6 +104,19 @@ int main(int argc, char **argv)
|
||||
QCoreApplication::setOrganizationDomain("qelectrotech.org");
|
||||
QCoreApplication::setApplicationName("QElectroTech");
|
||||
|
||||
// QET_SETTINGS_DIR keeps the settings in an INI file in that folder,
|
||||
// <folder>/QElectroTech/QElectroTech.ini, instead of the registry on
|
||||
// Windows, the system preferences on macOS or ~/.config on Linux. A tool
|
||||
// running QElectroTech headlessly (misc/qet-mcp) can then give each run
|
||||
// its own settings, and point it at an element collection, on every
|
||||
// system (issue #1178). Set before anything reads a setting.
|
||||
const QString settings_dir = qEnvironmentVariable("QET_SETTINGS_DIR");
|
||||
if (!settings_dir.isEmpty()) {
|
||||
QSettings::setDefaultFormat(QSettings::IniFormat);
|
||||
QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settings_dir);
|
||||
QSettings::setPath(QSettings::IniFormat, QSettings::SystemScope, settings_dir);
|
||||
}
|
||||
|
||||
// Refuse invalid data when building QDom documents instead of
|
||||
// serializing malformed XML (CVE-2026-15037). This is the default
|
||||
// from Qt 6.12 on; opt in explicitly for older Qt 5/6.
|
||||
|
||||
@@ -391,6 +391,18 @@ if(QET_HAS_SCRIPTING)
|
||||
target_compile_definitions(tst_scriptconductoruuid PRIVATE
|
||||
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\"")
|
||||
|
||||
# QET_SETTINGS_DIR moves the settings into an INI file there (#1178): a
|
||||
# script places a symbol only the folder's settings file can resolve.
|
||||
add_executable(
|
||||
tst_settingsdir
|
||||
tst_settingsdir.cpp)
|
||||
add_test(NAME tst_settingsdir COMMAND tst_settingsdir)
|
||||
add_dependencies(tst_settingsdir qelectrotech)
|
||||
target_link_libraries(tst_settingsdir PRIVATE Qt::Test)
|
||||
target_compile_definitions(tst_settingsdir PRIVATE
|
||||
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
|
||||
"QET_ELEMENTS_DIR=\"${QET_DIR}/elements\"")
|
||||
|
||||
# The project database filled from the file as it is read holds exactly
|
||||
# what it holds filled from the built folios (QET_DATABASE_FROM_FOLIOS=1),
|
||||
# on every example saved once; an older file falls back, saying why.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include <QtTest>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QRegularExpression>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
// QET_SETTINGS_DIR makes QElectroTech keep its settings in an INI file in
|
||||
// that folder (issue #1178). On Windows and macOS that is the only way to
|
||||
// give a headless run its own settings: HOME and XDG_CONFIG_HOME move them
|
||||
// on Linux alone. Checked here on Linux by making the usual settings file
|
||||
// point at an empty collection and the one in QET_SETTINGS_DIR at a
|
||||
// collection holding the symbol a script places: the symbol resolves only
|
||||
// if the folder's file is the one read.
|
||||
class tst_settingsdir : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QTemporaryDir m_dir;
|
||||
|
||||
static void writeCollectionSetting(const QString &file, const QString &collection)
|
||||
{
|
||||
QDir().mkpath(QFileInfo(file).path());
|
||||
QFile f(file);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
f.write("[elements-collections]\ncommon-collection-path=");
|
||||
f.write(collection.toUtf8());
|
||||
f.write("\n");
|
||||
}
|
||||
|
||||
// Runs a script placing common://custom/my_siren.elmt and returns what it
|
||||
// logged: "resolved", "not resolved", or empty if the run itself failed.
|
||||
QString placeTheSymbol(bool with_settings_dir)
|
||||
{
|
||||
const QString root = m_dir.filePath(with_settings_dir ? QStringLiteral("with")
|
||||
: QStringLiteral("without"));
|
||||
const QString home = root + QStringLiteral("/home");
|
||||
const QString settings = root + QStringLiteral("/settings");
|
||||
const QString collection = root + QStringLiteral("/collection");
|
||||
const QString empty_collection = root + QStringLiteral("/empty");
|
||||
QDir().mkpath(collection + QStringLiteral("/custom"));
|
||||
QDir().mkpath(empty_collection);
|
||||
QDir().mkpath(root + QStringLiteral("/tmp"));
|
||||
if (!QFile::copy(QStringLiteral(QET_ELEMENTS_DIR "/10_electric/10_allpole/380_signaling_operating/12_acoustic_signaling/sirene.elmt"),
|
||||
collection + QStringLiteral("/custom/my_siren.elmt")))
|
||||
return QString();
|
||||
|
||||
// The usual place (Linux): an empty collection. The folder: the right one.
|
||||
writeCollectionSetting(home + QStringLiteral("/.config/QElectroTech/QElectroTech.conf"),
|
||||
empty_collection);
|
||||
writeCollectionSetting(settings + QStringLiteral("/QElectroTech/QElectroTech.ini"),
|
||||
collection);
|
||||
|
||||
const QString project = root + QStringLiteral("/p.qet");
|
||||
QFile::copy(QFINDTESTDATA("fixtures/qet_bug_repro_resaved.qet"), project);
|
||||
const QString script = root + QStringLiteral("/add.js");
|
||||
QFile js(script);
|
||||
if (!js.open(QIODevice::WriteOnly))
|
||||
return QString();
|
||||
js.write("var u = qet.addElement(0, 'common://custom/my_siren.elmt', 100, 100);\n"
|
||||
"qet.log('RESULT ' + (u ? 'resolved' : 'not resolved'));\n");
|
||||
js.close();
|
||||
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen"));
|
||||
env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1"));
|
||||
env.insert(QStringLiteral("HOME"), home);
|
||||
env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/.config"));
|
||||
env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/.local/share"));
|
||||
env.insert(QStringLiteral("TMPDIR"), root + QStringLiteral("/tmp"));
|
||||
if (with_settings_dir)
|
||||
env.insert(QStringLiteral("QET_SETTINGS_DIR"), settings);
|
||||
else
|
||||
env.remove(QStringLiteral("QET_SETTINGS_DIR"));
|
||||
|
||||
QProcess proc;
|
||||
proc.setProcessEnvironment(env);
|
||||
proc.setProcessChannelMode(QProcess::MergedChannels);
|
||||
proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--run"), script, project});
|
||||
if (!proc.waitForFinished(60000))
|
||||
return QString();
|
||||
const QString out = QString::fromUtf8(proc.readAll());
|
||||
static const QRegularExpression result(QStringLiteral("RESULT ([a-z ]+)"));
|
||||
return result.match(out).captured(1).trimmed();
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
QVERIFY(m_dir.isValid());
|
||||
QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH)));
|
||||
}
|
||||
|
||||
void theFolderIsRead()
|
||||
{
|
||||
QCOMPARE(placeTheSymbol(true), QStringLiteral("resolved"));
|
||||
}
|
||||
|
||||
// Without the variable nothing changes: the usual file is read, and it
|
||||
// points at the empty collection.
|
||||
void withoutItTheUsualPlaceIsRead()
|
||||
{
|
||||
QCOMPARE(placeTheSymbol(false), QStringLiteral("not resolved"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_APPLESS_MAIN(tst_settingsdir)
|
||||
|
||||
#include "tst_settingsdir.moc"
|
||||
Reference in New Issue
Block a user