diff options
-rw-r--r-- | src/corelib/platform/wasm/qstdweb.cpp | 3 | ||||
-rw-r--r-- | src/corelib/platform/wasm/qstdweb_p.h | 8 | ||||
-rw-r--r-- | src/gui/platform/wasm/qwasmlocalfileaccess.cpp | 216 | ||||
-rw-r--r-- | src/gui/platform/wasm/qwasmlocalfileaccess_p.h | 12 | ||||
-rw-r--r-- | src/widgets/dialogs/qfiledialog.cpp | 10 | ||||
-rw-r--r-- | tests/manual/wasm/localfiles/main.cpp | 176 | ||||
-rw-r--r-- | tests/manual/wasm/qstdweb/CMakeLists.txt | 24 | ||||
-rw-r--r-- | tests/manual/wasm/qstdweb/files_auto.html | 13 | ||||
-rw-r--r-- | tests/manual/wasm/qstdweb/files_main.cpp | 471 | ||||
-rw-r--r-- | tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp | 12 |
10 files changed, 834 insertions, 111 deletions
diff --git a/src/corelib/platform/wasm/qstdweb.cpp b/src/corelib/platform/wasm/qstdweb.cpp index 43cd079e9d..486bfaf485 100644 --- a/src/corelib/platform/wasm/qstdweb.cpp +++ b/src/corelib/platform/wasm/qstdweb.cpp @@ -451,7 +451,8 @@ File FileList::operator[](int index) const return item(index); } -emscripten::val FileList::val() { +emscripten::val FileList::val() const +{ return m_fileList; } diff --git a/src/corelib/platform/wasm/qstdweb_p.h b/src/corelib/platform/wasm/qstdweb_p.h index b4b8948b3a..70f58cb85c 100644 --- a/src/corelib/platform/wasm/qstdweb_p.h +++ b/src/corelib/platform/wasm/qstdweb_p.h @@ -91,7 +91,7 @@ namespace qstdweb { int length() const; File item(int index) const; File operator[](int index) const; - emscripten::val val(); + emscripten::val val() const; private: emscripten::val m_fileList = emscripten::val::undefined(); @@ -183,6 +183,12 @@ namespace qstdweb { void all(std::vector<emscripten::val> promises, PromiseCallbacks callbacks); }; + + inline emscripten::val window() + { + static emscripten::val savedWindow = emscripten::val::global("window"); + return savedWindow; + } } QT_END_NAMESPACE diff --git a/src/gui/platform/wasm/qwasmlocalfileaccess.cpp b/src/gui/platform/wasm/qwasmlocalfileaccess.cpp index 172c8f6814..1b797be9fe 100644 --- a/src/gui/platform/wasm/qwasmlocalfileaccess.cpp +++ b/src/gui/platform/wasm/qwasmlocalfileaccess.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only #include "qwasmlocalfileaccess_p.h" +#include "qlocalfileapi_p.h" #include <private/qstdweb_p.h> #include <emscripten.h> #include <emscripten/bind.h> @@ -11,10 +12,98 @@ QT_BEGIN_NAMESPACE namespace QWasmLocalFileAccess { +namespace FileDialog { +namespace { +bool hasLocalFilesApi() +{ + return !qstdweb::window()["showOpenFilePicker"].isUndefined(); +} + +void showOpenViaHTMLPolyfill(const QStringList &accept, FileSelectMode fileSelectMode, + qstdweb::PromiseCallbacks onFilesSelected) +{ + // Create file input html element which will display a native file dialog + // and call back to our onchange handler once the user has selected + // one or more files. + emscripten::val document = emscripten::val::global("document"); + emscripten::val input = document.call<emscripten::val>("createElement", std::string("input")); + input.set("type", "file"); + input.set("style", "display:none"); + // input.set("accept", emscripten::val(accept)); + Q_UNUSED(accept); + input.set("multiple", emscripten::val(fileSelectMode == FileSelectMode::MultipleFiles)); + + // Note: there is no event in case the user cancels the file dialog. + static std::unique_ptr<qstdweb::EventCallback> changeEvent; + auto callback = [=](emscripten::val) { onFilesSelected.thenFunc(input["files"]); }; + changeEvent = std::make_unique<qstdweb::EventCallback>(input, "change", callback); + + // Activate file input + emscripten::val body = document["body"]; + body.call<void>("appendChild", input); + input.call<void>("click"); + body.call<void>("removeChild", input); +} + +void showOpenViaLocalFileApi(const QStringList &accept, FileSelectMode fileSelectMode, + qstdweb::PromiseCallbacks callbacks) +{ + using namespace qstdweb; + + auto options = LocalFileApi::makeOpenFileOptions(accept, fileSelectMode == FileSelectMode::MultipleFiles); + + Promise::make( + window(), QStringLiteral("showOpenFilePicker"), + { + .thenFunc = [=](emscripten::val fileHandles) mutable { + std::vector<emscripten::val> filePromises; + filePromises.reserve(fileHandles["length"].as<int>()); + for (int i = 0; i < fileHandles["length"].as<int>(); ++i) + filePromises.push_back(fileHandles[i].call<emscripten::val>("getFile")); + Promise::all(std::move(filePromises), callbacks); + }, + .catchFunc = callbacks.catchFunc, + .finallyFunc = callbacks.finallyFunc, + }, std::move(options)); +} + +void showSaveViaLocalFileApi(const std::string &fileNameHint, qstdweb::PromiseCallbacks callbacks) +{ + using namespace qstdweb; + using namespace emscripten; + + auto options = LocalFileApi::makeSaveFileOptions(QStringList(), fileNameHint); + + Promise::make( + window(), QStringLiteral("showSaveFilePicker"), + std::move(callbacks), std::move(options)); +} +} // namespace + +void showOpen(const QStringList &accept, FileSelectMode fileSelectMode, + qstdweb::PromiseCallbacks callbacks) +{ + hasLocalFilesApi() ? + showOpenViaLocalFileApi(accept, fileSelectMode, std::move(callbacks)) : + showOpenViaHTMLPolyfill(accept, fileSelectMode, std::move(callbacks)); +} + +bool canShowSave() +{ + return hasLocalFilesApi(); +} + +void showSave(const std::string &fileNameHint, qstdweb::PromiseCallbacks callbacks) +{ + Q_ASSERT(canShowSave()); + showSaveViaLocalFileApi(fileNameHint, std::move(callbacks)); +} +} // namespace FileDialog +namespace { void readFiles(const qstdweb::FileList &fileList, - const std::function<char *(uint64_t size, const std::string name)> &acceptFile, - const std::function<void ()> &fileDataReady) + const std::function<char *(uint64_t size, const std::string name)> &acceptFile, + const std::function<void ()> &fileDataReady) { auto readFile = std::make_shared<std::function<void(int)>>(); @@ -25,7 +114,7 @@ void readFiles(const qstdweb::FileList &fileList, return; } - const qstdweb::File file = fileList[fileIndex]; + const qstdweb::File file = qstdweb::File(fileList[fileIndex]); // Ask caller if the file should be accepted char *buffer = acceptFile(file.size(), file.name()); @@ -43,74 +132,103 @@ void readFiles(const qstdweb::FileList &fileList, (*readFile)(0); } +} -typedef std::function<void (const qstdweb::FileList &fileList)> OpenFileDialogCallback; -void openFileDialog(const std::string &accept, FileSelectMode fileSelectMode, - const OpenFileDialogCallback &filesSelected) +void downloadDataAsFile(const QByteArray &data, const std::string &fileNameHint) { - // Create file input html element which will display a native file dialog - // and call back to our onchange handler once the user has selected - // one or more files. + // Save a file by creating programmatically clicking a download + // link to an object url to a Blob containing a copy of the file + // content. The copy is made so that the passed in content buffer + // can be released as soon as this function returns. + qstdweb::Blob contentBlob = qstdweb::Blob::copyFrom(data.constData(), data.size()); emscripten::val document = emscripten::val::global("document"); - emscripten::val input = document.call<emscripten::val>("createElement", std::string("input")); - input.set("type", "file"); - input.set("style", "display:none"); - input.set("accept", emscripten::val(accept)); - input.set("multiple", emscripten::val(fileSelectMode == MultipleFiles)); - - // Note: there is no event in case the user cancels the file dialog. - static std::unique_ptr<qstdweb::EventCallback> changeEvent; - auto callback = [=](emscripten::val) { filesSelected(qstdweb::FileList(input["files"])); }; - changeEvent.reset(new qstdweb::EventCallback(input, "change", callback)); + emscripten::val window = qstdweb::window(); + emscripten::val contentUrl = window["URL"].call<emscripten::val>("createObjectURL", contentBlob.val()); + emscripten::val contentLink = document.call<emscripten::val>("createElement", std::string("a")); + contentLink.set("href", contentUrl); + contentLink.set("download", fileNameHint); + contentLink.set("style", "display:none"); - // Activate file input emscripten::val body = document["body"]; - body.call<void>("appendChild", input); - input.call<void>("click"); - body.call<void>("removeChild", input); + body.call<void>("appendChild", contentLink); + contentLink.call<void>("click"); + body.call<void>("removeChild", contentLink); + + window["URL"].call<emscripten::val>("revokeObjectURL", contentUrl); } -void openFiles(const std::string &accept, FileSelectMode fileSelectMode, +void openFiles(const QStringList &accept, FileSelectMode fileSelectMode, const std::function<void (int fileCount)> &fileDialogClosed, - const std::function<char *(uint64_t size, const std::string name)> &acceptFile, + const std::function<char *(uint64_t size, const std::string& name)> &acceptFile, const std::function<void()> &fileDataReady) { - openFileDialog(accept, fileSelectMode, [=](const qstdweb::FileList &files) { - fileDialogClosed(files.length()); - readFiles(files, acceptFile, fileDataReady); + FileDialog::showOpen(accept, fileSelectMode, { + .thenFunc = [=](emscripten::val result) { + auto files = qstdweb::FileList(result); + fileDialogClosed(files.length()); + readFiles(files, acceptFile, fileDataReady); + }, + .catchFunc = [=](emscripten::val) { + fileDialogClosed(0); + } }); } -void openFile(const std::string &accept, +void openFile(const QStringList &accept, const std::function<void (bool fileSelected)> &fileDialogClosed, - const std::function<char *(uint64_t size, const std::string name)> &acceptFile, + const std::function<char *(uint64_t size, const std::string& name)> &acceptFile, const std::function<void()> &fileDataReady) { auto fileDialogClosedWithInt = [=](int fileCount) { fileDialogClosed(fileCount != 0); }; openFiles(accept, FileSelectMode::SingleFile, fileDialogClosedWithInt, acceptFile, fileDataReady); } -void saveFile(const char *content, size_t size, const std::string &fileNameHint) +void saveDataToFileInChunks(emscripten::val fileHandle, const QByteArray &data) { - // Save a file by creating programmatically clicking a download - // link to an object url to a Blob containing a copy of the file - // content. The copy is made so that the passed in content buffer - // can be released as soon as this function returns. - qstdweb::Blob contentBlob = qstdweb::Blob::copyFrom(content, size); - emscripten::val document = emscripten::val::global("document"); - emscripten::val window = emscripten::val::global("window"); - emscripten::val contentUrl = window["URL"].call<emscripten::val>("createObjectURL", contentBlob.val()); - emscripten::val contentLink = document.call<emscripten::val>("createElement", std::string("a")); - contentLink.set("href", contentUrl); - contentLink.set("download", fileNameHint); - contentLink.set("style", "display:none"); - - emscripten::val body = document["body"]; - body.call<void>("appendChild", contentLink); - contentLink.call<void>("click"); - body.call<void>("removeChild", contentLink); + using namespace emscripten; + using namespace qstdweb; + + Promise::make(fileHandle, QStringLiteral("createWritable"), { + .thenFunc = [=](val writable) { + struct State { + size_t written; + std::function<void(val result)> continuation; + }; + + auto state = std::make_shared<State>(); + state->written = 0u; + state->continuation = [=](val) mutable { + const size_t remaining = data.size() - state->written; + if (remaining == 0) { + Promise::make(writable, QStringLiteral("close"), { .thenFunc = [=](val) {} }); + state.reset(); + return; + } + static constexpr size_t desiredChunkSize = 1024u; + const auto currentChunkSize = std::min(remaining, desiredChunkSize); + Promise::make(writable, QStringLiteral("write"), { + .thenFunc = state->continuation, + }, val(typed_memory_view(currentChunkSize, data.constData() + state->written))); + state->written += currentChunkSize; + }; + + state->continuation(val::undefined()); + }, + }); +} - window["URL"].call<emscripten::val>("revokeObjectURL", contentUrl); +void saveFile(const QByteArray &data, const std::string &fileNameHint) +{ + if (!FileDialog::canShowSave()) { + downloadDataAsFile(data, fileNameHint); + return; + } + + FileDialog::showSave(fileNameHint, { + .thenFunc = [=](emscripten::val result) { + saveDataToFileInChunks(result, data); + }, + }); } } // namespace QWasmLocalFileAccess diff --git a/src/gui/platform/wasm/qwasmlocalfileaccess_p.h b/src/gui/platform/wasm/qwasmlocalfileaccess_p.h index eb73463759..040fe3c47a 100644 --- a/src/gui/platform/wasm/qwasmlocalfileaccess_p.h +++ b/src/gui/platform/wasm/qwasmlocalfileaccess_p.h @@ -23,19 +23,19 @@ QT_BEGIN_NAMESPACE namespace QWasmLocalFileAccess { -enum FileSelectMode { SingleFile, MultipleFiles }; +enum class FileSelectMode { SingleFile, MultipleFiles }; -Q_CORE_EXPORT void openFiles(const std::string &accept, FileSelectMode fileSelectMode, +Q_CORE_EXPORT void openFiles(const QStringList &accept, FileSelectMode fileSelectMode, const std::function<void (int fileCount)> &fileDialogClosed, - const std::function<char *(uint64_t size, const std::string name)> &acceptFile, + const std::function<char *(uint64_t size, const std::string& name)> &acceptFile, const std::function<void()> &fileDataReady); -Q_CORE_EXPORT void openFile(const std::string &accept, +Q_CORE_EXPORT void openFile(const QStringList &accept, const std::function<void (bool fileSelected)> &fileDialogClosed, - const std::function<char *(uint64_t size, const std::string name)> &acceptFile, + const std::function<char *(uint64_t size, const std::string& name)> &acceptFile, const std::function<void()> &fileDataReady); -Q_CORE_EXPORT void saveFile(const char *content, size_t size, const std::string &fileNameHint); +Q_CORE_EXPORT void saveFile(const QByteArray &data, const std::string &fileNameHint); } // namespace QWasmLocalFileAccess diff --git a/src/widgets/dialogs/qfiledialog.cpp b/src/widgets/dialogs/qfiledialog.cpp index 966d86b089..885d56c5bb 100644 --- a/src/widgets/dialogs/qfiledialog.cpp +++ b/src/widgets/dialogs/qfiledialog.cpp @@ -2304,13 +2304,7 @@ void QFileDialog::getOpenFileContent(const QString &nameFilter, const std::funct openFileImpl.reset(); }; - auto qtFilterStringToWebAcceptString = [](const QString &qtString) { - // The Qt and Web name filter string formats are similar, but - // not identical. - return qtString.toStdString(); // ### TODO - }; - - QWasmLocalFileAccess::openFile(qtFilterStringToWebAcceptString(nameFilter), fileDialogClosed, acceptFile, fileContentReady); + QWasmLocalFileAccess::openFile(qt_make_filter_list(nameFilter), fileDialogClosed, acceptFile, fileContentReady); }; (*openFileImpl)(); @@ -2359,7 +2353,7 @@ void QFileDialog::getOpenFileContent(const QString &nameFilter, const std::funct void QFileDialog::saveFileContent(const QByteArray &fileContent, const QString &fileNameHint) { #ifdef Q_OS_WASM - QWasmLocalFileAccess::saveFile(fileContent.constData(), fileContent.size(), fileNameHint.toStdString()); + QWasmLocalFileAccess::saveFile(fileContent, fileNameHint.toStdString()); #else QFileDialog *dialog = new QFileDialog(); dialog->setAcceptMode(QFileDialog::AcceptSave); diff --git a/tests/manual/wasm/localfiles/main.cpp b/tests/manual/wasm/localfiles/main.cpp index 46e2b058c6..39d9f21901 100644 --- a/tests/manual/wasm/localfiles/main.cpp +++ b/tests/manual/wasm/localfiles/main.cpp @@ -2,52 +2,136 @@ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause #include <QtWidgets/QtWidgets> +#include <emscripten/val.h> +#include <emscripten.h> + +class AppWindow : public QObject +{ +Q_OBJECT +public: + AppWindow() : m_layout(new QVBoxLayout(&m_loadFileUi)), + m_window(emscripten::val::global("window")), + m_showOpenFilePickerFunction(m_window["showOpenFilePicker"]), + m_showSaveFilePickerFunction(m_window["showSaveFilePicker"]) + { + addWidget<QLabel>("Filename filter"); + + const bool localFileApiAvailable = + !m_showOpenFilePickerFunction.isUndefined() && !m_showSaveFilePickerFunction.isUndefined(); + + m_useLocalFileApisCheckbox = addWidget<QCheckBox>("Use the window.showXFilePicker APIs"); + m_useLocalFileApisCheckbox->setEnabled(localFileApiAvailable); + m_useLocalFileApisCheckbox->setChecked(localFileApiAvailable); + + m_filterEdit = addWidget<QLineEdit>("Images (*.png *.jpg);;PDF (*.pdf);;*.txt"); + + auto* loadFile = addWidget<QPushButton>("Load File"); + + m_fileInfo = addWidget<QLabel>("Opened file:"); + m_fileInfo->setTextInteractionFlags(Qt::TextSelectableByMouse); + + m_fileHash = addWidget<QLabel>("Sha256:"); + m_fileHash->setTextInteractionFlags(Qt::TextSelectableByMouse); + + addWidget<QLabel>("Saved file name"); + m_savedFileNameEdit = addWidget<QLineEdit>("qttestresult"); + + m_saveFile = addWidget<QPushButton>("Save File"); + m_saveFile->setEnabled(false); + + m_layout->addStretch(); + + m_loadFileUi.setLayout(m_layout); + + QObject::connect(m_useLocalFileApisCheckbox, &QCheckBox::toggled, std::bind(&AppWindow::onUseLocalFileApisCheckboxToggled, this)); + + QObject::connect(loadFile, &QPushButton::clicked, this, &AppWindow::onLoadClicked); + + QObject::connect(m_saveFile, &QPushButton::clicked, std::bind(&AppWindow::onSaveClicked, this)); + } + + void show() { + m_loadFileUi.show(); + } + + ~AppWindow() = default; + +private Q_SLOTS: + void onUseLocalFileApisCheckboxToggled() + { + m_window.set("showOpenFilePicker", + m_useLocalFileApisCheckbox->isChecked() ? + m_showOpenFilePickerFunction : emscripten::val::undefined()); + m_window.set("showSaveFilePicker", + m_useLocalFileApisCheckbox->isChecked() ? + m_showSaveFilePickerFunction : emscripten::val::undefined()); + } + + void onFileContentReady(const QString &fileName, const QByteArray &fileContents) + { + m_fileContent = fileContents; + m_fileInfo->setText(QString("Opened file: %1 size: %2").arg(fileName).arg(fileContents.size())); + m_saveFile->setEnabled(true); + + QTimer::singleShot(100, this, &AppWindow::computeAndDisplayFileHash); // update UI before computing hash + } + + void computeAndDisplayFileHash() + { + QByteArray hash = QCryptographicHash::hash(m_fileContent, QCryptographicHash::Sha256); + m_fileHash->setText(QString("Sha256: %1").arg(QString(hash.toHex()))); + } + + void onFileSaved(bool success) + { + m_fileInfo->setText(QString("File save result: %1").arg(success ? "success" : "failed")); + } + + void onLoadClicked() + { + QFileDialog::getOpenFileContent( + m_filterEdit->text(), + std::bind(&AppWindow::onFileContentReady, this, std::placeholders::_1, std::placeholders::_2)); + } + + void onSaveClicked() + { + m_fileInfo->setText("Saving file... (no result information with current API)"); + QFileDialog::saveFileContent(m_fileContent, m_savedFileNameEdit->text()); + } + +private: + template <class T, class... Args> + T* addWidget(Args... args) + { + T* widget = new T(std::forward<Args>(args)..., &m_loadFileUi); + m_layout->addWidget(widget); + return widget; + } + + QWidget m_loadFileUi; + + QCheckBox* m_useLocalFileApisCheckbox; + QLineEdit* m_filterEdit; + QVBoxLayout *m_layout; + QLabel* m_fileInfo; + QLabel* m_fileHash; + QLineEdit* m_savedFileNameEdit; + QPushButton* m_saveFile; + + emscripten::val m_window; + emscripten::val m_showOpenFilePickerFunction; + emscripten::val m_showSaveFilePickerFunction; + + QByteArray m_fileContent; +}; + int main(int argc, char **argv) { - QApplication app(argc, argv); - - QByteArray content; - - QWidget loadFileUi; - QVBoxLayout *layout = new QVBoxLayout(); - QPushButton *loadFile = new QPushButton("Load File"); - QLabel *fileInfo = new QLabel("Opened file:"); - fileInfo->setTextInteractionFlags(Qt::TextSelectableByMouse); - QLabel *fileHash = new QLabel("Sha256:"); - fileHash->setTextInteractionFlags(Qt::TextSelectableByMouse); - QPushButton *saveFile = new QPushButton("Save File"); - saveFile->setEnabled(false); - - auto onFileReady = [=, &content](const QString &fileName, const QByteArray &fileContents) { - content = fileContents; - fileInfo->setText(QString("Opened file: %1 size: %2").arg(fileName).arg(fileContents.size())); - saveFile->setEnabled(true); - - auto computeDisplayFileHash = [=](){ - QByteArray hash = QCryptographicHash::hash(fileContents, QCryptographicHash::Sha256); - fileHash->setText(QString("Sha256: %1").arg(QString(hash.toHex()))); - }; - - QTimer::singleShot(100, computeDisplayFileHash); // update UI before computing hash - }; - auto onLoadClicked = [=](){ - QFileDialog::getOpenFileContent("*.*", onFileReady); - }; - QObject::connect(loadFile, &QPushButton::clicked, onLoadClicked); - - auto onSaveClicked = [=, &content]() { - QFileDialog::saveFileContent(content, "qtsavefiletest.dat"); - }; - QObject::connect(saveFile, &QPushButton::clicked, onSaveClicked); - - layout->addWidget(loadFile); - layout->addWidget(fileInfo); - layout->addWidget(fileHash); - layout->addWidget(saveFile); - layout->addStretch(); - - loadFileUi.setLayout(layout); - loadFileUi.show(); - - return app.exec(); + QApplication application(argc, argv); + AppWindow window; + window.show(); + return application.exec(); } + +#include "main.moc" diff --git a/tests/manual/wasm/qstdweb/CMakeLists.txt b/tests/manual/wasm/qstdweb/CMakeLists.txt index ea034fad67..093fe54663 100644 --- a/tests/manual/wasm/qstdweb/CMakeLists.txt +++ b/tests/manual/wasm/qstdweb/CMakeLists.txt @@ -20,3 +20,27 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/../qtwasmtestlib/qtwasmtestlib.js ${CMAKE_CURRENT_BINARY_DIR}/qtwasmtestlib.js) + +qt_internal_add_manual_test(files_auto + SOURCES + files_main.cpp + ../qtwasmtestlib/qtwasmtestlib.cpp + PUBLIC_LIBRARIES + Qt::Core + Qt::CorePrivate + Qt::GuiPrivate +) + +include_directories(../qtwasmtestlib/) + +add_custom_command( + TARGET files_auto POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_CURRENT_SOURCE_DIR}/files_auto.html + ${CMAKE_CURRENT_BINARY_DIR}/files_auto.html) + +add_custom_command( + TARGET files_auto POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + ${CMAKE_CURRENT_SOURCE_DIR}/../qtwasmtestlib/qtwasmtestlib.js + ${CMAKE_CURRENT_BINARY_DIR}/qtwasmtestlib.js) diff --git a/tests/manual/wasm/qstdweb/files_auto.html b/tests/manual/wasm/qstdweb/files_auto.html new file mode 100644 index 0000000000..71e8088dfb --- /dev/null +++ b/tests/manual/wasm/qstdweb/files_auto.html @@ -0,0 +1,13 @@ +<!doctype html> +<script type="text/javascript" src="https://sinonjs.org/releases/sinon-14.0.0.js" + integrity="sha384-z8J4N1s2hPDn6ClmFXDQkKD/e738VOWcR8JmhztPRa+PgezxQupgZu3LzoBO4Jw8" + crossorigin="anonymous"></script> +<script type="text/javascript" src="qtwasmtestlib.js"></script> +<script type="text/javascript" src="files_auto.js"></script> +<script> + window.onload = () => { + runTestCase(document.getElementById("log")); + }; +</script> +<p>Running files auto test.</p> +<div id="log"></div> diff --git a/tests/manual/wasm/qstdweb/files_main.cpp b/tests/manual/wasm/qstdweb/files_main.cpp new file mode 100644 index 0000000000..4dfae7e13b --- /dev/null +++ b/tests/manual/wasm/qstdweb/files_main.cpp @@ -0,0 +1,471 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#include <QtCore/QCoreApplication> +#include <QtCore/QEvent> +#include <QtCore/QMutex> +#include <QtCore/QObject> +#include <QtCore/QTimer> +#include <QtGui/private/qwasmlocalfileaccess_p.h> + +#include <qtwasmtestlib.h> +#include <emscripten.h> +#include <emscripten/bind.h> +#include <emscripten/val.h> + +#include <string_view> + +using namespace emscripten; + +class FilesTest : public QObject +{ + Q_OBJECT + +public: + FilesTest() : m_window(val::global("window")), m_testSupport(val::object()) {} + + ~FilesTest() noexcept { + for (auto& cleanup: m_cleanup) { + cleanup(); + } + } + +private: + void init() { + EM_ASM({ + window.testSupport = {}; + + window.showOpenFilePicker = sinon.stub(); + + window.mockOpenFileDialog = (files) => { + window.showOpenFilePicker.withArgs(sinon.match.any).callsFake( + (options) => Promise.resolve(files.map(file => { + const getFile = sinon.stub(); + getFile.callsFake(() => Promise.resolve({ + name: file.name, + size: file.content.length, + slice: () => new Blob([new TextEncoder().encode(file.content)]), + })); + return { + kind: 'file', + name: file.name, + getFile + }; + })) + ); + }; + + window.showSaveFilePicker = sinon.stub(); + + window.mockSaveFilePicker = (file) => { + window.showSaveFilePicker.withArgs(sinon.match.any).callsFake( + (options) => { + const createWritable = sinon.stub(); + createWritable.callsFake(() => { + const write = file.writeFn ?? (() => { + const write = sinon.stub(); + write.callsFake((stuff) => { + if (file.content !== new TextDecoder().decode(stuff)) { + const message = `Bad file content ${file.content} !== ${new TextDecoder().decode(stuff)}`; + Module.qtWasmFail(message); + return Promise.reject(message); + } + + return Promise.resolve(); + }); + return write; + })(); + + window.testSupport.write = write; + + const close = file.closeFn ?? (() => { + const close = sinon.stub(); + close.callsFake(() => Promise.resolve()); + return close; + })(); + + window.testSupport.close = close; + + return Promise.resolve({ + write, + close + }); + }); + return Promise.resolve({ + kind: 'file', + name: file.name, + createWritable + }); + } + ); + }; + }); + } + + template <class T> + T* Own(T* plainPtr) { + m_cleanup.emplace_back([plainPtr]() mutable { + delete plainPtr; + }); + return plainPtr; + } + + val m_window; + val m_testSupport; + + std::vector<std::function<void()>> m_cleanup; + +private slots: + void selectOneFileWithFileDialog(); + void selectMultipleFilesWithFileDialog(); + void cancelFileDialog(); + void rejectFile(); + void saveFileWithFileDialog(); +}; + +class BarrierCallback { +public: + BarrierCallback(int number, std::function<void()> onDone) + : m_remaining(number), m_onDone(std::move(onDone)) {} + + void operator()() { + if (!--m_remaining) { + m_onDone(); + } + } + +private: + int m_remaining; + std::function<void()> m_onDone; +}; + + +template <class Arg> +std::string argToString(std::add_lvalue_reference_t<std::add_const_t<Arg>> arg) { + return std::to_string(arg); +} + +template <> +std::string argToString<bool>(const bool& value) { + return value ? "true" : "false"; +} + +template <> +std::string argToString<std::string>(const std::string& arg) { + return arg; +} + +template <> +std::string argToString<const std::string&>(const std::string& arg) { + return arg; +} + +template<class Type> +struct Matcher { + virtual ~Matcher() = default; + + virtual bool matches(std::string* explanation, const Type& actual) const = 0; +}; + +template<class Type> +struct AnyMatcher : public Matcher<Type> { + bool matches(std::string* explanation, const Type& actual) const final { + Q_UNUSED(explanation); + Q_UNUSED(actual); + return true; + } + + Type m_value; +}; + +template<class Type> +struct EqualsMatcher : public Matcher<Type> { + EqualsMatcher(Type value) : m_value(std::forward<Type>(value)) {} + + bool matches(std::string* explanation, const Type& actual) const final { + const bool ret = actual == m_value; + if (!ret) + *explanation += argToString<Type>(actual) + " != " + argToString<Type>(m_value); + return actual == m_value; + } + + // It is crucial to hold a copy, otherwise we lose const refs. + std::remove_reference_t<Type> m_value; +}; + +template<class Type> +std::unique_ptr<EqualsMatcher<Type>> equals(Type value) { + return std::make_unique<EqualsMatcher<Type>>(value); +} + +template<class Type> +std::unique_ptr<AnyMatcher<Type>> any(Type value) { + return std::make_unique<AnyMatcher<Type>>(value); +} + +template <class ...Types> +struct Expectation { + std::tuple<std::unique_ptr<Matcher<Types>>...> m_argMatchers; + int m_callCount = 0; + int m_expectedCalls = 1; + + template<std::size_t... Indices> + bool match(std::string* explanation, const std::tuple<Types...>& tuple, std::index_sequence<Indices...>) const { + return ( ... && (std::get<Indices>(m_argMatchers)->matches(explanation, std::get<Indices>(tuple)))); + } + + bool matches(std::string* explanation, Types... args) const { + if (m_callCount >= m_expectedCalls) { + *explanation += "Too many calls\n"; + return false; + } + return match(explanation, std::make_tuple(args...), std::make_index_sequence<std::tuple_size_v<std::tuple<Types...>>>()); + } +}; + +template <class R, class ...Types> +struct Behavior { + std::function<R(Types...)> m_callback; + + void call(std::function<R(Types...)> callback) { + m_callback = std::move(callback); + } +}; + +template<class... Args> +std::string argsToString(Args... args) { + return (... + (", " + argToString<Args>(args))); +} + +template<> +std::string argsToString<>() { + return ""; +} + +template<class R, class ...Types> +struct ExpectationToBehaviorMapping { + Expectation<Types...> expectation; + Behavior<R, Types...> behavior; +}; + +template<class R, class... Args> +class MockCallback { +public: + std::function<R(Args...)> get() { + return [this](Args... result) -> R { + return processCall(std::forward<Args>(result)...); + }; + } + + Behavior<R, Args...>& expectCallWith(std::unique_ptr<Matcher<Args>>... matcherArgs) { + auto matchers = std::make_tuple(std::move(matcherArgs)...); + m_behaviorByExpectation.push_back({Expectation<Args...> {std::move(matchers)}, Behavior<R, Args...> {}}); + return m_behaviorByExpectation.back().behavior; + } + + Behavior<R, Args...>& expectRepeatedCallWith(int times, std::unique_ptr<Matcher<Args>>... matcherArgs) { + auto matchers = std::make_tuple(std::move(matcherArgs)...); + m_behaviorByExpectation.push_back({Expectation<Args...> {std::move(matchers), 0, times}, Behavior<R, Args...> {}}); + return m_behaviorByExpectation.back().behavior; + } + +private: + R processCall(Args... args) { + std::string argsAsString = argsToString(args...); + std::string triedExpectations; + auto it = std::find_if(m_behaviorByExpectation.begin(), m_behaviorByExpectation.end(), + [&](const ExpectationToBehaviorMapping<R, Args...>& behavior) { + return behavior.expectation.matches(&triedExpectations, std::forward<Args>(args)...); + }); + if (it != m_behaviorByExpectation.end()) { + ++it->expectation.m_callCount; + return it->behavior.m_callback(args...); + } else { + QWASMFAIL("Unexpected call with " + argsAsString + ". Tried: " + triedExpectations); + } + return R(); + } + + std::vector<ExpectationToBehaviorMapping<R, Args...>> m_behaviorByExpectation; +}; + +void FilesTest::selectOneFileWithFileDialog() +{ + init(); + + static constexpr std::string_view testFileContent = "This is a happy case."; + + EM_ASM({ + mockOpenFileDialog([{ + name: 'file1.jpg', + content: UTF8ToString($0) + }]); + }, testFileContent.data()); + + auto* fileSelectedCallback = Own(new MockCallback<void, bool>()); + fileSelectedCallback->expectCallWith(equals(true)).call([](bool) mutable {}); + + auto* fileBuffer = Own(new QByteArray()); + + auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>()); + acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent.size()), equals<const std::string&>("file1.jpg")) + .call([fileBuffer](uint64_t, std::string) mutable -> char* { + fileBuffer->resize(testFileContent.size()); + return fileBuffer->data(); + }); + + auto* fileDataReadyCallback = Own(new MockCallback<void>()); + fileDataReadyCallback->expectCallWith().call([fileBuffer]() mutable { + QWASMCOMPARE(fileBuffer->data(), std::string(testFileContent)); + QWASMSUCCESS(); + }); + + QWasmLocalFileAccess::openFile( + {QStringLiteral("*")}, fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get()); +} + +void FilesTest::selectMultipleFilesWithFileDialog() +{ + static constexpr std::array<std::string_view, 3> testFileContent = + { "Cont 1", "2s content", "What is hiding in 3?"}; + + init(); + + EM_ASM({ + mockOpenFileDialog([{ + name: 'file1.jpg', + content: UTF8ToString($0) + }, { + name: 'file2.jpg', + content: UTF8ToString($1) + }, { + name: 'file3.jpg', + content: UTF8ToString($2) + }]); + }, testFileContent[0].data(), testFileContent[1].data(), testFileContent[2].data()); + + auto* fileSelectedCallback = Own(new MockCallback<void, int>()); + fileSelectedCallback->expectCallWith(equals(3)).call([](int) mutable {}); + + auto fileBuffer = std::make_shared<QByteArray>(); + + auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>()); + acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent[0].size()), equals<const std::string&>("file1.jpg")) + .call([fileBuffer](uint64_t, std::string) mutable -> char* { + fileBuffer->resize(testFileContent[0].size()); + return fileBuffer->data(); + }); + acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent[1].size()), equals<const std::string&>("file2.jpg")) + .call([fileBuffer](uint64_t, std::string) mutable -> char* { + fileBuffer->resize(testFileContent[1].size()); + return fileBuffer->data(); + }); + acceptFileCallback->expectCallWith(equals<uint64_t>(testFileContent[2].size()), equals<const std::string&>("file3.jpg")) + .call([fileBuffer](uint64_t, std::string) mutable -> char* { + fileBuffer->resize(testFileContent[2].size()); + return fileBuffer->data(); + }); + + auto* fileDataReadyCallback = Own(new MockCallback<void>()); + fileDataReadyCallback->expectRepeatedCallWith(3).call([fileBuffer]() mutable { + static int callCount = 0; + QWASMCOMPARE(fileBuffer->data(), std::string(testFileContent[callCount])); + + callCount++; + if (callCount == 3) { + QWASMSUCCESS(); + } + }); + + QWasmLocalFileAccess::openFiles( + {QStringLiteral("*")}, QWasmLocalFileAccess::FileSelectMode::MultipleFiles, + fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get()); +} + +void FilesTest::cancelFileDialog() +{ + init(); + + EM_ASM({ + window.showOpenFilePicker.withArgs(sinon.match.any).returns(Promise.reject("The user cancelled the dialog")); + }); + + auto* fileSelectedCallback = Own(new MockCallback<void, bool>()); + fileSelectedCallback->expectCallWith(equals(false)).call([](bool) mutable { + QWASMSUCCESS(); + }); + + auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>()); + auto* fileDataReadyCallback = Own(new MockCallback<void>()); + + QWasmLocalFileAccess::openFile( + {QStringLiteral("*")}, fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get()); +} + +void FilesTest::rejectFile() +{ + init(); + + static constexpr std::string_view testFileContent = "We don't want this file."; + + EM_ASM({ + mockOpenFileDialog([{ + name: 'dontwant.dat', + content: UTF8ToString($0) + }]); + }, testFileContent.data()); + + auto* fileSelectedCallback = Own(new MockCallback<void, bool>()); + fileSelectedCallback->expectCallWith(equals(true)).call([](bool) mutable {}); + + auto* fileDataReadyCallback = Own(new MockCallback<void>()); + + auto* acceptFileCallback = Own(new MockCallback<char*, uint64_t, const std::string&>()); + acceptFileCallback->expectCallWith(equals<uint64_t>(std::string_view(testFileContent).size()), equals<const std::string&>("dontwant.dat")) + .call([](uint64_t, const std::string) { + QTimer::singleShot(0, []() { + // No calls to fileDataReadyCallback + QWASMSUCCESS(); + }); + return nullptr; + }); + + QWasmLocalFileAccess::openFile( + {QStringLiteral("*")}, fileSelectedCallback->get(), acceptFileCallback->get(), fileDataReadyCallback->get()); +} + +void FilesTest::saveFileWithFileDialog() +{ + init(); + + static constexpr std::string_view testFileContent = "Save this important content"; + + EM_ASM({ + mockSaveFilePicker({ + name: 'somename', + content: UTF8ToString($0), + closeFn: (() => { + const close = sinon.stub(); + close.callsFake(() => + new Promise(resolve => { + resolve(); + Module.qtWasmPass(); + })); + return close; + })() + }); + }, testFileContent.data()); + + QByteArray data; + data.prepend(testFileContent); + QWasmLocalFileAccess::saveFile(data, "hintie"); +} + +int main(int argc, char **argv) +{ + auto testObject = std::make_shared<FilesTest>(); + QtWasmTest::initTestCase<QCoreApplication>(argc, argv, testObject); + return 0; +} + +#include "files_main.moc" diff --git a/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp index c70c390249..7d1db44cb0 100644 --- a/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp +++ b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp @@ -101,10 +101,22 @@ void runTestFunction(std::string name) QMetaObject::invokeMethod(g_testObject, name.c_str()); } +void failTest(std::string message) +{ + completeTestFunction(QtWasmTest::Fail, std::move(message)); +} + +void passTest() +{ + completeTestFunction(QtWasmTest::Pass, ""); +} + EMSCRIPTEN_BINDINGS(qtwebtestrunner) { emscripten::function("cleanupTestCase", &cleanupTestCase); emscripten::function("getTestFunctions", &getTestFunctions); emscripten::function("runTestFunction", &runTestFunction); + emscripten::function("qtWasmFail", &failTest); + emscripten::function("qtWasmPass", &passTest); } // |