diff --git a/src/commands.hxx b/src/commands.hxx new file mode 100644 index 0000000..67d99b6 --- /dev/null +++ b/src/commands.hxx @@ -0,0 +1,1466 @@ +/*! @file + + @id $Id$ +*/ +// 1 2 3 4 5 6 7 8 +// 45678901234567890123456789012345678901234567890123456789012345678901234567890 +#ifndef COMMANDS_HXX +#define COMMANDS_HXX + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class Script; +class Command; + +class Logger { + public: + Logger(Command* command, Script* script); + void plainlog(QString txt); + void log(QString txt); + ~Logger(); + private: + Command* _command; + Script* _script; +}; + +class Command: public QObject { + Q_OBJECT; + public: + Command(): _log(true), _line(-1) {} + virtual ~Command() {} + virtual QString tag() const = 0; + virtual QString description() const = 0; + virtual QString command() const = 0; + virtual std::shared_ptr parse(Script*, QString, + QStringList&, int) = 0; + virtual bool execute(Script*, QWebFrame*) = 0; + void line(int linenr) { + _line = linenr; + } + int line() const { + return _line; + } + void testsuite(QString name) { + _testsuite = name; + } + QString testsuite() { + return _testsuite; + } + void targetdir(QString name) { + _targetdir = name; + } + QString targetdir() { + return _targetdir; + } + bool log() { + return _log; + } + void log(bool l) { + _log = l; + } + QString result() { + return _result; + } + protected: + void sleep(int s) { + QTime dieTime= QTime::currentTime().addSecs(s); + while (QTime::currentTime()page()->currentFrame(), selector, log, repeat, sleepsec); + if (!element.isNull()) return element; + element = find1(frame->page()->mainFrame(), selector, log, repeat, sleepsec); + if (!element.isNull()) return element; + return element; + } + QWebElement find1(QWebFrame* frame, QString selector, Logger& log, + int repeat = 5, int sleepsec = 1) { + QWebElement element; + for (int i=0; ifindFirstElement(selector); + if (!element.isNull()) return element; + Q_FOREACH(QWebFrame* childFrame, frame->childFrames()) { + element = find1(childFrame, selector, log, 1, 0); + if (!element.isNull()) return element; + } + if (sleepsec) sleep(sleepsec); + } + return element; + } + void realMouseClick(QWebFrame* frame, QString selector, Logger& log) { + QWebElement element(find(frame, selector, log)); + if (element.isNull()) throw ElementNotFound(selector); + QWidget* web(frame->page()->view()); + QRect elGeom=element.geometry(); + QPoint elPoint=elGeom.center(); + int elX=elPoint.x(); + int elY=elPoint.y(); + int webWidth=web->width(); + int webHeight=web->height(); + int pixelsToScrolRight=0; + int pixelsToScrolDown=0; + if (elX>webWidth) + pixelsToScrolRight = //the +10 scrolls a bit further + elX-webWidth+elGeom.width()/2+10; + if (elY>webHeight) + pixelsToScrolDown = //the +10 scrolls a bit further + elY-webHeight+elGeom.height()/2+10; + frame->setScrollBarValue(Qt::Horizontal, pixelsToScrolRight); + frame->setScrollBarValue(Qt::Vertical, pixelsToScrolDown); + QPoint pointToClick(elX-pixelsToScrolRight, elY-pixelsToScrolDown); + QMouseEvent pressEvent(QMouseEvent::MouseButtonPress, + pointToClick, Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + QCoreApplication::sendEvent(web, &pressEvent); + QMouseEvent releaseEvent(QMouseEvent::MouseButtonRelease, + pointToClick, Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + QCoreApplication::sendEvent(web, &releaseEvent); + QCoreApplication::processEvents(); + } + void log(Script*); + bool _log; + protected: + QString _result; + private: + int _line; + QString _testsuite; + QString _targetdir; +}; + +class Empty: public Command { + public: + QString tag() const { + return ""; + } + QString description() const { + return + "" + "\n\n" + "Empty lines are allowed"; + } + QString command() const { + return ""; + } + std::shared_ptr parse(Script*, QString, QStringList&, int) { + std::shared_ptr cmd(new Empty()); + return cmd; + } + bool execute(Script*, QWebFrame*) { + return true; + } +}; + +class Comment: public Command { + public: + Comment(QString line): _line(line) {} + QString tag() const { + return "#"; + } + QString description() const { + return + "# comment" + "\n\n" + "Comments are lines that start with #"; + } + QString command() const { + return _line; + } + std::shared_ptr parse(Script*, QString args, QStringList&, int) { + std::shared_ptr cmd(new Comment(args)); + return cmd; + } + bool execute(Script* script, QWebFrame*) { + Logger log(this, script); + return true; + } + private: + QString _line; +}; + +class Screenshot: public Command { + public: + static QString sourceHtml(int line, QString target, QString base, + QString name, QWebFrame* frame) { + if (!QDir(target).exists() && !QDir().mkpath(target)) + throw DirectoryCannotBeCreated(target); + QCoreApplication::processEvents(); + QString filename(target+QDir::separator()+ + QString("%1-%2-%3.html") + .arg(base) + .arg(line, 4, 10, QChar('0')).arg(name)); + QFile file(filename); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + throw CannotWriteSouceHTML(filename); + QTextStream out(&file); + out<toHtml(); + if (out.status()!=QTextStream::Ok) throw CannotWriteSouceHTML(filename); + return QDir(filename).absolutePath(); + } + static QString screenshot(int line, QString target, QString base, + QString name, QWebFrame* frame) { + bool wasShown(frame->page()->view()->isVisible()); + frame->page()->view()->show(); + QCoreApplication::processEvents(); + QImage image(frame->page()->view()->size(), QImage::Format_RGB32); + QPainter painter(&image); + frame->render(&painter); + painter.end(); + if (!wasShown) frame->page()->view()->hide(); + if (!QDir(target).exists() && !QDir().mkpath(target)) + throw DirectoryCannotBeCreated(target); + QCoreApplication::processEvents(); + QString filename(target+QDir::separator()+ + QString("%1-%2-%3.png") + .arg(base) + .arg(line, 4, 10, QChar('0')).arg(name)); + if (!image.save(filename)) throw CannotWriteScreenshot(filename); + return QDir(filename).absolutePath(); + } + QString tag() const { + return "screenshot"; + } + QString description() const { + return + "screenshot " + "\n\n" + "Create a PNG screenshot of the actual web page and store it in the " + "file .png. If not already opened, a browser window " + "will pop up to take the screenshot."; + } + QString command() const { + return "screenshot "+_filename; + } + std::shared_ptr parse(Script*, QString args, QStringList&, int) { + std::shared_ptr cmd(new Screenshot()); + cmd->_filename = args; + return cmd; + } + bool execute(Script* script, QWebFrame* frame); + private: + QString _filename; +}; + +class RunDownload: public QObject { + Q_OBJECT; + public: + RunDownload(QNetworkReply* reply, QString filename): + _reply(reply), _file(filename) { + connect(_reply, SIGNAL(finished()), SLOT(finished())); + connect(_reply, SIGNAL(downloadProgress(qint64, qint64)), + SLOT(downloadProgress(qint64, qint64))); + _file.open(QIODevice::WriteOnly); + } + ~RunDownload() { + disconnect(_reply, SIGNAL(finished()), this, SLOT(finished())); + disconnect(_reply, SIGNAL(downloadProgress(qint64, qint64)), + this, SLOT(downloadProgress(qint64, qint64))); + delete _reply; + } + Q_SIGNALS: + void completed(bool netsuccess, bool filesuccess); + private Q_SLOTS: + void finished() { + _file.write(_reply->readAll()); + _file.close(); + completed(_reply->error()==QNetworkReply::NoError, + _file.error()==QFileDevice::NoError); + delete this; + } + void downloadProgress(qint64 bytes, qint64) { + _file.write(_reply->read(bytes)); + } + private: + QNetworkReply* _reply; + QFile _file; +}; + +/** @mainpage + + The WebTester web application testing framework consists of three parts: + + -# a gui to create testcases, see @ref webtester + -# a command line tool to run testcases, see @ref webrunner + -# test scripts, see @ref testscript. */ + +class Script: public QObject { + Q_OBJECT; + Q_SIGNALS: + void logging(QString); + public: + typedef std::pair Signal; + public: + static QString xmlattr(QString attr, bool br = false) { + attr.replace("&", "&")//.replace(" ", " ") + .replace("\"", """); + if (br) attr.replace("\n", "
"); + attr.replace("<", "<").replace(">", ">"); + return attr; + } + static QString xmlstr(const std::string& attr) { + return xmlstr(QString::fromStdString(attr)); + } + static QString xmlstr(QString attr) { + return attr + .replace(">", ">").replace("<", "<") + .replace("
", "\n").replace(""", "\"") + .replace(" ", " ").replace("&", "&"); + } + public: + Script() { + initPrototypes(); + } + QString syntax() const { + return + "Script syntax is a text file that consists of list of commands. Each " + "command starts at the begin of a new line. Empty lines are allowed. " + "Lines that start with \"#\" are treated as comments." + "\n\n" + "Note: When a selector is required as parameter, then the selector " + "is a CSS selector that must not contain spaces."; + } + QString commands() const { + QString cmds; + for (auto it(_prototypes.begin()); it!=_prototypes.end(); ++it) + cmds+="\n\n"+it->second->description(); + return cmds.trimmed(); + } + void reset() { + _script.clear(); + while (!_signals.empty()) _signals.pop(); + _timer.stop(); + _ignores.clear(); + _cout.clear(); + _cerr.clear(); + } + std::shared_ptr parse(QStringList& in, int linenr) try { + QString line(in.takeFirst().trimmed()); + QString cmd(line), args; + int space(line.indexOf(' ')); + if (space>0) { + cmd = line.left(space); + args = line.right(line.size()-space-1); + } + Prototypes::const_iterator it(_prototypes.find(cmd)); + if (it!=_prototypes.end()) { + std::shared_ptr command(it->second->parse + (this, args, in, linenr)); + command->line(linenr); + return command; + } else { + return unknown(line); + } + } catch (Exception& e) { + e.line(linenr); + throw; + } + void parse(QStringList in) { + for (int linenr(1), oldsize(0); + oldsize=in.size(), in.size(); + linenr+=oldsize-in.size()) + _script.push_back(parse(in, linenr)); + } + void run(QWebFrame* frame, xml::Node& testsuite, + QString targetdir = QString(), bool screenshots = true, + int maxretries = 0) { + _timeout = 20; // defaults to 20s + _ignoreSignalsUntil.clear(); + addSignals(frame); + _screenshots = screenshots; + _timer.setSingleShot(true); + int retries(0), back(0); + for (auto cmd(_script.begin()); cmd!=_script.end(); ++cmd) { + xml::Node testcase("testcase"); + try { + testcase.attr("classname") = + testsuite.attr("name"); + //xmlattr((*cmd)->command(), true).toStdString(); + testcase.attr("name") = + xmlattr((*cmd)->tag(), true).toStdString(); + if (!_ignores.size() || (*cmd)->tag()=="label") { // not ignored + _timer.start(_timeout*1000); + (*cmd)->testsuite(xmlstr(testsuite.attr("name"))); + (*cmd)->targetdir(!targetdir.isEmpty() ? targetdir : + xmlstr(testsuite.attr("name"))); + try { + if (!(*cmd)->execute(this, frame)) { + _timer.stop(); + if (!back) retries = 0; else --back; + testcase<<(xml::String("system-out") = + xmlattr(_cout).toStdString()); + testcase<<(xml::String("system-err") = + xmlattr(_cerr).toStdString()); + _cout.clear(); + _cerr.clear(); + testsuite<line(), (*cmd)->targetdir(), + QFileInfo((*cmd)->testsuite()).baseName(), + QString("retry-%1") + .arg((ulong)retries, 2, 10, + QLatin1Char('0')), + frame)); + plainlog("[[ATTACHMENT|"+filename+"]]"); + } catch (... ) {} // ignore exception in screenshot + if (++retries<=maxretries) { // retry in that case + QUrl url(frame->url()); + if ((*cmd)->command()=="expect loadFinished true") { + ------cmd; + back += 3; + _ignoreSignalsUntil = "loadStarted"; + frame->load(url); + } else if ((*cmd)->command()=="expect loadStarted") { + ----cmd; + back += 2; + _ignoreSignalsUntil = "loadStarted"; + frame->page()->triggerAction(QWebPage::Stop); + } else if ((*cmd)->command().startsWith("expect urlChanged")) { + QString url2((*cmd)->command()); + url2.remove("expect urlChanged"); + if (url2.size()) url=url2.trimmed(); + ----cmd; + back += 2; + _ignoreSignalsUntil = "loadStarted"; + frame->load(url); + } else { + throw; + } + } else { + throw; + } + log(QString("WARNING: retry#%1, redo last %2 steps; error: %3") + .arg(retries).arg(back).arg(e.what())); + } + _timer.stop(); + if (!back) retries = 0; else --back; + testcase<<(xml::String("system-out") = + xmlattr(_cout).toStdString()); + testcase<<(xml::String("system-err") = + xmlattr(_cerr).toStdString()); + _cout.clear(); + _cerr.clear(); + testsuite<line()); + if (screenshots) + try { // write html source and take a last screenshot on error + { + QString filename(Screenshot::sourceHtml + ((*cmd)->line(), (*cmd)->targetdir(), + QFileInfo((*cmd)->testsuite()).baseName(), + "error", frame)); + plainlog("[[ATTACHMENT|"+filename+"]]"); + } { + QString filename(Screenshot::screenshot + ((*cmd)->line(), (*cmd)->targetdir(), + QFileInfo((*cmd)->testsuite()).baseName(), + "error", frame)); + plainlog("[[ATTACHMENT|"+filename+"]]"); + } + } catch (... ) {} // ignore exception in screenshot + throw; + } + } + removeSignals(frame); + if (!_signals.empty()) throw UnhandledSignals(_signals); + } + QString& cout() { + return _cout; + } + QString& cerr() { + return _cerr; + } + int steps() { + return _script.size(); + } + bool screenshots() { + return _screenshots; + } + Signal getSignal() { + while (!_signals.size()) QCoreApplication::processEvents(); + Signal res(_signals.front()); + _signals.pop(); + return res; + } + QTimer& timer() { + return _timer; + } + void ignoreto(const QString& l) { + _ignores.insert(l); + } + void label(const QString& l) { + _ignores.remove(l); + } + void set(QString name, QString value) { + _variables[name] = value; + } + void unset(QString name) { + _variables.remove(name); + } + void timeout(int t) { + _timeout = t; + } + QString replacevars(QString txt) { + for(QMap::iterator it(_variables.begin()); + it!=_variables.end(); ++it) + txt.replace(it.key(), it.value()); + return txt; + } + public Q_SLOTS: + void log(QString text) { + text = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss ")+text; + logging(text); + std::cout< unknown(QString line) { + if (!line.size()) return std::shared_ptr(new Empty()); + if (line[0]=='#') return std::shared_ptr(new Comment(line)); + throw UnknownCommand(line); // error + } + void addSignals(QWebFrame* frame) { + connect(dynamic_cast + (frame->page()->networkAccessManager()), + SIGNAL(log(QString)), + SLOT(log(QString))); + connect(frame, SIGNAL(contentsSizeChanged(const QSize&)), + SLOT(contentsSizeChanged(const QSize&))); + connect(frame, SIGNAL(iconChanged()), + SLOT(iconChanged())); + connect(frame, SIGNAL(initialLayoutCompleted()), + SLOT(initialLayoutCompleted())); + connect(frame, SIGNAL(javaScriptWindowObjectCleared()), + SLOT(javaScriptWindowObjectCleared())); + connect(frame, SIGNAL(loadFinished(bool)), + SLOT(loadFinished(bool))); + connect(frame, SIGNAL(loadStarted()), + SLOT(loadStarted())); + connect(frame, SIGNAL(titleChanged(const QString&)), + SLOT(titleChanged(const QString&))); + connect(frame, SIGNAL(urlChanged(const QUrl&)), + SLOT(urlChanged(const QUrl&))); + connect(&_timer, SIGNAL(timeout()), SLOT(timeout())); + } + void removeSignals(QWebFrame* frame) { + disconnect(dynamic_cast + (frame->page()->networkAccessManager()), + SIGNAL(log(QString)), + this, SLOT(log(QString))); + disconnect(frame, SIGNAL(contentsSizeChanged(const QSize&)), + this, SLOT(contentsSizeChanged(const QSize&))); + disconnect(frame, SIGNAL(iconChanged()), + this, SLOT(iconChanged())); + disconnect(frame, SIGNAL(initialLayoutCompleted()), + this, SLOT(initialLayoutCompleted())); + disconnect(frame, SIGNAL(javaScriptWindowObjectCleared()), + this, SLOT(javaScriptWindowObjectCleared())); + disconnect(frame, SIGNAL(loadFinished(bool)), + this, SLOT(loadFinished(bool))); + disconnect(frame, SIGNAL(loadStarted()), + this, SLOT(loadStarted())); + disconnect(frame, SIGNAL(titleChanged(const QString&)), + this, SLOT(titleChanged(const QString&))); + disconnect(frame, SIGNAL(urlChanged(const QUrl&)), + this, SLOT(urlChanged(const QUrl&))); + disconnect(frame, SIGNAL(urlChanged(const QUrl&)), + this, SLOT(urlChanged(const QUrl&))); + disconnect(&_timer, SIGNAL(timeout()), this, SLOT(timeout())); + } + void initPrototypes(); + void add(Command* c) { + _prototypes[c->tag()] = std::shared_ptr(c); + } + private Q_SLOTS: + void contentsSizeChanged(const QSize&) { + } + void iconChanged() { + } + void initialLayoutCompleted() { + } + void javaScriptWindowObjectCleared() { + } + void loadFinished(bool ok) { + QString sig(ok?"true":"false"); + if (_ignoreSignalsUntil.size() && + _ignoreSignalsUntil != "loadFinished "+sig) { + log("warning: ignored loadFinished, waiting for "+_ignoreSignalsUntil); + return; + } + _ignoreSignalsUntil.clear(); + log(".... signal"); + log("received loadFinished "+QString(ok?"true":"false")); + log("....................."); + _signals.push(std::make_pair("loadFinished", QStringList(sig))); + } + void loadStarted() { + if (_ignoreSignalsUntil.size() && _ignoreSignalsUntil != "loadStarted") { + log("warning: ignored loadStarted, waiting for "+_ignoreSignalsUntil); + return; + } + _ignoreSignalsUntil.clear(); + log(".... signal"); + log("received loadStarted"); + log("....................."); + _signals.push(std::make_pair("loadStarted", QStringList())); + } + void frameChanged() { + } + void titleChanged(const QString&) { + //_signals.push(std::make_pair("titleChanged", QStringList(title))); + } + void urlChanged(const QUrl& url) { + if (_ignoreSignalsUntil.size() && _ignoreSignalsUntil != "urlChanged") { + log("warning: ignored urlChanged, waiting for "+_ignoreSignalsUntil); + return; + } + _ignoreSignalsUntil.clear(); + log(".... signal"); + log("received urlChanged "+url.toString()); + log("....................."); + _signals.push(std::make_pair("urlChanged", + QStringList(url.toString()))); + } + void timeout() { + throw TimeOut(); + } + private: + typedef std::map> Prototypes; + typedef std::vector> Commands; + Prototypes _prototypes; + Commands _script; + std::queue _signals; + QTimer _timer; + QSet _ignores; + QString _cout; + QString _cerr; + bool _screenshots; + QString _ignoreSignalsUntil; + QMap _variables; + int _timeout; +}; + +class Do: public Command { + public: + QString tag() const { + return "do"; + } + QString description() const { + return + "do \n \n " + "\n\n" + "Execute JavaScript on a CSS selected object. The object is the first " + "object in the DOM tree that matches the given CSS selector. You can " + "refere to the selected object within the scripy by \"this\". The " + "JavaScript code is on the following lines and at least intended by " + "one space"; + } + QString command() const { + return "do "+_selector+_javascript; + } + std::shared_ptr parse(Script*, QString args, + QStringList& in, int) { + std::shared_ptr cmd(new Do()); + cmd->_selector = args; + while (in.size() && in[0].size() && in[0][0]==' ') + cmd->_javascript += "\n"+in.takeFirst(); + return cmd; + } + bool execute(Script* script, QWebFrame* frame) { + Logger log(this, script); + QWebElement element(find(frame, _selector, log)); + if (element.isNull()) throw ElementNotFound(_selector); + _result = + element.evaluateJavaScript(script->replacevars(_javascript)).toString(); + return true; + } + private: + QString _selector; + QString _javascript; +}; + +class Load: public Command { + public: + QString tag() const { + return "load"; + } + QString description() const { + return + "load " + "\n\n" + "Load an URL, the URL is given as parameter in full syntax."; + } + QString command() const { + return "load "+_url; + } + std::shared_ptr parse(Script*, QString args, QStringList&, int) { + std::shared_ptr cmd(new Load()); + cmd->_url = args; + return cmd; + } + bool execute(Script* script, QWebFrame* frame) { + Logger log(this, script); + frame->load(script->replacevars(_url)); + return true; + } + private: + QString _url; +}; + +class Expect: public Command { + public: + QString tag() const { + return "expect"; + } + QString description() const { + return + "expect []" + "\n\n" + "Expect a signal. Signals are emitted by webkit and may contain " + "parameter. If a parameter is given in the script, then the parameter " + "must match exactly. If no parameter is given, then the signal must " + "be emitted, but the parameters of the signal are not checked." + "\n\n" + "Known signals and parameters are:\n" + " - loadFinished \n" + " - loadStarted\n" + " - urlChanged "; + } + QString command() const { + return "expect "+_signal+(_args.size()?" "+_args.join(' '):QString()); + } + std::shared_ptr parse(Script*, QString args, QStringList&, int) { + std::shared_ptr cmd(new Expect()); + cmd->_args = args.split(" "); + cmd->_signal = cmd->_args.takeFirst(); + return cmd; + } + bool execute(Script* script, QWebFrame*) { + Logger log(this, script); + QString signal(_signal); + QStringList args; + Q_FOREACH(QString arg, _args) args.push_back(script->replacevars(arg)); + Script::Signal lastsignal(script->getSignal()); + QStringList lastargs; + Q_FOREACH(QString arg, lastsignal.second) + lastargs.push_back(script->replacevars(arg)); + if (lastsignal.first!=signal || (args.size() && args!=lastargs)) + throw WrongSignal(signal, args, lastsignal); + return true; + } + private: + QString _signal; + QStringList _args; +}; + +class Open: public Command { + public: + QString tag() const { + return "open"; + } + QString description() const { + return + "open" + "\n\n" + "Open the browser window, so you can follow the test steps visually."; + } + QString command() const { + return "open"; + } + std::shared_ptr parse(Script*, QString, QStringList&, int) { + std::shared_ptr cmd(new Open()); + return cmd; + } + bool execute(Script* script, QWebFrame* frame) { + Logger log(this, script); + frame->page()->view()->show(); + return true; + } +}; + +class Sleep: public Command { + public: + QString tag() const { + return "sleep"; + } + QString description() const { + return + "sleep " + "\n\n" + "Sleep for a certain amount of seconds. This helps, if you must wait " + "for some javascript actions, i.e. AJAX or slow pages, and the " + "excpeted signals are not sufficient."; + } + QString command() const { + return "sleep "+_time; + } + std::shared_ptr parse(Script*, QString time, QStringList&, int) { + std::shared_ptr cmd(new Sleep()); + cmd->_time = "10"; // default: 10s + if (time.size()) cmd->_time = time; + return cmd; + } + bool execute(Script* script, QWebFrame*) { + Logger log(this, script); + script->timer().stop(); + bool ok; + unsigned int time(script->replacevars(_time).toUInt(&ok)); + if (!ok) + throw BadArgument(script->replacevars(_time) + +" should be a number of seconds"); + sleep(time); + return true; + } + private: + QString _time; +}; + +class Exit: public Command { + public: + QString tag() const { + return "exit"; + } + QString description() const { + return + "exit" + "\n\n" + "Successfully terminate script immediately. The following commands " + "are not executed. This helps when you debug your scripts and you " + "want the script stop at a certain point for investigations."; + } + QString command() const { + return "exit"; + } + std::shared_ptr parse(Script*, QString, QStringList&, int) { + std::shared_ptr cmd(new Exit()); + return cmd; + } + bool execute(Script* script, QWebFrame*) { + Logger log(this, script); + return false; + } +}; + +class IgnoreTo: public Command { + public: + QString tag() const { + return "ignoreto"; + } + QString description() const { + return + "ignoreto