// SPDX-License-Identifier: GPL-3.0-or-later

#include "test_utils.h"
#include "tests.h"
#include "tests_common.h"

#include "common/mimetypes.h"
#include "common/commandstatus.h"

#include <QElapsedTimer>
#include <QRegularExpression>
#include <QTemporaryFile>
#include <algorithm>

void CoreTests::commandExit()
{
    RUN("exit", "");

    TEST( m_test->waitForServerToStop() );

    QCOMPARE( run(Args("exit")), 1 );
}

void CoreTests::commandEval()
{
    RUN("eval" << "", "");
    RUN("eval" << "1", "1\n");
    RUN("eval" << "[1,2,3]", "1\n2\n3\n");
    RUN("eval" << "'123'", "123\n");
    RUN("eval" << "'123'", "123\n");

    RUN("-e" << "", "");
    RUN("-e" << "1", "1\n");
}

void CoreTests::commandEvalThrows()
{
    RUN_EXPECT_ERROR_WITH_STDERR(
        "throw Error('Some exception')", CommandException,
        "ScriptError: Some exception\n"
        "\n"
        "--- backtrace ---\n"
    );

    RUN_EXPECT_ERROR_WITH_STDERR(
        "throw 'Some exception'", CommandException,
        "ScriptError: Some exception\n"
    );

    RUN_EXPECT_ERROR("eval('throw Error(1)')", CommandException);
    RUN_EXPECT_ERROR("eval('throw 1')", CommandException);
    RUN_EXPECT_ERROR("eval" << "throw Error(1)", CommandException);
    RUN_EXPECT_ERROR("eval" << "throw 1", CommandException);
}

void CoreTests::commandEvalThrowsWithLineNumber()
{
    // Multi-line script: exception on a specific line
    RUN_EXPECT_ERROR_WITH_STDERR(
        "eval" << "var x = 1\nvar y = 2\nthrow Error('on line 3')",
        CommandException,
        "At line 3 in:\n"
        " 1|var x = 1\n"
        " 2|var y = 2\n"
        " 3|throw Error('on line 3')\n"
    );

    // Single-line throw: line number is 1
    RUN_EXPECT_ERROR_WITH_STDERR(
        "throw Error('single line')", CommandException,
        "At line 1 in:\n"
    );

    // Non-Error throw: no line number (no .lineNumber on plain string)
    RUN_EXPECT_ERROR_WITH_STDERR(
        "throw 'no line info'", CommandException,
        "ScriptError: no line info\n"
    );

    // Nested eval: inner script is multiline (2 lines)
    RUN_EXPECT_ERROR_WITH_STDERR(
        "eval" << "eval(\"\\\\nthrow Error('nested')\")",
        CommandException,
        "At line 2 in:\n"
        " 1|\n"
        " 2|throw Error('nested')\n"
    );

    // Doubly nested eval: both inner scripts are multiline.
    // Inner script is 3 lines, middle script is 2 lines.
    RUN_EXPECT_ERROR_WITH_STDERR(
        "eval" << "eval(\"\\\\neval('\\\\\\\\n\\\\\\\\nthrow Error()')\")",
        CommandException,
        "At line 3 in:\n"
        " 1|\n"
        " 2|\n"
        " 3|throw Error()\n"
    );

    // Verify the middle frame in the doubly nested case is also numbered
    RUN_EXPECT_ERROR_WITH_STDERR(
        "eval" << "eval(\"\\\\neval('\\\\\\\\n\\\\\\\\nthrow Error()')\")",
        CommandException,
        " 2|eval('\\n\\nthrow Error()')\n"
    );
}


void CoreTests::commandEvalSyntaxError()
{
    RUN_EXPECT_ERROR_WITH_STDERR("eval" << "(", CommandException, "SyntaxError");
}

void CoreTests::commandEvalArguments()
{
    RUN("eval" << "str(arguments[1]) + ', ' + str(arguments[2])" << "Test 1" << "Test 2",
        "Test 1, Test 2\n");
}

void CoreTests::commandEvalEndingWithComment()
{
    /*
    With Qml scripts in Qt 5, it's not possible to get uncaught exceptions
    from `QJSEngine::evaluate()`.

    Workaround is to wrap the script properly in an try/catch block:

        try {
            %1
        } catch(e) {
            _store_exception_internally(e);
            throw e;
        }

    (Unfortunately, it's still possible to escape the function with a script injection.)
    */
    RUN("eval" << "1 // TEST", "1\n");
}

void CoreTests::commandPrint()
{
    RUN("print" << "1", "1");
    RUN("print" << "TEST", "TEST");
}

void CoreTests::commandAbort()
{
    RUN("abort(); 1", "");
    RUN("eval" << "abort(); 1", "");
    RUN("eval" << "eval('abort(); print(1)'); 2", "");
    RUN("eval" << "execute('copyq', 'eval', '--', 'abort(); print(1)'); 2", "2\n");
}

void CoreTests::commandFail()
{
    QByteArray stdoutActual;
    QByteArray stderrActual;
    QCOMPARE( run(Args("fail"), &stdoutActual, &stderrActual), 10 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    QCOMPARE( stdoutActual, QByteArray() );
}

void CoreTests::commandSource()
{
    const auto script =
            R"(
            test = function() { return " TEST" }
            print("SOURCED")
            )";

    QTemporaryFile scriptFile;
    QVERIFY(scriptFile.open());
    scriptFile.write(script);
    scriptFile.close();
    const auto scriptFileName = scriptFile.fileName();

    RUN("source" << scriptFileName, "SOURCED");
    RUN("source" << scriptFileName << "test()", "SOURCED TEST\n");
}

void CoreTests::commandVisible()
{
    RUN("visible", "true\n");
}

void CoreTests::commandToggle()
{
    RUN("visible", "true\n");
    RUN("toggle", "false\n");
    WAIT_ON_OUTPUT("visible", "false\n");

    RUN("toggle", "true\n");
    WAIT_ON_OUTPUT("visible", "true\n");
}

void CoreTests::commandShowHide()
{
    RUN("visible", "true\n");
    RUN("hide", "");
    WAIT_ON_OUTPUT("visible", "false\n");

    RUN("show", "");
    WAIT_ON_OUTPUT("visible", "true\n");
}


void CoreTests::commandShowHideRapid()
{
    // Verify the main window can be hidden and reshown reliably in
    // rapid succession.  Regression test for #3445: deferred platform
    // raise in raiseWindow() could prevent the window from appearing.
    for (int i = 0; i < 3; ++i) {
        RUN("visible", "true\n");
        RUN("hide", "");
        WAIT_ON_OUTPUT("visible", "false\n");

        RUN("show", "");
        WAIT_ON_OUTPUT("visible", "true\n");
    }
}
void CoreTests::commandShowAt()
{
    RUN("visible", "true\n");
    RUN("hide", "");
    WAIT_ON_OUTPUT("visible", "false\n");

    RUN("showAt", "");
    WAIT_ON_OUTPUT("visible", "true\n");
}

