now, multi document editing works mostly as designed; unfortunately qt does not send an event when the visibility of a tab window changes

master
Marc Wäckerlin 6 years ago
parent 74910f249a
commit 9142588686
  1. 213
      src/scriptfile.hxx
  2. 142
      src/scriptfile.ui
  3. 444
      src/testgui.hxx
  4. 225
      src/testgui.ui
  5. 20
      src/webtester.cxx

@ -1,23 +1,32 @@
#ifndef __SCRIPTFILE__HXX
#define __SCRIPTFILE__HXX
#include <commands.hxx>
#include <ui_scriptfile.hxx>
#include <QMessageBox>
#include <QScrollBar>
#include <QTextDocumentFragment>
#include <cassert>
class ScriptFile: public QDockWidget, protected Ui::ScriptFile {
Q_OBJECT
Q_SIGNALS:
void modified(ScriptFile*);
void link(QString);
void include(QString);
void close(ScriptFile*);
void run(const QString&, const QString&, bool, Script&);
public:
ScriptFile(QWidget* p=0): QDockWidget(p) {
ScriptFile(QWidget* p = nullptr): QDockWidget(p) {
setupUi(this);
assert(connect(_editor, SIGNAL(textChanged()), SLOT(modified())));
assert(connect(_editor, SIGNAL(include(QString)), SIGNAL(include(QString))));
assert(connect(_editor, SIGNAL(link(QString)), SIGNAL(link(QString))));
_searchBar->hide();
_replaceBar->hide();
_pageBar->hide();
_lineBar->hide();
_progress->hide();
_status->setCurrentIndex(STATUS_NONE);
}
CodeEditor* editor() {
return _editor;
@ -30,10 +39,208 @@ class ScriptFile: public QDockWidget, protected Ui::ScriptFile {
setWindowTitle(name+"[*]");
setWindowModified(false);
}
public Q_SLOTS:
void load(QString name = QString()) {
if (isWindowModified() &&
QMessageBox::question(this, tr("Changes Not Saved"),
tr("Load script without saving changes?"))
!= QMessageBox::Yes)
return;
QString oldname(_name);
if (!name.isEmpty()) _name = name;
QFileInfo info(name);
if (info.absoluteDir()==QDir::current()) _name = info.fileName();
try {
QFile file(_name);
if (!file.open(QIODevice::ReadOnly|QIODevice::Text))
throw std::runtime_error("file open failed");
_editor->setPlainText(QString::fromUtf8(file.readAll()));
if (file.error()!=QFileDevice::NoError)
throw std::runtime_error("file read failed");
setWindowTitle(_name+"[*]");
setWindowModified(false);
_status->setCurrentIndex(STATUS_NONE);
} catch(const std::exception& x) {
QMessageBox::critical(this, tr("Open Failed"),
tr("Reading test script failed, %2. "
"Cannot read test script from file %1.")
.arg(_name).arg(x.what()));
_name = oldname;
}
}
void save(QString name = QString()) {
QString oldname(_name);
if (!name.isEmpty()) _name = name;
QFile file(_name);
try {
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
throw std::runtime_error("file open failed");
QTextStream out(&file);
out<<_editor->toPlainText();
if (out.status()!=QTextStream::Ok)
throw std::runtime_error(std::string("file write failed (")
+char(out.status()+48)+")");
setWindowModified(false);
setWindowTitle(_name+"[*]");
} catch(const std::exception& x) {
QMessageBox::critical(this, tr("Save Failed"),
tr("Saving test script failed, %2. "
"Cannot write test script to file %1.")
.arg(_name).arg(x.what()));
_name = oldname;
}
}
void clear() {
if (isWindowModified() &&
QMessageBox::question(this, tr("Changes Not Saved"),
tr("Clear script without saving changes?"))
!= QMessageBox::Yes)
return;
_editor->clear();
setWindowModified(false);
}
void modified() {
setWindowModified(true);
modified(this);
}
void run() {
_progress->reset();
_progress->show();
_status->setCurrentIndex(STATUS_RUNNING);
bool oldRecordState(_record->isChecked());
_record->setChecked(false);
_record->setEnabled(false);
_run->setEnabled(false);
Script script;
try {
assert(connect(&script, SIGNAL(progress(QString, int, int)), SLOT(progress(QString, int, int))));
QString text(_editor->textCursor().selection().toPlainText());
if (text.isEmpty()) text = _editor->toPlainText();
run(_name, text, _screenshots->isChecked(), script);
_status->setCurrentIndex(STATUS_SUCCESS);
} catch (std::exception &x) {
_status->setCurrentIndex(STATUS_ERROR);
std::shared_ptr<Command> cmd(script.command());
if (cmd)
QMessageBox::critical(this,
tr("Test Failed"),
tr("<html>"
" <h1>Error [%1]</h1>"
" <dl>"
" <dt>Command:</dt><dd><code>%3</code></dd>"
" <dt>File:</dt><dd>%4</dd>"
" <dt>Line:</dt><dd>%5</dd>"
" <dt>Error Message:</dt><dd><pre>%2</pre></dd>"
" </dl>"
"</html>")
.arg(demangle(typeid(x).name()))
.arg(x.what())
.arg(cmd->command())
.arg(cmd->file())
.arg(cmd->line()));
else
QMessageBox::critical(this,
tr("Test Failed"),
tr("<html>"
" <h1>Error [%1]</h1>"
" <p><code>%2</code></p>"
"</html>")
.arg(demangle(typeid(x).name()))
.arg(QString(x.what()).replace("\n", "<br/>")));
}
_run->setEnabled(true);
_record->setEnabled(true);
_record->setChecked(oldRecordState);
_progress->hide();
}
void appendCommand(const QString& txt) {
if (!_record->isChecked()) return;
_editor->appendPlainText(txt);
QScrollBar *vb(_editor->verticalScrollBar());
_editor->moveCursor(QTextCursor::End);
_editor->ensureCursorVisible();
if (!vb) return;
vb->setValue(vb->maximum());
}
void appendCommand(const QString& selector, const QString& txt) {
if (!_record->isChecked()) return;
QString text(_editor->toPlainText());
QStringList lines(text.split("\n"));
bool changed(false);
while (lines.size() &&
(lines.last()=="click "+selector ||
lines.last().startsWith("setvalue "+selector+" -> "))) {
lines.removeLast();
changed = true;
}
if (changed) {
_editor->setPlainText(lines.join("\n"));
_editor->moveCursor(QTextCursor::End);
_editor->ensureCursorVisible();
}
appendCommand(txt);
}
void appendWebLoadFinished(bool ok) {
if (!_record->isChecked()) return;
QString text(_editor->toPlainText());
QStringList lines(text.split("\n"));
if (ok && lines.size()>1 &&
lines.last().startsWith("expect urlChanged") &&
lines.at(lines.size()-2)=="expect loadStarted") {
// replace three expect lines by one single line
QString url(lines.last().replace("expect urlChanged", "").trimmed());
lines.removeLast(); lines.removeLast();
_editor->setPlainText(lines.join("\n"));
_editor->moveCursor(QTextCursor::End);
_editor->ensureCursorVisible();
appendCommand("expect load "+url);
} else {
appendCommand("expect loadFinished "+QString(ok?"true":"false"));
}
}
void unsupportedContent(QNetworkReply* reply) {
if (!_record->isChecked()) return;
QString filename(reply->url().toString().split('/').last());
if (reply->header(QNetworkRequest::ContentDispositionHeader).isValid()) {
QString part(reply->header(QNetworkRequest::ContentDispositionHeader)
.toString());
if (part.contains(QRegularExpression("attachment; *filename="))) {
part.replace(QRegularExpression(".*attachment; *filename="), "");
if (part.size()) filename = part;
}
}
QString text(_editor->toPlainText());
int pos1(text.lastIndexOf(QRegularExpression("^do ")));
int pos2(text.lastIndexOf(QRegularExpression("^load ")));
int pos3(text.lastIndexOf(QRegularExpression("^click ")));
text.insert(std::max({pos1, pos2, pos3}), "download "+filename);
_editor->setPlainText(text);
_editor->moveCursor(QTextCursor::End);
_editor->ensureCursorVisible();
}
void progress(const QString& txt, int pos, int max) {
_progress->setFormat(QString("%1 — %p%").arg(txt));
_progress->setMinimum(0);
_progress->setMaximum(max);
_progress->setValue(pos);
}
void runEnabled(bool f = true) {
_run->setEnabled(false);
}
void on__run_clicked() {
run();
}
protected:
void closeEvent (QCloseEvent*) {
void closeEvent(QCloseEvent*) {
close(this);
}
private:
enum RunStatus {
STATUS_NONE = 0,
STATUS_RUNNING,
STATUS_SUCCESS,
STATUS_ERROR
};
private:
QString _name;
};

@ -7,16 +7,135 @@
<x>0</x>
<y>0</y>
<width>628</width>
<height>378</height>
<height>593</height>
</rect>
</property>
<property name="windowTitle">
<string>DockW&amp;idget</string>
<string>Do&amp;ckWidget</string>
</property>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QVBoxLayout" name="verticalLayout">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="CodeEditor" name="_editor"/>
<layout class="QVBoxLayout" name="verticalLayout"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="CodeEditor" name="_editor"/>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<widget class="QPushButton" name="_record">
<property name="text">
<string>Record</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="_run">
<property name="text">
<string>Run</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="_screenshots">
<property name="text">
<string>Screenshots</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>28</height>
</size>
</property>
</spacer>
</item>
<item alignment="Qt::AlignHCenter">
<widget class="QStackedWidget" name="_status">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="page_6"/>
<widget class="QWidget" name="page_5">
<layout class="QGridLayout" name="gridLayout_9">
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="label_6">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;div style=&quot;font-size: xx-large&quot;&gt;⌛&lt;/div&gt;</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_3">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="label_4">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;div style=&quot;font-size: xx-large; color: green&quot;&gt;✔&lt;/div&gt;</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_4">
<layout class="QGridLayout" name="gridLayout_8">
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="label_5">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;div style=&quot;font-size: xx-large; color: red&quot;&gt;✘&lt;/div&gt;</string>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QWidget" name="_searchBar" native="true">
@ -72,21 +191,28 @@
</widget>
</item>
<item>
<widget class="QWidget" name="_pageBar" native="true">
<widget class="QWidget" name="_lineBar" native="true">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QSpinBox" name="_page"/>
<widget class="QSpinBox" name="_line"/>
</item>
<item>
<widget class="QPushButton" name="_goPage">
<widget class="QPushButton" name="_goLine">
<property name="text">
<string>page</string>
<string>line</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QProgressBar" name="_progress">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
</layout>
</widget>
</widget>

@ -1,7 +1,7 @@
/*! @file
@id $Id$
*/
*/
// 1 2 3 4 5 6 7 8
// 45678901234567890123456789012345678901234567890123456789012345678901234567890
#ifndef TESTGUI_HXX
@ -24,7 +24,6 @@
#include <stdexcept>
#include <QNetworkReply>
#include <QEvent>
#include <QTextDocumentFragment>
#include <mrw/stdext.hxx>
class TestGUI: public QMainWindow, protected Ui::TestGUI {
@ -39,7 +38,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
_inEventFilter(false) {
setWindowTitle("[*]");
setupUi(this);
menuViews->addAction(_scriptDock->toggleViewAction());
setDockOptions(dockOptions()|QMainWindow::GroupedDragging);
menuViews->addAction(_setupScriptDock->toggleViewAction());
menuViews->addAction(_scriptCommandsDock->toggleViewAction());
menuViews->addAction(_domDock->toggleViewAction());
@ -48,8 +47,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
menuViews->addAction(_logDock->toggleViewAction());
menuViews->addAction(_sourceDock->toggleViewAction());
menuViews->addAction(_executeDock->toggleViewAction());
_progress->hide();
_status->setCurrentIndex(STATUS_NONE);
QSettings settings("mrw", "webtester");
restoreGeometry(settings.value("geometry").toByteArray());
restoreState(settings.value("windowstate").toByteArray());
@ -64,22 +62,22 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
_web->installEventFilter(this); // track mouse and keyboard
pg->setForwardUnsupportedContent(true);
_commands->setText(Script().commands(Script::HTML));
assert(connect(menuFile, SIGNAL(aboutToShow()), SLOT(fileMenuOpened())));
assert(connect(QApplication::instance(), SIGNAL(focusChanged(QWidget*, QWidget*)),
SLOT(focusChanged(QWidget*, QWidget*))));
assert(connect(pg, SIGNAL(uploadFile(QString)), SLOT(uploadFile(QString))));
assert(connect(pg, SIGNAL(unsupportedContent(QNetworkReply*)),
SLOT(unsupportedContent(QNetworkReply*))));
assert(connect(pg, SIGNAL(downloadRequested(const QNetworkRequest&)),
SLOT(downloadRequested(const QNetworkRequest&))));
//assert(connect(_testscript, SIGNAL(include(QString)), SLOT(include(QString))));
assert(connect(_testscript, SIGNAL(link(QString)), SLOT(include(QString))));
if (setupScript.size()) loadSetup(setupScript);
if (scriptFile.size()) loadFile(scriptFile);
if (scriptFile.size()) load(scriptFile);
}
virtual ~TestGUI() {}
public Q_SLOTS:
void on__load_clicked() {
enterText(true);
if (_record->isChecked())
appendCommand("load "+map(_url->currentText()));
appendCommand("load "+map(_url->currentText()));
storeUrl(_url->currentText());
_webprogress->setFormat(_url->currentText());
_web->load(_url->currentText());
@ -91,123 +89,34 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
void on__actionOpen_triggered() {
QString name(QFileDialog::getOpenFileName(this, tr("Open Test Script")));
if (name.isEmpty()) return;
loadFile(name);
_status->setCurrentIndex(STATUS_NONE);
load(name);
}
void on__actionOpenSetupScript_triggered() {
QString name(QFileDialog::getOpenFileName(this, tr("Open Setup Script")));
if (name.isEmpty()) return;
loadSetup(name);
_status->setCurrentIndex(STATUS_NONE);
}
void on__actionRevertToSaved_triggered() {
loadFile(_filename);
_status->setCurrentIndex(STATUS_NONE);
ScriptFile* active(activeScriptFile());
if (active) active->load();
}
void on__actionSaveAs_triggered() {
ScriptFile* active(activeScriptFile());
if (!active) return;
QString name(QFileDialog::getSaveFileName(this, tr("Save Test Script")));
if (name.isEmpty()) return;
_filename = name;
on__actionSave_triggered();
_status->setCurrentIndex(STATUS_NONE);
if (!name.isEmpty()) active->save(name);
}
void on__actionSave_triggered() {
QFile file(_filename);
try {
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
throw std::runtime_error("file open failed");
QTextStream out(&file);
out<<_testscript->toPlainText();
if (out.status()!=QTextStream::Ok)
throw std::runtime_error(std::string("file write failed (")
+char(out.status()+48)+")");
_actionSave->setEnabled(true);
_actionRevertToSaved->setEnabled(true);
setWindowModified(false);
setWindowTitle(_filename+"[*]");
} catch(const std::exception& x) {
QMessageBox::critical(this, tr("Save Failed"),
tr("Saving test script failed, %2. "
"Cannot write test script to file %1.")
.arg(_filename).arg(x.what()));
}
_status->setCurrentIndex(STATUS_NONE);
ScriptFile* active(activeScriptFile());
if (active) active->save();
}
void on__actionClear_triggered() {
if (isWindowModified() &&
QMessageBox::question(this, tr("Changes Not Saved"),
tr("Clear script without saving changes?"))
!= QMessageBox::Yes)
return;
_testscript->clear();
_log->clear();
_filename.clear();
_actionSave->setEnabled(false);
_actionRevertToSaved->setEnabled(false);
setWindowTitle("[*]");
setWindowModified(false);
_status->setCurrentIndex(STATUS_NONE);
}
void on__run_clicked() {
_progress->reset();
_progress->show();
_status->setCurrentIndex(STATUS_RUNNING);
bool oldRecordState(_record->isChecked());
_record->setChecked(false);
_record->setEnabled(false);
_run->setEnabled(false);
Script script;
try {
connect(&script, SIGNAL(logging(QString)), SLOT(logging(QString)));
connect(&script, SIGNAL(progress(QString, int, int)), SLOT(progress(QString, int, int)));
std::shared_ptr<xml::Node> testsuites(new xml::Node("testsuite"));
if (_setupscriptactive->isEnabled()
&& _setupscriptactive->isChecked()) {
script.parse(_setupscript->toPlainText().split('\n'), "setup");
script.run(_web->page()->mainFrame(), testsuites, QString(),
_screenshots->isChecked());
script.reset();
}
QString text(_testscript->textCursor().selection().toPlainText());
if (text.isEmpty()) text = _testscript->toPlainText();
script.parse(text.split('\n'), "script");
script.run(_web->page()->mainFrame(), testsuites, QString(),
_screenshots->isChecked());
_status->setCurrentIndex(STATUS_SUCCESS);
} catch (std::exception &x) {
_status->setCurrentIndex(STATUS_ERROR);
std::shared_ptr<Command> cmd(script.command());
if (cmd)
QMessageBox::critical(this,
tr("Test Failed"),
tr("<html>"
" <h1>Error [%1]</h1>"
" <dl>"
" <dt>Command:</dt><dd><code>%3</code></dd>"
" <dt>File:</dt><dd>%4</dd>"
" <dt>Line:</dt><dd>%5</dd>"
" <dt>Error Message:</dt><dd><pre>%2</pre></dd>"
" </dl>"
"</html>")
.arg(demangle(typeid(x).name()))
.arg(x.what())
.arg(cmd->command())
.arg(cmd->file())
.arg(cmd->line()));
else
QMessageBox::critical(this,
tr("Test Failed"),
tr("<html>"
" <h1>Error [%1]</h1>"
" <p><code>%2</code></p>"
"</html>")
.arg(demangle(typeid(x).name()))
.arg(QString(x.what()).replace("\n", "<br/>")));
}
_run->setEnabled(true);
_record->setEnabled(true);
_record->setChecked(oldRecordState);
_progress->hide();
ScriptFile* active(activeScriptFile());
if (active) active->clear();
}
void on__actionRun_triggered() {
ScriptFile* active(activeScriptFile());
if (active) active->run();
}
void on__focused_clicked() {
enterText(true);
@ -228,8 +137,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
}
void on__web_linkClicked(const QUrl& url) {
enterText(true);
if (_record->isChecked())
appendCommand("load "+map(url.url()));
appendCommand("load "+map(url.url()));
}
void on__web_loadProgress(int progress) {
enterText(true);
@ -237,8 +145,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
}
void on__web_loadStarted() {
enterText(true);
if (_record->isChecked())
appendCommand("expect "+map("loadStarted"));
appendCommand("expect "+map("loadStarted"));
_webprogress->setValue(0);
_urlStack->setCurrentIndex(PROGRESS_VIEW);
}
@ -252,8 +159,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
_webprogress->setFormat(url.url());
storeUrl(url);
enterText(true);
if (_record->isChecked())
appendCommand("expect "+map("urlChanged "+url.url()));
appendCommand("expect "+map("urlChanged "+url.url()));
}
void on__web_selectionChanged() {
_source->setPlainText(_web->hasSelection()
@ -262,24 +168,8 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
}
void on__web_loadFinished(bool ok) {
enterText(true);
if (_record->isChecked()) {
QString text(_testscript->toPlainText());
QStringList lines(text.split("\n"));
if (ok && lines.size()>1 &&
lines.last().startsWith("expect urlChanged") &&
lines.at(lines.size()-2)=="expect loadStarted") {
// replace three expect lines by one single line
QString url(lines.last().replace("expect urlChanged", "").trimmed());
lines.removeLast(); lines.removeLast();
_testscript->setPlainText(lines.join("\n"));
_testscript->moveCursor(QTextCursor::End);
_testscript->ensureCursorVisible();
appendCommand("expect "+map("load "+url));
} else {
appendCommand("expect "+map("loadFinished "
+QString(ok?"true":"false")));
}
}
for (auto testscript: _testscripts)
testscript->appendWebLoadFinished(ok);
_urlStack->setCurrentIndex(URL_VIEW);
on__web_selectionChanged();
setLinks();
@ -287,8 +177,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
setDom();
}
void on__setupscript_textChanged() {
bool oldRecordState(_record->isChecked());
_run->setEnabled(false);
for (auto testscript: _testscripts) testscript->runEnabled(false);
_setupscriptactive->setEnabled(false);
try {
_setupscriptstatus->setText(trUtf8("?"));
@ -305,8 +194,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
} catch (std::exception &x) {
_setupscriptstatus->setText(trUtf8(""));
}
_run->setEnabled(true);
_record->setChecked(oldRecordState);
for (auto testscript: _testscripts) testscript->runEnabled(true);
}
void on__forms_currentItemChanged(QTreeWidgetItem* item, QTreeWidgetItem*) {
if (!item) return;
@ -318,32 +206,13 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
}
void uploadFile(QString filename) {
enterText(true);
if (_record->isChecked())
appendCommand("upload "+map(filename));
appendCommand("upload "+map(filename));
}
void unsupportedContent(QNetworkReply* reply) {
if (!_record->isChecked()) return;
QString filename(reply->url().toString().split('/').last());
if (reply->header(QNetworkRequest::ContentDispositionHeader).isValid()) {
QString part(reply->header(QNetworkRequest::ContentDispositionHeader)
.toString());
if (part.contains(QRegularExpression("attachment; *filename="))) {
part.replace(QRegularExpression(".*attachment; *filename="), "");
if (part.size()) filename = part;
}
}
QString text(_testscript->toPlainText());
int pos1(text.lastIndexOf(QRegularExpression("^do ")));
int pos2(text.lastIndexOf(QRegularExpression("^load ")));
int pos3(text.lastIndexOf(QRegularExpression("^click ")));
text.insert(std::max({pos1, pos2, pos3}), "download "+filename);
_testscript->setPlainText(text);
_testscript->moveCursor(QTextCursor::End);
_testscript->ensureCursorVisible();
for (auto testscript: _testscripts) testscript->unsupportedContent(reply);
}
void downloadRequested(const QNetworkRequest&) {
if (_record->isChecked())
appendCommand("download2");
appendCommand("download2");
}
void logging(const QString& txt) {
_log->appendPlainText(txt);
@ -351,49 +220,80 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
if (!vb) return;
vb->setValue(vb->maximum());
}
void progress(const QString& txt, int pos, int max) {
_progress->setFormat(QString("%1 — %p%").arg(txt));
_progress->setMinimum(0);
_progress->setMaximum(max);
_progress->setValue(pos);
void fileMenuOpened() {
focusChanged(nullptr, nullptr);
}
void appendCommand(const QString& txt) {
_testscript->appendPlainText(txt);
QScrollBar *vb(_testscript->verticalScrollBar());
_testscript->moveCursor(QTextCursor::End);
_testscript->ensureCursorVisible();
if (!vb) return;
vb->setValue(vb->maximum());
void modified(ScriptFile* win) {
focusChanged(nullptr, win);
}
void include(QString name) {
if (_testscripts.contains(name)) return;
void focusChanged(QWidget*, QWidget* focus) {
ScriptFile* active(activeScriptFile(focus));
if (active)
setWindowFilePath(active->name());
else
setWindowFilePath(QString());
_actionRevertToSaved->setEnabled(active);
_actionSaveAs->setEnabled(active);
_actionSave->setEnabled(active&&active->isWindowModified());
_actionClear->setEnabled(active);
_actionRun->setEnabled(active);
}
void activate(QString name) {
QFileInfo info(name);
if (info.absoluteDir()==QDir::current()) name = info.fileName();
if (!_testscripts.contains(name)) return load(name);
_testscripts[name]->show();
_testscripts[name]->raise();
_testscripts[name]->activateWindow();
}
void load(QString name) {
QFileInfo info(name);
if (info.absoluteDir()==QDir::current()) name = info.fileName();
if (_testscripts.contains(name)) try {
_testscripts[name]->load(name);
return activate(name);
} catch(const std::exception& x) {
remove(_testscripts[name]);
}
QDockWidget* first(_testscripts.isEmpty()?_setupScriptDock:_testscripts.last());
_testscripts[name] = new ScriptFile(this);
// assert(connect(_testscripts[name], SIGNAL(include(QString)), SLOT(include(QString))));
assert(connect(_testscripts[name], SIGNAL(link(QString)), SLOT(include(QString))));
assert(connect(_testscripts[name], SIGNAL(modified(ScriptFile*)), SLOT(modified(ScriptFile*))));
assert(connect(_testscripts[name], SIGNAL(link(QString)), SLOT(activate(QString))));
assert(connect(_testscripts[name], SIGNAL(close(ScriptFile*)), SLOT(remove(ScriptFile*))));
QFile file(name);
assert(connect(_testscripts[name], SIGNAL(run(const QString&, const QString&, bool, Script&)), SLOT(run(const QString&, const QString&, bool, Script&))));
try {
if (!file.open(QIODevice::ReadOnly|QIODevice::Text))
throw std::runtime_error("file open failed");
_testscripts[name]->editor()->setPlainText(QString::fromUtf8(file.readAll()));
if (file.error()!=QFileDevice::NoError)
throw std::runtime_error("file read failed");
_testscripts[name]->name(name);
tabifyDockWidget(_scriptDock, _testscripts[name]);
// QDockWidget* d(0);
// for (QWidget* w(QApplication::focusWidget()); w&&!(d=qobject_cast<QDockWidget*>(w));
// w=qobject_cast<QWidget*>(w->parent()));
// if (d) d->raise();
_testscripts[name]->raise();
_testscripts[name]->load(name);
tabifyDockWidget(first, _testscripts[name]);
activate(name);
} catch(const std::exception& x) {
remove(_testscripts[name]);
}
}
void remove(ScriptFile* scriptfile) {
/// @todo check if modified
_testscripts.remove(scriptfile->name());
delete scriptfile;
}
void run(const QString& name, const QString& text, bool screenshots, Script& script) {
std::shared_ptr<xml::Node> testsuites(new xml::Node("testsuite"));
assert(connect(&script, SIGNAL(logging(QString)), SLOT(logging(QString))));
if (_setupscriptactive->isEnabled()
&& _setupscriptactive->isChecked()) {
script.parse(_setupscript->toPlainText().split('\n'), "setup");
script.run(_web->page()->mainFrame(), testsuites, QString(), screenshots);
script.reset();
}
script.parse(text.split('\n'), name);
script.run(_web->page()->mainFrame(), testsuites, QString(), screenshots);
}
protected:
ScriptFile* activeScriptFile(QWidget* focus=nullptr) {
//for (auto win: _testscripts) if (win->isActiveWindow()) return win;
ScriptFile* active(nullptr);
for (QObject* wid(focus?focus:QApplication::focusWidget()); !active && wid; wid = wid->parent())
active = dynamic_cast<ScriptFile*>(wid);
return active;
}
void closeEvent(QCloseEvent* event) {
QSettings settings("mrw", "webtester");
settings.setValue("geometry", saveGeometry());
@ -440,41 +340,39 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
case QEvent::MouseButtonRelease: {
enterText(true);
_lastFocused=element;
if (_record->isChecked()) {
if (!element.isNull()) {
QString selected(selector(element));
if (handleMooTools(_lastFocused)) {
// handled in handleMooTools
} else if (_lastFocused.tagName()=="SELECT") {
// click on a select results in a value change
// find all selected options ...
QStringList v;
for (QWebElement option: _lastFocused.findAll("option")) {
//! @bug QT does not support selected
if (option.evaluateJavaScript("this.selected").toBool())
v += value(option);
}
setValue(selected, v);
} else if (_lastFocused.tagName()=="TEXTAREA" ||
(_lastFocused.tagName()=="INPUT" &&
_lastFocused.attribute("type")=="text")) {
// user clickt in a text edit field, so not the klick
// is important, but the text that will be typed
_typing = true;
} else {
if (_web->page()->selectedText() != "") {
// user has selected a text, append a check
appendCommand("exists "+map(selected)
+" -> "+_web->page()->selectedText());
_web->page()->findText(QString());
} else {
// user has clicked without selection, append a click
appendCommand("click "+map(selected));
}
if (!element.isNull()) {
QString selected(selector(element));
if (handleMooTools(_lastFocused)) {
// handled in handleMooTools
} else if (_lastFocused.tagName()=="SELECT") {
// click on a select results in a value change
// find all selected options ...
QStringList v;
for (QWebElement option: _lastFocused.findAll("option")) {
//! @bug QT does not support selected
if (option.evaluateJavaScript("this.selected").toBool())
v += value(option);
}
setValue(selected, v);
} else if (_lastFocused.tagName()=="TEXTAREA" ||
(_lastFocused.tagName()=="INPUT" &&
_lastFocused.attribute("type")=="text")) {
// user clickt in a text edit field, so not the klick
// is important, but the text that will be typed
_typing = true;
} else {
appendCommand("# click, but where?");
if (_web->page()->selectedText() != "") {
// user has selected a text, append a check
appendCommand("exists "+map(selected)
+" -> "+_web->page()->selectedText());
_web->page()->findText(QString());
} else {
// user has clicked without selection, append a click
appendCommand("click "+map(selected));
}
}
} else {
appendCommand("# click, but where?");
}
} break;
case QEvent::MouseButtonPress: {
@ -505,26 +403,6 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
_url->setCurrentText(u.url());
}
}
void loadFile(QString name) {
QFile file(name);
try {
if (!file.open(QIODevice::ReadOnly|QIODevice::Text))
throw std::runtime_error("file open failed");
_testscript->setPlainText(QString::fromUtf8(file.readAll()));
if (file.error()!=QFileDevice::NoError)
throw std::runtime_error("file read failed");
_filename = name;
_actionSave->setEnabled(true);
_actionRevertToSaved->setEnabled(true);
setWindowTitle(name+"[*]");
setWindowModified(false);
} catch(const std::exception& x) {
QMessageBox::critical(this, tr("Open Failed"),
tr("Reading test script failed, %2. "
"Cannot read test script from file %1.")
.arg(name).arg(x.what()));
}
}
void loadSetup(QString name) {
QFile file(name);
try {
@ -535,10 +413,10 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
throw std::runtime_error("file read failed");
on__setupscript_textChanged();
} catch(const std::exception& x) {
QMessageBox::critical(this, tr("Open Failed"),
tr("Reading test script failed, %2. "
"Cannot read test script from file %1.")
.arg(name).arg(x.what()));
QMessageBox::critical(this, tr("Open Failed"),
tr("Reading test script failed, %2. "
"Cannot read test script from file %1.")
.arg(name).arg(x.what()));
}
}
void enterText(bool force=false) {
@ -561,9 +439,9 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
return QString(); // error
}
void highlight(QWebElement element) {
element
.evaluateJavaScript("var selection = window.getSelection();"
"selection.setBaseAndExtent(this, 0, this, 1);");
element
.evaluateJavaScript("var selection = window.getSelection();"
"selection.setBaseAndExtent(this, 0, this, 1);");
}
QWebElement focused(QMouseEvent* event = 0) {
for (QWebElement element: _web->page()->currentFrame()->findAllElements("*")) {
@ -644,41 +522,26 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
return in;
}
void javascript(const QString& selector, QString code) {
if (_record->isChecked())
appendCommand("do "+map(selector)+"\n "
+map(code).replace("\n", "\n "));
}
void cleanup(const QString& selector) {
QString text(_testscript->toPlainText());
QStringList lines(text.split("\n"));
bool changed(false);
while (lines.size() &&
(lines.last()=="click "+selector ||
lines.last().startsWith("setvalue "+selector+" -> "))) {
lines.removeLast();
changed = true;
}
if (changed) {
_testscript->setPlainText(lines.join("\n"));
_testscript->moveCursor(QTextCursor::End);
_testscript->ensureCursorVisible();
}
appendCommand("do "+map(selector)+"\n "
+map(code).replace("\n", "\n "));
}
void setValue(const QString& selector, QString code) {
if (_record->isChecked()) {
cleanup(selector);
appendCommand("setvalue "+map(selector)+" -> '"
+map(code).replace("'", "\\'").replace("\n", "\\n")+"'");
}
appendCommand(selector,
"setvalue "+map(selector)+" -> '"
+map(code).replace("'", "\\'").replace("\n", "\\n")+"'");
}
void setValue(const QString& selector, QStringList code) {
if (_record->isChecked()) {
cleanup(selector);
appendCommand("setvalue "+map(selector)+" -> '"+
map(code.replaceInStrings("'", "\\'")
.replaceInStrings("\n", "\\n")
.join("', '")+"'"));
}
appendCommand(selector,
"setvalue "+map(selector)+" -> '"+
map(code.replaceInStrings("'", "\\'")
.replaceInStrings("\n", "\\n")
.join("', '")+"'"));
}
void appendCommand(const QString& txt) {
for (auto testscript: _testscripts) testscript->appendCommand(txt);
}
void appendCommand(const QString& selector, const QString& txt) {
for (auto testscript: _testscripts) testscript->appendCommand(selector, txt);
}
bool handleMooTools(QWebElement element) {
QString selected(selector(element));
@ -697,10 +560,9 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
return true;
} else if (mooComboItem.hasMatch()) {
// special treatment for item in moo tools combobox
appendCommand
("click realmouse "+map("li.active-result[data-option-array-index=\""
+element.attribute("data-option-array-index")
+"\"]"));
appendCommand("click realmouse "+map("li.active-result[data-option-array-index=\""
+element.attribute("data-option-array-index")
+"\"]"));
appendCommand("sleep "+map("1"));
return true;
} else if (element.tagName()=="INPUT") {
@ -861,7 +723,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
case QEvent::LayoutDirectionChange: return "QEvent::LayoutDirectionChange - The direction of layouts changed.";
case QEvent::LayoutRequest: return "QEvent::LayoutRequest - Widget layout needs to be redone.";
case QEvent::Leave: return "QEvent::Leave - Mouse leaves widget's boundaries.";
//case QEvent::LeaveEditFocus: return "QEvent::LeaveEditFocus - An editor widget loses focus for editing. QT_KEYPAD_NAVIGATION must be defined.";
//case QEvent::LeaveEditFocus: return "QEvent::LeaveEditFocus - An editor widget loses focus for editing. QT_KEYPAD_NAVIGATION must be defined.";
case QEvent::LeaveWhatsThisMode: return "QEvent::LeaveWhatsThisMode - Send to toplevel widgets when the application leaves \"What's This?\" mode.";
case QEvent::LocaleChange: return "QEvent::LocaleChange - The system locale has changed.";
case QEvent::NonClientAreaMouseButtonDblClick: return "QEvent::NonClientAreaMouseButtonDblClick - A mouse double click occurred outside the client area.";
@ -887,7 +749,7 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
case QEvent::Polish: return "QEvent::Polish - The widget is polished.";
case QEvent::PolishRequest: return "QEvent::PolishRequest - The widget should be polished.";
case QEvent::QueryWhatsThis: return "QEvent::QueryWhatsThis - The widget should accept the event if it has \"What's This?\" help.";
//case QEvent::ReadOnlyChange: return "QEvent::ReadOnlyChange - Widget's read-only state has changed (since Qt 5.4).";
//case QEvent::ReadOnlyChange: return "QEvent::ReadOnlyChange - Widget's read-only state has changed (since Qt 5.4).";
case QEvent::RequestSoftwareInputPanel: return "QEvent::RequestSoftwareInputPanel - A widget wants to open a software input panel (SIP).";
case QEvent::Resize: return "QEvent::Resize - Widget's size changed (QResizeEvent).";
case QEvent::ScrollPrepare: return "QEvent::ScrollPrepare - The object needs to fill in its geometry information (QScrollPrepareEvent).";
@ -942,12 +804,6 @@ class TestGUI: public QMainWindow, protected Ui::TestGUI {
URL_VIEW = 0,
PROGRESS_VIEW
};
enum RunStatus {
STATUS_NONE = 0,
STATUS_RUNNING,
STATUS_SUCCESS,
STATUS_ERROR
};
private:
QString _filename;
QWebElement _lastFocused; // cache for last focussed element

@ -10,6 +10,9 @@
<height>1180</height>
</rect>
</property>
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="windowTitle">
<string/>
</property>
@ -101,7 +104,7 @@
<x>0</x>
<y>0</y>
<width>888</width>
<height>30</height>
<height>34</height>
</rect>
</property>
<widget class="QMenu" name="menuViews">
@ -115,14 +118,13 @@
</property>
<addaction name="_actionOpen"/>
<addaction name="_actionOpenSetupScript"/>
<addaction name="separator"/>
<addaction name="_actionSave"/>
<addaction name="_actionSaveAs"/>
<addaction name="separator"/>
<addaction name="_actionRun"/>
<addaction name="_actionRunLine"/>
<addaction name="separator"/>
<addaction name="_actionRevertToSaved"/>
<addaction name="_actionClear"/>
<addaction name="_actionRun"/>
<addaction name="separator"/>
<addaction name="_actionQuit"/>
</widget>
<widget class="QMenu" name="menuHelp">
@ -138,7 +140,7 @@
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="_domDock">
<property name="windowTitle">
<string>D&amp;OM Tree</string>
<string>DOM &amp;Tree</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
@ -393,144 +395,6 @@ this.dispatchEvent(evObj);</string>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="_scriptDock">
<property name="windowTitle">
<string>&amp;Test Script</string>
</property>
<attribute name="dockWidgetArea">
<number>4</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_12">
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="CodeEditor" name="_testscript"/>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<widget class="QPushButton" name="_record">
<property name="text">
<string>Record</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="_run">
<property name="text">
<string>Run</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="_screenshots">
<property name="text">
<string>Screenshots</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>28</height>
</size>
</property>
</spacer>
</item>
<item alignment="Qt::AlignHCenter">
<widget class="QStackedWidget" name="_status">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="page_6"/>
<widget class="QWidget" name="page_5">
<layout class="QGridLayout" name="gridLayout_9">
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="label_6">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;div style=&quot;font-size: xx-large&quot;&gt;⌛&lt;/div&gt;</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_3">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="label_4">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;div style=&quot;font-size: xx-large; color: green&quot;&gt;✔&lt;/div&gt;</string>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_4">
<layout class="QGridLayout" name="gridLayout_8">
<item row="0" column="0" alignment="Qt::AlignHCenter">
<widget class="QLabel" name="label_5">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>&lt;div style=&quot;font-size: xx-large; color: red&quot;&gt;✘&lt;/div&gt;</string>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QProgressBar" name="_progress">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="_logDock">
<property name="windowTitle">
<string>Scri&amp;pt Run Log</string>
@ -699,6 +563,9 @@ this.dispatchEvent(evObj);</string>
</property>
</action>
<action name="_actionSaveAs">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Save &amp;As ...</string>
</property>
@ -715,6 +582,9 @@ this.dispatchEvent(evObj);</string>
</property>
</action>
<action name="_actionRun">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>&amp;Run</string>
</property>
@ -728,6 +598,9 @@ this.dispatchEvent(evObj);</string>
</property>
</action>
<action name="_actionClear">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>&amp;Clear</string>
</property>
@ -986,38 +859,6 @@ this.dispatchEvent(evObj);</string>
</hint>
</hints>
</connection>
<connection>
<sender>_actionTestScript</sender>
<signal>triggered(bool)</signal>
<receiver>_scriptDock</receiver>
<slot>setVisible(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>-1</x>
<y>-1</y>
</hint>
<hint type="destinationlabel">
<x>443</x>
<y>155</y>
</hint>
</hints>
</connection>
<connection>
<sender>_scriptDock</sender>
<signal>visibilityChanged(bool)</signal>
<receiver>_actionTestScript</receiver>
<slot>setChecked(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>443</x>
<y>155</y>
</hint>
<hint type="destinationlabel">
<x>-1</x>
<y>-1</y>
</hint>
</hints>
</connection>
<connection>
<sender>_actionLog</sender>
<signal>triggered(bool)</signal>
@ -1066,22 +907,6 @@ this.dispatchEvent(evObj);</string>
</hint>
</hints>
</connection>
<connection>
<sender>_actionRun</sender>
<signal>triggered()</signal>
<receiver>_run</receiver>
<slot>click()</slot>
<hints>
<hint type="sourcelabel">
<x>-1</x>
<y>-1</y>
</hint>
<hint type="destinationlabel">
<x>299</x>
<y>90</y>
</hint>
</hints>
</connection>
<connection>
<sender>_url</sender>
<signal>activated(int)</signal>
@ -1098,21 +923,5 @@ this.dispatchEvent(evObj);</string>
</hint>
</hints>
</connection>
<connection>
<sender>_testscript</sender>
<signal>modificationChanged(bool)</signal>
<receiver>TestGUI</receiver>
<slot>setWindowModified(bool)</slot>
<hints>
<hint type="sourcelabel">
<x>126</x>
<y>144</y>
</hint>
<hint type="destinationlabel">
<x>443</x>
<y>589</y>
</hint>
</hints>
</connection>
</connections>
</ui>

@ -4,22 +4,22 @@
#include <version.hxx>
int main(int argc, char *argv[]) try {
QApplication a(argc, argv);
a.setApplicationDisplayName(a.tr("WebTester"));
a.setApplicationName(webtester::package_name().c_str());
a.setApplicationVersion(webtester::version().c_str());
QApplication app(argc, argv);
app.setApplicationDisplayName(app.tr("WebTester"));
app.setApplicationName(webtester::package_name().c_str());
app.setApplicationVersion(webtester::version().c_str());
QCommandLineParser parser;
parser.addHelpOption();
parser.addOption(QCommandLineOption
(QStringList()<<"u"<<"url",
"set initial URL to <url>", "url"));
parser.process(a);
parser.process(app);
QStringList scripts(parser.positionalArguments());
TestGUI w(0, parser.value("url"),
scripts.size()>1?scripts[0]:"",
scripts.size()>1?scripts[1]:scripts.size()?scripts[0]:"");
w.show();
return a.exec();
TestGUI win(0, parser.value("url"),
scripts.size()>1?scripts[0]:"",
scripts.size()>1?scripts[1]:scripts.size()?scripts[0]:"");
win.show();
return app.exec();
} catch (std::exception &x) {
std::cerr<<"**** error: "<<x.what()<<std::endl;
return 1;

Loading…
Cancel
Save