diff --git a/src/calibre/utils/rapydscript.py b/src/calibre/utils/rapydscript.py
index 05bd9e3782..e90fb20673 100644
--- a/src/calibre/utils/rapydscript.py
+++ b/src/calibre/utils/rapydscript.py
@@ -329,6 +329,59 @@ def atomic_write(base, name, content):
atomic_rename(tname, name)
+def run_rapydscript_tests():
+ from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript
+ from PyQt5.Qt import QApplication, QEventLoop
+ from calibre.gui2.webengine import secure_webengine
+ from calibre.gui2 import must_use_qt
+ must_use_qt()
+ base = base_dir()
+ rapydscript_dir = os.path.join(base, 'src', 'pyj')
+ fname = os.path.join(rapydscript_dir, 'test.pyj')
+ with lopen(fname, 'rb') as f:
+ js = compile_fast(f.read(), fname)
+
+ def create_script(src, name):
+ s = QWebEngineScript()
+ s.setName(name)
+ s.setInjectionPoint(QWebEngineScript.DocumentReady)
+ s.setWorldId(QWebEngineScript.ApplicationWorld)
+ s.setRunsOnSubFrames(False)
+ s.setSourceCode(src)
+ return s
+
+ class Tester(QWebEnginePage):
+
+ def __init__(self):
+ QWebEnginePage.__init__(self)
+ self.titleChanged.connect(self.title_changed)
+ secure_webengine(self)
+ self.scripts().insert(create_script(js, 'test-rapydscript.js'))
+ self.setHtml('
initialize')
+ self.working = True
+
+ def title_changed(self, title):
+ if title == 'initialized':
+ self.titleChanged.disconnect()
+ self.runJavaScript('window.main()', QWebEngineScript.ApplicationWorld, self.callback)
+
+ def spin_loop(self):
+ while self.working:
+ QApplication.instance().processEvents(QEventLoop.ExcludeUserInputEvents)
+ return self.result
+
+ def callback(self, result):
+ self.result = result
+ self.working = False
+
+ def javaScriptConsoleMessage(self, level, msg, line_num, source_id):
+ print(msg, file=sys.stderr if level > 0 else sys.stdout)
+
+ tester = Tester()
+ result = tester.spin_loop()
+ raise SystemExit(int(result))
+
+
def compile_editor():
base = base_dir()
rapydscript_dir = os.path.join(base, 'src', 'pyj')
diff --git a/src/pyj/test.pyj b/src/pyj/test.pyj
new file mode 100644
index 0000000000..aea7d9f841
--- /dev/null
+++ b/src/pyj/test.pyj
@@ -0,0 +1,47 @@
+# vim:fileencoding=utf-8
+# License: GPL v3 Copyright: 2020, Kovid Goyal
+from __python__ import bound_methods, hash_literals
+
+import traceback
+
+from testing import registered_tests, reset_dom
+
+
+def get_matching_tests_for_name(name):
+ ans = []
+ for k in Object.keys(registered_tests):
+ q = k.split('.')[-1]
+ if not name or q is name:
+ ans.append(registered_tests[k])
+ return ans
+
+
+def run_tests(tests):
+ failed_tests = []
+ count = 0
+ for f in tests:
+ print(f.test_name, '...')
+ reset_dom()
+ try:
+ f()
+ count += 1
+ except:
+ traceback.print_stack()
+ failed_tests.append((f.test_name, traceback.format_stack()))
+ return failed_tests, count
+
+
+def main():
+ tests = get_matching_tests_for_name()
+ st = window.performance.now()
+ failed_tests, total = run_tests(tests)
+ time = window.performance.now() - st
+ if failed_tests.length:
+ console.error(f'{failed_tests.length} out of {total} failed in {time:.1f} seconds')
+ else:
+ print(f'Ran {total} tests in {time:.1f} seconds')
+ return 1 if failed_tests.length else 0
+
+
+window.main = main
+document.title = 'initialized'
diff --git a/src/pyj/testing.pyj b/src/pyj/testing.pyj
new file mode 100644
index 0000000000..ffdc037158
--- /dev/null
+++ b/src/pyj/testing.pyj
@@ -0,0 +1,78 @@
+# vim:fileencoding=utf-8
+# License: GPL v3 Copyright: 2020, Kovid Goyal
+from __python__ import bound_methods, hash_literals
+
+from dom import clear
+
+def raise_fail(preamble, msg):
+ if msg:
+ msg = '. ' + msg
+ else:
+ msg = ''
+ raise AssertionError(preamble + msg)
+
+
+def assert_equal(a, b, msg):
+
+ def fail():
+ raise_fail(f'{a} != {b}', msg)
+
+ atype = jstype(a)
+ btype = jstype(b)
+ base_types = {'number': True, 'boolean': True, 'string': True, 'undefined': True}
+ if base_types[a] or base_types[b] or a is None or b is None:
+ if a is not b:
+ fail()
+ return
+ if a.__eq__:
+ if not a.__eq__(b):
+ fail()
+ return
+ if b.__eq__:
+ if not b.__eq__(a):
+ fail()
+ return
+ if a.length? or b.length?:
+ if a.length is not b.length:
+ fail()
+ for i in range(a.length):
+ assert_equal(a[i], b[i])
+ return
+ if atype is 'object':
+ for key in Object.keys(a):
+ assert_equal(a[key], b[key])
+ if btype is 'object':
+ for key in Object.keys(b):
+ assert_equal(a[key], b[key])
+
+ if a is not b:
+ fail()
+
+
+def assert_true(x, msg):
+ if not x:
+ raise_fail(f'{x} is not truthy', msg)
+
+
+def assert_fale(x, msg):
+ if x:
+ raise_fail(f'{x} is truthy', msg)
+
+
+def reset_dom():
+ html = document.documentElement
+ clear(html)
+ head = document.createElement('head')
+ body = document.createElement('body')
+ html.appendChild(head)
+ html.appendChild(body)
+
+
+registered_tests = {}
+
+
+def test(f):
+ mod = f.__module__ or 'unknown_test_module'
+ f.test_name = mod + '.' + f.name
+ registered_tests[f.test_name] = f
+ return f