void CoreTests::commandFocused()
{
    RUN("focused", "true\n");
    RUN("hide", "");
    RUN("focused", "false\n");
}

void CoreTests::commandsUnicode()
{
    const auto text = QString::fromUtf8(QByteArray("Zkouška s různými českými znaky!"));
    RUN_WITH_INPUT("eval" << "input()", text, text);
    RUN_WITH_INPUT("eval" << "str(input())", text, text + "\n");

    RUN_WITH_INPUT("eval" << "fromUnicode(str(input()), 'utf8')", text, text);
    RUN_WITH_INPUT("eval" << "toUnicode(fromUnicode(str(input()), 'utf16'), 'utf16')", text, text + "\n");
    RUN_WITH_INPUT("eval" << "toUnicode(fromUnicode(str(input()), 'utf32le'), 'utf32le')", text, text + "\n");
    RUN_WITH_INPUT("eval" << "toUnicode( fromUnicode(str(input()), 'utf16le') )", text, text + "\n");
}

void CoreTests::commandsAddRead()
{
    RUN("add" << "A", "");
    RUN("read" << "0", "A");

    RUN("add" << "B", "");
    RUN("read" << "0", "B");
    RUN("read" << "1", "A");

    RUN("add" << "C" << "D", "");
    RUN("read" << "0", "D");
    RUN("read" << "1", "C");
    RUN("read" << "2", "B");
    RUN("read" << "3", "A");
}

void CoreTests::commandsWriteRead()
{
    const QByteArray input("\x00\x01\x02\x03\x04", 5);
    const auto arg1 = QString::fromLatin1("\x01\x02\x03\x04");
    const auto arg2 = QString::fromLatin1("\x7f\x6f\x5f\x4f");
    TEST( m_test->runClient(
              Args() << "write"
              << COPYQ_MIME_PREFIX "test1" << arg1
              << COPYQ_MIME_PREFIX "test2" << "-"
              << COPYQ_MIME_PREFIX "test3" << arg2, "",
              input) );
    RUN("read" << COPYQ_MIME_PREFIX "test1" << "0", arg1.toLatin1());
    RUN("read" << COPYQ_MIME_PREFIX "test2" << "0", input);
    RUN("read" << COPYQ_MIME_PREFIX "test3" << "0", arg2.toLatin1());

    RUN("write(1, {'text/plain': 'A'}, {'text/plain': 'B'})", "");
    RUN("read(mimeText, 0, 1, 2, 3)", "\nB\nA\n");

    RUN("write(0, [{'text/plain': 'C'}, {'text/plain': 'D'}])", "");
    RUN("read(mimeText, 0, 1, 2, 3)", "D\nC\n\nB");

    RUN("write(0, ['E', 'F'])", "");
    RUN("read(mimeText, 0, 1, 2, 3)", "F\nE\nD\nC");

    RUN_EXPECT_ERROR_WITH_STDERR(
        "write(0, [{}], [{}])",
        CommandException, "Unexpected multiple item list arguments");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "write(0)",
        CommandException, "Expected item arguments");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "write(0, '1', '2', '3')",
        CommandException, "Unexpected uneven number of mimeType/data arguments");
}

void CoreTests::commandsReadUtf8ByDefault()
{
    RUN("add({[mimeText]: 'A', [mimeTextUtf8]: 'B'})", "");
    RUN("read(mimeText, 0)", "A");
    RUN("read(0)", "B");
}

void CoreTests::commandChange()
{
    RUN("add" << "C" << "B" << "A", "");
    RUN("change" << "1" << "text/plain" << "b", "");
    RUN("separator" << " " << "read" << "0" << "1" << "2", "A b C");

    RUN("change" << "1" << "text/plain" << "B" << "text/html" << "<b>B</b>", "");
    RUN("read" << "text/html" << "1", "<b>B</b>");
    RUN("separator" << " " << "read" << "0" << "1" << "2", "A B C");

    RUN("change(1, 'text/html', undefined)", "");
    RUN("read" << "?" << "1", "text/plain\n");
}

void CoreTests::commandSetCurrentTab()
{
    const auto tab = testTab(1);
    RUN("setCurrentTab" << tab, "");
    TEST_SELECTED(tab + "\n");
}

void CoreTests::commandConfig()
{
    QByteArray stdoutActual;
    QByteArray stderrActual;
    QCOMPARE( run(Args("config"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    QVERIFY( !stdoutActual.isEmpty() );

    // invalid option
    RUN_EXPECT_ERROR_WITH_STDERR("config" << "xxx", CommandException, "xxx");

    QCOMPARE( run(Args("config") << "tab_tree", &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    QVERIFY2( stdoutActual == "true\n" || stdoutActual == "false\n", stdoutActual);

    RUN("config" << "tab_tree" << "true", "true\n");
    RUN("config" << "tab_tree", "true\n");

    RUN("config" << "tab_tree" << "false", "false\n");
    RUN("config" << "tab_tree", "false\n");

    RUN("config" << "tab_tree" << "1", "true\n");
    RUN("config" << "tab_tree", "true\n");

    RUN("config" << "tab_tree" << "0", "false\n");
    RUN("config" << "tab_tree", "false\n");

    // Set multiple options.
    RUN("config" << "tab_tree" << "0" << "text_wrap" << "1",
        "tab_tree=false\n"
        "text_wrap=true\n");

    // Don't set any options if there is an invalid one.
    RUN_EXPECT_ERROR_WITH_STDERR("config" << "tab_tree" << "1" << "xxx" << "0", CommandException, "xxx");
    RUN("config" << "tab_tree", "false\n");
}

void CoreTests::commandToggleConfig()
{
    RUN("toggleConfig" << "check_clipboard", "false\n");
    RUN("config" << "check_clipboard", "false\n");
    RUN("toggleConfig" << "check_clipboard", "true\n");
    RUN("config" << "check_clipboard", "true\n");

    RUN_EXPECT_ERROR("toggleConfig", CommandException);
    RUN_EXPECT_ERROR_WITH_STDERR("toggleConfig" << "xxx", CommandException, "xxx");
    RUN_EXPECT_ERROR_WITH_STDERR("toggleConfig" << "clipboard_tab", CommandException, "clipboard_tab");
}

void CoreTests::commandDialog()
{
    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('text')", "TEST\n"); },
        [&]{ KEYS("focus::QLineEdit<:QDialog" << ":TEST" << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('text') === undefined", "true\n"); },
        [&]{ KEYS("focus::QLineEdit<:QDialog" << "ESCAPE"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('.defaultChoice', 2, 'list', [1, 2, 3])", "2\n"); },
        [&]{ KEYS("focus::QComboBox<:QDialog" << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('.defaultChoice', '', 'list', [1, 2, 3])", "\n"); },
        [&]{ KEYS("focus::QComboBox<:QDialog" << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('list', [0, 1, 2])", "0\n"); },
        [&]{ KEYS("focus::QComboBox<:QDialog" << "ENTER"); }
    );

    // Can't focus configuration checkboxes on OS X
#ifndef Q_OS_MAC
    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('boolean', true) === true", "true\n"); },
        [&]{ KEYS("focus::QCheckBox<:QDialog" << "ENTER"); }
    );
#endif

    // Verify that special argument ".title" changes dialog's object name
    // so that geometry can be stored.
    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('.title', 'test', 'text')", ""); },
        [&]{ KEYS("focus::QLineEdit<dialog_test:QDialog" << "ESCAPE"); }
    );

    KEYS(clipboardBrowserId);
    const QByteArray script = R"(
        dialog(
            '.width', 100,
            '.height', 100,
            '.x', 10,
            '.y', 10,
            '.style', 'background: red',
            '.icon', '',
            '.label', 'TEST',
            'text', 'DEFAULT',
        )
    )";
    RUN_MULTIPLE(
        [&]{ RUN(script, "DEFAULT\n"); },
        [&]{ KEYS("focus::QLineEdit<:QDialog" << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("dialog('.title', 'Remove Items', '.label', 'Remove all items?') === true", "true\n"); },
        [&]{ KEYS("focus::QPushButton<dialog_Remove Items:QDialog" << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    const QByteArray script2 = R"(
        dialog(
            '.modal', true,
            '.onTop', true,
            'text', 'DEFAULT',
        )
    )";
    RUN_MULTIPLE(
        [&]{ RUN(script2, "DEFAULT\n"); },
        [&]{ KEYS("focus::QLineEdit<:QDialog" << "ENTER"); }
    );
}

void CoreTests::commandDialogCloseOnDisconnect()
{
    RUN("afterMilliseconds(0, abort); dialog()", "");
}

void CoreTests::commandMenuItems()
{
    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("menuItems('a', 'b', 'c')", "a\n"); },
        [&]{ KEYS(customMenuId << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("menuItems([{'text/plain': 'a'}, {'text/plain': 'b'}])", "0\n"); },
        [&]{ KEYS(customMenuId << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("menuItems('a', 'b', 'c')", "\n"); },
        [&]{ KEYS(customMenuId << "ESCAPE"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("menuItems([{'text/plain': 'a'}, {'text/plain': 'b'}])", "-1\n"); },
        [&]{ KEYS(customMenuId << "ESCAPE"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("menuItems('a', 'b', 'c')", "b\n"); },
        [&]{ KEYS(customMenuId << ":b" << "ENTER"); }
    );

    KEYS(clipboardBrowserId);
    RUN_MULTIPLE(
        [&]{ RUN("menuItems([{'text/plain': 'a'}, {'text/plain': 'b'}])", "1\n"); },
        [&]{ KEYS(customMenuId << ":b" << "ENTER"); }
    );

    RUN("afterMilliseconds(0, abort); menuItems('a', 'b', 'c')", "");
}

void CoreTests::commandsPackUnpack()
{
    QMap<QLatin1String, QByteArray> data;
    data[mimeText] = "plain text";
    data[mimeHtml] = "<b>HTML text</b>";
    data[QLatin1String(COPYQ_MIME_PREFIX "test1")] = "test1 data";
    data[QLatin1String(COPYQ_MIME_PREFIX "test2")] = "test2 data";

    auto args = Args() << "write";
    for (const auto &mime : data.keys())
        args << mime << data[mime];
    RUN(args, "");

    const QByteArray script1 =
            "var data = read('" + toByteArray(mimeItems) + "', 0); var item = unpack(data);";

    // Unpack item read from list.
    for (const auto &mime : data.keys()) {
        RUN("eval"
            << script1 + "var mime = '" + mime + "'; print(mime + ':' + str(item[mime]))",
            mime + ':' + data[mime]);
    }

    // Test pack and unpack consistency.
    const QByteArray script2 = "data = pack(item); item = unpack(data);";
    for (const auto &mime : data.keys()) {
        RUN("eval"
            << script1 + script2 + "var mime = '" + mime + "'; print(mime + ':' + str(item[mime]))",
            mime + ':' + data[mime]);
    }
}

void CoreTests::commandsBase64()
{
    const QByteArray data = "0123456789\001\002\003\004\005\006\007abcdefghijklmnopqrstuvwxyz!";
    const QByteArray base64 = data.toBase64();

    TEST( m_test->runClient(Args() << "eval" << "print(input())", data, data) );
    TEST( m_test->runClient(Args() << "eval" << "print(tobase64(input()))", base64, data) );

    // Line break is added only if return value is string;
    // tobase64() returns string, frombase64() returns byte array.
    RUN("tobase64" << data, base64 + '\n');
    RUN("frombase64" << base64, data);

    TEST( m_test->runClient(Args() << "eval" << "print(frombase64(tobase64(input())))", data, data) );

    // Test Base64 encoding and decoding consistency.
    TEST( m_test->runClient(
              Args() << "eval"
              << "var base64 = tobase64(input()); if (str(input()) === str(frombase64(base64))) print('OK')",
              "OK", data) );
}

void CoreTests::commandsGetSetItem()
{
    QMap<QByteArray, QByteArray> data;
    data["text/plain"] = "plain text";
    data["text/html"] = "<b>HTML text</b>";
    data[COPYQ_MIME_PREFIX "test1"] = "test1 data";
    data[COPYQ_MIME_PREFIX "test2"] = "test2 data";

    const QString tab = testTab(1);
    const Args args = Args("tab") << tab;

    Args args2 = args;
    args2 << "write";
    for (const auto &mime : data.keys())
        args2 << mime << data[mime];
    RUN(args2, "");

    // Get item from list.
    for (const auto &mime : data.keys()) {
        RUN(args << "eval"
            << "var mime = '" + mime + "'; print(mime + ':' + str(getitem(0)[mime]))",
            mime + ':' + data[mime]);
    }

    // Set item.
    RUN(args << "eval"
        << "setitem(1, { 'text/plain': 'plain text 2', 'text/html': '<b>HTML text 2</b>' })",
        "");

    RUN(args << "eval" << "print(getitem(1)['text/plain'])", "plain text 2");
    RUN(args << "eval" << "print(getitem(1)['text/html'])", "<b>HTML text 2</b>");
}

void CoreTests::commandsChecksums()
{
    RUN("eval" <<
        "[md5sum('TEST'), sha1sum('TEST'),"
        " sha256sum('TEST'), sha512sum('TEST')]"
        ,
        "033bd94b1168d7e4f0d644c3c95e35bf\n"
        "984816fd329622876e14907634264e6f332e9fb3\n"
        "94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2\n"
        "7bfa95a688924c47c7d22381f20cc926f524beacb13f84e203d4bd8cb6ba2fce81c57a5f059bf3d509926487bde925b3bcee0635e4f7baeba054e5dba696b2bf\n"
    );
}

void CoreTests::commandEscapeHTML()
{
    RUN("escapeHTML" << "&\n<\n>", "&amp;<br />&lt;<br />&gt;\n");
}

void CoreTests::commandExecute()
{
    const QByteArray script =
            "function test(c, expected_stdout, expected_exit_code) {"
            "    if (str(c.stdout) !== expected_stdout) print('Unexpected stdout: \"' + str(c.stdout) + '\"');"
            "    if (c.exit_code !== expected_exit_code) print('Unexpected exit_code: ' + str(c.exit_code));"
            "}";

    RUN("eval" << script +
        "c = execute('copyq', 'write', 'text/plain', 'plain text', 'text/html', '<b>test HTML</b>');"
        "test(c, '', 0);"
        , "");

    RUN("eval" << script +
        "c = execute('copyq', 'read', 'text/plain', 0);"
        "test(c, 'plain text', 0);"
        , "");

    RUN("eval" << script +
        "c = execute('copyq', 'read', 'text/html', 0);"
        "test(c, '<b>test HTML</b>', 0);"
        , "");

    RUN("eval" << script +
        "c = execute('copyq', 'read', 0, function(lines) { print(lines); });"
        "test(c, 'plain text', 0);"
        , "plain text");
}

void CoreTests::commandSettings()
{
    RUN("config" << "clipboard_tab" << "TEST", "TEST\n");

    RUN("settings" << "test_variable", "");
    RUN("settings" << "test_variable" << "TEST VALUE", "");
    RUN("settings" << "test_variable", "TEST VALUE");
    RUN("settings" << "test_variable" << "TEST VALUE 2", "");
    RUN("settings" << "test_variable", "TEST VALUE 2");

    RUN("config" << "clipboard_tab", "TEST\n");
}

void CoreTests::commandsEnvSetEnv()
{
    RUN("eval" <<
        "\n var name = 'COPYQ_ENV_TEST'"
        "\n if (setEnv(name, 'OK'))"
        "\n   print(env(name))"
        "\n else"
        "\n   print('FAILED')"
        , "OK"
        );
}

void CoreTests::commandSleep()
{
    RUN_MULTIPLE(
        [&]{
            QElapsedTimer t;
            t.start();
            RUN("sleep" << "200", "");
            const auto afterElapsed200Ms = t.elapsed();
            QVERIFY(afterElapsed200Ms > 200);
        },
        [&]{
            QElapsedTimer t;
            t.start();
            RUN("sleep" << "50", "");
            const auto afterElapsed50Ms = t.elapsed();
            QVERIFY(afterElapsed50Ms > 50);
        }
    );
}

void CoreTests::commandsData()
{
    RUN("eval" << "setData('x', 'X'); data('x')", "X");
    RUN("eval" << "setData('x', 'X'); setData('y', 'Y'); str(data('x')) + str(data('y'))", "XY\n");

    RUN("dataFormats", "");
    RUN("eval" << "setData('x'); dataFormats()", "x\n");
    RUN("eval" << "setData('x'); setData('y'); dataFormats()", "x\ny\n");

    RUN("eval" << "setData('x'); setData('y'); removeData('x'); dataFormats()", "y\n");
    RUN("eval" << "setData('x'); setData('y'); removeData('y'); dataFormats()", "x\n");
}

void CoreTests::commandCurrentWindowTitle()
{
    RUN("disable", "");
    WAIT_ON_OUTPUT("currentWindowTitle", appWindowTitle("*Clipboard Storing Disabled*"));
    RUN("enable", "");
}

void CoreTests::commandCopy()
{
    RUN("copy" << "A", "true\n");
    WAIT_FOR_CLIPBOARD("A");

    RUN("copy" << "DATA" << "B", "true\n");
    WAIT_FOR_CLIPBOARD2("B", "DATA");

    // Test copying UTF-8 text.
    // On macOS, text/plain and text/plain;charset=utf-8 map to the same UTI
    // (public.utf8-plain-text), so setting both with different values causes
    // the pasteboard to return the wrong one. Only set mimeTextUtf8 on macOS.
#ifdef Q_OS_MAC
    RUN("--" << "copy({[mimeTextUtf8]: '\u2705'})", "true\n");
#else
    RUN("--" << "copy({[mimeText]: '\\\\u2705', [mimeTextUtf8]: '\u2705'})", "true\n");
#endif
    WAIT_FOR_CLIPBOARD2("\u2705", mimeTextUtf8);

    RUN( Args() << "copy"
         << "DATA3" << "C"
         << "DATA4" << "D"
         , "true\n" );
    WAIT_FOR_CLIPBOARD2("C", "DATA3");

    SKIP_ON_ENV("COPYQ_TESTS_SKIP_MULTIPLE_CLIPBOARD_FORMATS");
    WAIT_FOR_CLIPBOARD2("D", "DATA4");

    RUN( "copy({'DATA1': 1, 'DATA2': 2})", "true\n" );
    WAIT_FOR_CLIPBOARD2("1", "DATA1");
    WAIT_FOR_CLIPBOARD2("2", "DATA2");

    RUN_EXPECT_ERROR_WITH_STDERR(
        "copy({}, {})",
        CommandException, "Expected single item");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "copy([{}, {}])",
        CommandException, "Expected single item");
}

void CoreTests::commandClipboard()
{
    TEST( m_test->setClipboard("A") );
    WAIT_FOR_CLIPBOARD("A");

    TEST( m_test->setClipboard("B", "DATA") );
    WAIT_FOR_CLIPBOARD2("B", "DATA");
    RUN("clipboard" << "DATA", "B");
}

void CoreTests::commandHasClipboardFormat()
{
    TEST( m_test->setClipboard("B", "DATA") );
    WAIT_FOR_CLIPBOARD2("B", "DATA");
    WAIT_ON_OUTPUT("hasClipboardFormat('DATA')", "true\n");
    RUN("hasClipboardFormat('text/plain')", "false\n");

    TEST( m_test->setClipboard("A") );
    WAIT_ON_OUTPUT("hasClipboardFormat('text/plain')", "true\n");
}

void CoreTests::commandEdit()
{
    SKIP_ON_ENV("COPYQ_TESTS_SKIP_COMMAND_EDIT");

    RUN("config" << "editor" << "", "\n");

    // Edit clipboard and new item.
    TEST( m_test->setClipboard("TEST") );
    RUN("edit" << "-1", "");
    KEYS("END" << ":LINE 1" << "F2");
    RUN("read" << "0", "TESTLINE 1");
    WAIT_FOR_CLIPBOARD("TESTLINE 1");

    // Edit existing item.
    RUN("edit" << "0", "");
    KEYS("END" << "ENTER" << ":LINE 2" << "F2");
    RUN("read" << "0", "TESTLINE 1\nLINE 2");
    WAIT_FOR_CLIPBOARD("TESTLINE 1");

    // Edit clipboard (ignore existing data) and new item.
    RUN("edit", "");
    KEYS("END" << ":LINE 1" << "F2");
    RUN("read" << "0", "LINE 1");
    WAIT_FOR_CLIPBOARD("LINE 1");
}

void CoreTests::commandEditItem()
{
    SKIP_ON_ENV("COPYQ_TESTS_SKIP_COMMAND_EDIT");

    RUN("config" << "editor" << "", "\n");

    // Edit clipboard and new item.
    TEST( m_test->setClipboard("<!--StartFragment-->TEST<!--EndFragment-->", mimeHtml) );
    RUN("editItem" << "-1" << mimeHtml, "");
    KEYS("END" << ":LINE 1" << "F2");
    const auto expected = QByteArrayLiteral("TESTLINE 1");
#ifdef Q_OS_WIN
    const auto expectedClipboard = QByteArrayLiteral("<!--StartFragment-->TESTLINE 1<!--EndFragment-->");
#else
    const auto expectedClipboard = expected;
#endif
    RUN("read" << mimeHtml << "0", expected);
    RUN("read" << "0", "");
    WAIT_FOR_CLIPBOARD2(expectedClipboard, mimeHtml);
    WAIT_FOR_CLIPBOARD("");

    // Edit existing item.
    RUN("editItem" << "0" << mimeHtml, "");
    KEYS("END" << "ENTER" << ":LINE 2" << "F2");
    RUN("read" << mimeHtml << "0", expected + "\nLINE 2");
    RUN("read" << "0", "");
    WAIT_FOR_CLIPBOARD2(expectedClipboard, mimeHtml);
    WAIT_FOR_CLIPBOARD("");

    // Edit clipboard (ignore existing data) and new item.
    RUN("editItem" << "-1" << mimeHtml << "TEST", "");
    KEYS("END" << ":LINE 1" << "F2");
    RUN("read" << mimeHtml << "0", "TESTLINE 1");
    RUN("read" << "0", "");
    WAIT_FOR_CLIPBOARD2(expectedClipboard, mimeHtml);
    WAIT_FOR_CLIPBOARD("");
}

void CoreTests::commandGetSetCurrentPath()
{
    RUN("currentPath", QDir::currentPath() + "\n");

    const auto newPath = QDir::homePath().toUtf8();

    TEST( m_test->runClient(
              Args("eval") << "currentPath(input()); print(currentPath())",
              newPath, newPath) );

    TEST( m_test->runClient(
              Args("eval") << "currentPath(input()); print(Dir().absolutePath())",
              newPath, newPath) );
}

void CoreTests::commandSelectItems()
{
    RUN("add" << "C" << "B" << "A", "");

    RUN("selectItems" << "1", "true\n");
    TEST_SELECTED(QString(clipboardTabName) + " 1 1\n");

    RUN("selectItems" << "0" << "2", "true\n");
    TEST_SELECTED(QString(clipboardTabName) + " 2 0 2\n");

    RUN("selectItems" << "2" << "0", "true\n");
    TEST_SELECTED(QString(clipboardTabName) + " 0 2 0\n");

    const auto tab = testTab(1);
    const auto args = Args("tab") << tab;
    RUN(args << "add" << "C" << "B" << "A", "");
    RUN(args << "selectItems" << "1" << "2", "true\n");
    TEST_SELECTED(QString(clipboardTabName) + " 0 2 0\n");
    RUN("setCurrentTab" << tab, "");
    TEST_SELECTED(tab + " 2 1 2\n");
}

void CoreTests::commandsExportImport()
{
    const auto tab1 = testTab(1);
    RUN("tab" << tab1 << "add" << "C" << "B" << "A", "");

    const auto tab2 = testTab(2);
    RUN("tab" << tab2 << "add" << "3" << "2", "");

    RUN("config" << "maxitems" << "3", "3\n");
    RUN("config" << "editor" << "EDITOR1 %1", "EDITOR1 %1\n");

    TemporaryFile tmp;
    const auto fileName = tmp.fileName();

    RUN("exportData" << fileName, "");

    RUN("config" << "maxitems" << "1", "1\n");
    RUN("config" << "editor" << "EDITOR2 %1", "EDITOR2 %1\n");
    RUN("removetab" << tab1, "");
    RUN("tab" << tab2 << "add" << "1", "");

    RUN("importData" << fileName, "");

    RUN("config" << "maxitems", "3\n");
    RUN("config" << "editor", "EDITOR1 %1\n");

    const auto suffix = " (1)";
    RUN("tab",
        QString(clipboardTabName) + "\n"
        + tab2 + "\n"
        + clipboardTabName + suffix + "\n"
        + tab1 + "\n"
        + tab2 + suffix + "\n");

    RUN("tab" << tab1 << "read" << "0" << "1" << "2", "A\nB\nC");
    RUN("tab" << tab2 + suffix << "read" << "0" << "1", "2\n3");
    RUN("tab" << tab2 << "read" << "0", "1");
}

void CoreTests::commandsGetSetCommands()
{
    RUN("commands().length", "0\n");

    RUN("setCommands([{name: 'test', cmd: 'copyq help'}])", "");
    RUN("commands().length", "1\n");
    RUN("commands()[0].name", "test\n");
    RUN("commands()[0].cmd", "copyq help\n");
    RUN("commands()[0].enable", "true\n");

    RUN("setCommands(commands())", "");
    RUN("commands().length", "1\n");
    RUN("commands()[0].name", "test\n");
    RUN("commands()[0].enable", "true\n");
}

void CoreTests::commandsImportExportCommands()
{
   const QString commands =
           R"('
           [Commands]
           1\Name=Test 1
           2\Name=Test 2
           size=2
           ')";
   RUN("eval" << "importCommands(arguments[1]).length" << "--" << commands, "2\n");
   RUN("eval" << "importCommands(arguments[1])[0].name" << "--" << commands, "Test 1\n");
   RUN("eval" << "importCommands(arguments[1])[1].name" << "--" << commands, "Test 2\n");

   RUN("importCommands(exportCommands([{},{}])).length", "2\n");
   RUN("importCommands(exportCommands([{},{name: 'Test 2'}]))[1].name", "Test 2\n");
}

void CoreTests::commandsImportExportCommandsFixIndentation()
{
    {
        const QString commands =
                "[Command]\n"
                "Command=\"\n    1\n    2\n    3\"";
        RUN("eval" << "importCommands(arguments[1])[0].cmd" << "--" << commands, "1\n2\n3\n");
    }

    {
        const QString commands =
                "[Command]\n"
                "Command=\"\r\n    1\r\n    2\r\n    3\"";
        RUN("eval" << "importCommands(arguments[1])[0].cmd" << "--" << commands, "1\n2\n3\n");
    }
}

void CoreTests::commandsAddCommandsRegExp()
{
    const QString commands =
            "[Command]\n"
            "Match=^(https?|ftps?)://\\\\$\n";

    // Ensure there is a basic RegExp support.
    RUN("/test/", "/test/\n");
    RUN("/test/.source", "test\n");

    RUN("eval" << "exportCommands(importCommands(arguments[1]))" << "--" << commands, commands);
    RUN("eval" << "Object.prototype.toString.call(importCommands(arguments[1])[0].re)" << "--" << commands, "[object RegExp]\n");
    RUN("eval" << "Object.prototype.toString.call(importCommands(arguments[1])[0].wndre)" << "--" << commands, "[object RegExp]\n");
    RUN("eval" << "importCommands(arguments[1])[0].re" << "--" << commands, "/^(https?|ftps?):\\/\\/\\$/\n");
    RUN("eval" << "importCommands(arguments[1])[0].wndre" << "--" << commands, "/(?:)/\n");

    RUN("eval" << "addCommands(importCommands(arguments[1]))" << "--" << commands, "");
    KEYS(commandDialogListId << "Enter" << clipboardBrowserId);

    RUN("exportCommands(commands())", commands);
    RUN("commands()[0].name", "\n");
    RUN("commands()[0].re", "/^(https?|ftps?):\\/\\/\\$/\n");
    RUN("commands()[0].wndre", "/(?:)/\n");
}

void CoreTests::commandScreenshot()
{
    RUN("screenshot().size() > 0", "true\n");
}

void CoreTests::commandNotification()
{
    const auto script = R"(
        notification(
            '.title', 'title',
            '.message', 'message',
            '.time', 1000,
            '.id', 'test',
            '.icon', 'copyq',
            '.button', 'OK', '', '',
            '.button', 'CANCEL', '', '',
            '.urgency', 'critical',
            '.persistent', true,
        )
        )";
    RUN(script, "");

    RUN_EXPECT_ERROR_WITH_STDERR(
                "notification('.message', 'message', 'BAD')", CommandException, "Unknown argument: BAD");
}

void CoreTests::commandNotificationUrgency()
{
    RUN("notification('.urgency', 'low')", "");
    RUN("notification('.urgency', 'normal')", "");
    RUN("notification('.urgency', 'high')", "");
    RUN("notification('.urgency', 'critical')", "");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "notification('.urgency', 'unknown')",
        CommandException,
        "Unknown value for '.urgency' notification field: unknown");
}

void CoreTests::commandNotificationPersistent()
{
    RUN("notification('.persistent', true)", "");
    RUN("notification('.persistent', false)", "");
    RUN("notification('.persistent', 1)", "");
    RUN("notification('.persistent', 0)", "");
    RUN("notification('.persistent', 'true')", "");
    RUN("notification('.persistent', 'false')", "");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "notification('.persistent', 'unknown')",
        CommandException,
        "Unknown value for '.persistent' notification field: unknown");
}

void CoreTests::commandIcon()
{
    RUN("iconColor", QByteArray(defaultSessionColor) + "\n");

    RUN("iconColor" << "red", "");
    RUN("iconColor", "#ff0000\n");

    RUN_EXPECT_ERROR("iconColor" << "BAD_COLOR_NAME", CommandException);
    RUN("iconColor", "#ff0000\n");

    RUN("iconColor" << "", "");
    RUN("iconColor", QByteArray(defaultSessionColor) + "\n");

    RUN("iconColor" << defaultSessionColor, "");
    RUN("iconColor", QByteArray(defaultSessionColor) + "\n");
}

void CoreTests::commandIconTag()
{
    RUN("iconTag", "\n");

    RUN("iconTag" << "TEST", "");
    RUN("iconTag", "TEST\n");

    RUN("iconTag" << "", "");
    RUN("iconTag", "\n");
}

void CoreTests::commandIconTagColor()
{
    RUN("iconTagColor", QByteArray(defaultTagColor) + "\n");

    RUN("iconTagColor" << "red", "");
    RUN("iconTagColor", "#ff0000\n");

    RUN_EXPECT_ERROR("iconTagColor" << "BAD_COLOR_NAME", CommandException);
    RUN("iconTagColor", "#ff0000\n");

    RUN("iconTagColor" << defaultTagColor, "");
    RUN("iconTagColor", QByteArray(defaultTagColor) + "\n");
}

void CoreTests::commandLoadTheme()
{
    RUN_EXPECT_ERROR_WITH_STDERR(
        "loadTheme" << "a non-existent file", CommandException, "ScriptError: Failed to read theme");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "loadTheme" << ".", CommandException, "ScriptError: Failed to read theme");

    {
        QTemporaryFile tmp;
        QVERIFY(tmp.open());
        tmp.write("INVALID INI FILE");
        tmp.close();
        RUN_EXPECT_ERROR_WITH_STDERR(
            "loadTheme" << tmp.fileName(), CommandException, "ScriptError: Failed to parse theme");
    }

    {
        QTemporaryFile tmp;
        QVERIFY(tmp.open());
        tmp.write("[General]");
        tmp.close();
        RUN("loadTheme" << tmp.fileName(), "");
    }

    // Verify default stylesheets - if there is a syntax error,
    // application prints a warning which should be captured by tests.
    {
        QTemporaryFile tmp;
        QVERIFY(tmp.open());
        tmp.write("[General]\nstyle_main_window=true");
        tmp.close();
        RUN("loadTheme" << tmp.fileName(), "");
    }
}

void CoreTests::commandDateString()
{
    const auto dateFormat = "TEST:yyyy-MM-dd";
    const auto dateTime = QDateTime::currentDateTime();
    const auto today = dateTime.toString(dateFormat);
    RUN("dateString" << dateFormat, today + "\n");
}

void CoreTests::commandAfterMilliseconds()
{
    const QString script = "afterMilliseconds(100, function(){ print('TEST'); abort(); });";
    RUN(script, "");
    RUN(script + "sleep(1)", "");
    RUN(script + "sleep(200)", "TEST");
}

void CoreTests::commandAsync()
{
    RUN("afterMilliseconds(0, function() { print(currentItem()); abort(); }); dialog()", "-1");
}

void CoreTests::commandFilter()
{
    RUN("filter", "\n");
    RUN("filter" << "test", "");
    RUN("filter", "test\n");
    RUN("filter" << "another", "");
    RUN("filter", "another\n");
    RUN("filter" << "", "");
    RUN("filter", "\n");

    // Empty filter() after ESC.
    RUN("filter" << "test", "");
    RUN("filter", "test\n");
    KEYS("ESC");
    RUN("filter", "\n");
}

void CoreTests::commandMimeTypes()
{
    RUN("eval" <<
        "[mimeText, mimeHtml, mimeUriList, mimeWindowTitle,"
        " mimeItems, mimeItemNotes, mimeOwner, mimeClipboardMode,"
        " mimeCurrentTab, mimeSelectedItems, mimeCurrentItem,"
        " mimeHidden, mimeShortcut, mimeColor, mimeOutputTab]"
        ,
        QByteArray(mimeText.data()) + "\n"
        + mimeHtml + "\n"
        + mimeUriList + "\n"
        + mimeWindowTitle + "\n"
        + mimeItems + "\n"
        + mimeItemNotes + "\n"
        + mimeOwner + "\n"
        + mimeClipboardMode + "\n"
        + mimeCurrentTab + "\n"
        + mimeSelectedItems + "\n"
        + mimeCurrentItem + "\n"
        + mimeHidden + "\n"
        + mimeShortcut + "\n"
        + mimeColor + "\n"
        + mimeOutputTab + "\n"
    );
}

void CoreTests::commandUnload()
{
    // Failure if tab is visible.
    RUN("unload", "");

    const auto tab = testTab(1);

    // Success if tab doesn't exist.
    RUN("unload" << tab, tab + "\n");

    RUN("tab" << tab << "add" << "A", "");
    // Success if tab is not visible and editor is not open.
    RUN("unload" << tab, tab + "\n");

    RUN("tab" << tab << "add" << "B", "");
    RUN("unload", tab + "\n");
    // Success if tab is not loaded.
    RUN("unload", tab + "\n");

    // Success if tab does not exist.
    RUN("unload" << "missing-tab", "missing-tab\n");
}

void CoreTests::commandForceUnload()
{
    RUN("forceUnload", "");
    RUN_EXPECT_ERROR_WITH_STDERR("add" << "A", CommandException, "ScriptError: Invalid tab");

    KEYS(clipboardBrowserRefreshButtonId << "Space");
    RUN("add" << "A", "");

    const auto tab = testTab(1);
    RUN("tab" << tab << "add" << "A", "");

    RUN("forceUnload" << tab, "");

    RUN("setCurrentTab" << tab, "");
    RUN_EXPECT_ERROR_WITH_STDERR(
        "tab" << tab << "add" << "B", CommandException, "ScriptError: Invalid tab");

    KEYS(clipboardBrowserRefreshButtonId << "Space");
    RUN("add" << "B", "");
}

void CoreTests::commandServerLogAndLogs()
{
    const QByteArray data1 = generateData();
    QRegularExpression re("\\[[^]]+\\] Note <Server-[0-9]+>: " + QRegularExpression::escape(data1));

    QByteArray stdoutActual;
    QByteArray stderrActual;

    QCOMPARE( run(Args("logs"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    QVERIFY( !stdoutActual.isEmpty() );
    QVERIFY( !QString::fromUtf8(stdoutActual).contains(re) );

    RUN("serverLog" << data1, "");

    QCOMPARE( run(Args("logs"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    QVERIFY( !stdoutActual.isEmpty() );
    QVERIFY2( QString::fromUtf8(stdoutActual).contains(re), stdoutActual );
}

void CoreTests::commandStats()
{
    QByteArray stdoutActual;
    QByteArray stderrActual;
    QCOMPARE( run(Args("stats"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    QVERIFY( !stdoutActual.isEmpty() );
    const auto stats = stdoutActual.split('\n');
    QVERIFY2( stats.value(0).startsWith("TOTAL: "), stdoutActual);
    QVERIFY2( stats.contains("QApplication: 1"), stdoutActual);
    QVERIFY2( stats.contains("MainWindow: 1"), stdoutActual);
    QVERIFY2( stats.contains("#MainWindow: 1"), stdoutActual);
    QVERIFY2( stats.contains("#tabWidget: 1"), stdoutActual);
    QVERIFY2( stats.contains("#searchBar: 1"), stdoutActual);
    QVERIFY2( stats.contains("/MainWindow/#centralWidget/#tabWidget: 1"), stdoutActual);
    QVERIFY2( stats.contains("/MainWindow/#centralWidget/Utils::FilterLineEdit#searchBar: 1"), stdoutActual);
    QVERIFY2( stats.contains("/MainWindow/#centralWidget/#tabWidget/QStackedWidget/ClipboardBrowserPlaceholder/ClipboardBrowser: 1"), stdoutActual);
    QVERIFY2( stats.contains("/MainWindow/#centralWidget/#tabWidget/QStackedWidget/ClipboardBrowserPlaceholder: 1"), stdoutActual);

    // Verify model stats
    RUN("add" << "test_item", "");
    QCOMPARE( run(Args("stats"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    const auto stats2 = stdoutActual.split('\n');
    bool hasModelWithDataSize = false;
    for (const auto &line : stats2) {
        if (line.startsWith("MODEL ")) {
            QVERIFY2(line.contains("rows="), line);
            if (line.contains("dataSize=")) {
                hasModelWithDataSize = true;
                // Verify human-readable suffix is present
                QVERIFY2(line.contains(" B)") || line.contains(" KiB)") || line.contains(" MiB)"), line);
            }
        }
    }
    QVERIFY2(hasModelWithDataSize, stdoutActual);

    // Verify data directory size
    bool hasDataDir = false;
    for (const auto &line : stats2) {
        if (line.startsWith("DATA_DIR ")) {
            hasDataDir = true;
            QVERIFY2(line.contains("size="), line);
        }
    }
    QVERIFY2(hasDataDir, stdoutActual);

    // Verify new stat lines
    bool hasTabs = false;
    bool hasCommands = false;
    bool hasLogFiles = false;
    for (const auto &line : stats2) {
        if (line.startsWith("TABS: ")) {
            hasTabs = true;
            QVERIFY2(line.contains("total="), line);
            QVERIFY2(line.contains("loaded="), line);
        }
        if (line.startsWith("COMMANDS: ")) {
            hasCommands = true;
            QVERIFY2(line.contains("automatic="), line);
            QVERIFY2(line.contains("script="), line);
        }
        if (line.startsWith("LOG_FILES: ")) {
            hasLogFiles = true;
            QVERIFY2(line.contains("count="), line);
            QVERIFY2(line.contains("size="), line);
        }
    }
    QVERIFY2(hasTabs, stdoutActual);
    QVERIFY2(hasCommands, stdoutActual);
    QVERIFY2(hasLogFiles, stdoutActual);

    // Verify monitorClipboard internal action appears with per-sub-process pid/rss
    const auto isMonitorAction = [](const QByteArray &line) {
        return line.startsWith("ACTION ") && line.contains("monitorClipboard");
    };
    const auto it = std::find_if(stats2.cbegin(), stats2.cend(), isMonitorAction);
    QVERIFY2(it != stats2.cend(), stdoutActual);
    QVERIFY2(it->contains("pid="), *it);
}

void CoreTests::statsQObjectLeak()
{
    // Set up items and commands that produce QObjects on selection change:
    // - inMenu commands trigger context menu rebuilds (updateContextMenu)
    // - display command triggers runDisplayCommands() on item widget creation
    // - matchCmd triggers runMenuCommandFilters() on selection change
    RUN("add" << "B" << "A", "");
    const auto script = R"(
        setCommands([
            {name: 'cmd1', inMenu: true, shortcuts: ['Ctrl+F1'], cmd: 'copyq add cmd1'},
            {name: 'cmd2', inMenu: true, globalShortcuts: ['Ctrl+F2'], cmd: 'copyq add cmd2'},
            {name: 'cmd3', inMenu: true, matchCmd: 'copyq: true', cmd: 'copyq add cmd3'},
            {name: 'cmd4', display: true, cmd: 'copyq: setData(mimeHtml, data(mimeText))'},
        ])
        )";
    RUN(script, "");

    // Open item preview.
    KEYS(clipboardBrowserId << "F7");
    RUN("preview", "true\n");

    // Exercise the UI: select items back and forth to trigger menu rebuilds.
    auto exerciseUI = [&]{
        RUN("selectItems(0)", "true\n");
        RUN("selectItems(1)", "true\n");
        RUN("selectItems(0)", "true\n");
        RUN("selectItems(1)", "true\n");
    };

    // Warm up: first pass may allocate lazy-init objects.
    exerciseUI();

    // Capture baseline QObject count.
    QByteArray stdoutActual;
    QByteArray stderrActual;
    QCOMPARE( run(Args("stats"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    const auto baseline = stdoutActual.split('\n').value(0);
    QVERIFY2( baseline.startsWith("TOTAL: "), baseline );
    const int baselineTotal = baseline.mid(7).trimmed().toInt();
    QVERIFY(baselineTotal > 0);

    // Exercise the same UI interactions again.
    exerciseUI();

    // Capture QObject count after second pass.
    QCOMPARE( run(Args("stats"), &stdoutActual, &stderrActual), 0 );
    QVERIFY2( testStderr(stderrActual), stderrActual );
    const auto after = stdoutActual.split('\n').value(0);
    QVERIFY2( after.startsWith("TOTAL: "), after );
    const int afterTotal = after.mid(7).trimmed().toInt();

    QVERIFY2(
        afterTotal <= baselineTotal,
        QStringLiteral("QObject count grew from %1 to %2 — possible leak")
            .arg(baselineTotal).arg(afterTotal).toUtf8()
    );
}

void CoreTests::statsItemPreview()
{
    RUN("add" << "test_item", "");

    const QByteArray previewItemLine =
        "/MainWindow/#dockWidgetItemPreview"
        "/#dockWidgetItemPreviewContents/QScrollArea#ClipboardBrowser"
        "/#item_preview/ItemText#item: 1";

    QByteArray statsOutput;
    auto fetchStats = [&]() {
        QByteArray stderrActual;
        run(Args("stats"), &statsOutput, &stderrActual);
    };
    auto statsContainPreviewItem = [&]() {
        return statsOutput.split('\n').contains(previewItemLine);
    };

    // Preview disabled — no preview item widget in stats.
    RUN("preview", "false\n");
    fetchStats();
    QVERIFY2(!statsContainPreviewItem(), statsOutput);

    // Enable preview — item widget should appear.
    KEYS(clipboardBrowserId << "F7");
    RUN("preview", "true\n");
    fetchStats();
    QVERIFY2(statsContainPreviewItem(), statsOutput);

    // Disable preview — item widget must be gone (not leaked).
    KEYS(clipboardBrowserId << "F7");
    RUN("preview", "false\n");
    fetchStats();
    QVERIFY2(!statsContainPreviewItem(), statsOutput);
}


void CoreTests::chainingCommands()
{
    const auto tab1 = testTab(1);
    RUN("tab" << tab1 << "add" << "C" << "B" << "A", "");
    RUN("tab" << tab1 << "separator" << " " << "read" << "0" << "1" << "2", "A B C");
    RUN("tab" << tab1 << "separator" << "\\t" << "showAt" << "read" << "0" << "1" << "2", "A\tB\tC");

    // Chain functions without arguments.
    RUN("enable" << "disable" << "monitoring", "false\n");
    RUN("if (!monitoring()) enable" << "monitoring", "true\n");

    // Don't treat arguments after "eval" as functions.
    RUN("eval" << "arguments[1]" << "TEST", "TEST");
    RUN("eval" << "arguments[1]" << "--" << "TEST", "TEST");
}

void CoreTests::insertRemoveItems()
{
    const Args args = Args("tab") << testTab(1) << "separator" << ",";

    RUN(args << "add" << "ghi" << "abc", "");
    RUN(args << "insert" << "1" << "def", "");
    RUN(args << "read" << "0" << "1" << "2" << "3" << "4", "abc,def,ghi,,");

    RUN(args << "insert" << "0" << "012", "");
    RUN(args << "read" << "0" << "1" << "2" << "3" << "4", "012,abc,def,ghi,");

    RUN(args << "remove" << "0" << "2", "");
    RUN(args << "read" << "0" << "1" << "2" << "3" << "4", "abc,ghi,,,");

    QByteArray in("ABC");
    QCOMPARE( run(Args(args) << "insert" << "1" << "-", nullptr, nullptr, in), 0);
    RUN(args << "read" << "0" << "1" << "2" << "3" << "4", "abc,ABC,ghi,,");
}

void CoreTests::handleUnexpectedTypes()
{
    RUN("add(version)", "");
    RUN("add([version])", "");
    RUN("add({version: version})", "");
}
