From 3e8d3388f6bf7b178bdfef5eb6258171af5494bf Mon Sep 17 00:00:00 2001 From: gsb Date: Wed, 29 Apr 2026 18:04:20 +0000 Subject: [PATCH] Add Selenium integration test framework --- examples/flask-collab/server.py | 2 +- examples/flask-collab/static/ribbit | 1 + examples/flask-collab/templates/index.html | 10 + jest.config.js | 1 + package-lock.json | 279 ++++++++++++++++++++ package.json | 2 + src/ts/ribbit-editor.ts | 1 + src/ts/ribbit.ts | 16 +- src/ts/toolbar.ts | 2 + test/integration/index.html | 46 ++++ test/integration/server.js | 60 +++++ test/integration/test.js | 290 +++++++++++++++++++++ 12 files changed, 703 insertions(+), 7 deletions(-) create mode 120000 examples/flask-collab/static/ribbit create mode 100644 test/integration/index.html create mode 100644 test/integration/server.js create mode 100644 test/integration/test.js diff --git a/examples/flask-collab/server.py b/examples/flask-collab/server.py index cda9536..dc7ca0d 100644 --- a/examples/flask-collab/server.py +++ b/examples/flask-collab/server.py @@ -157,4 +157,4 @@ def broadcast_peers(): if __name__ == "__main__": - app.run(debug=True, port=5000) + app.run(debug=True, host="0.0.0.0", port=5000) diff --git a/examples/flask-collab/static/ribbit b/examples/flask-collab/static/ribbit new file mode 120000 index 0000000..7ec67e7 --- /dev/null +++ b/examples/flask-collab/static/ribbit @@ -0,0 +1 @@ +/tmp/ribbit/dist/ribbit \ No newline at end of file diff --git a/examples/flask-collab/templates/index.html b/examples/flask-collab/templates/index.html index eb7ac23..a69275a 100644 --- a/examples/flask-collab/templates/index.html +++ b/examples/flask-collab/templates/index.html @@ -11,6 +11,16 @@ #status { font-size: 12px; color: #666; margin-bottom: 10px; } #revisions { margin-top: 20px; } #revisions button { margin: 2px; } + #ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; } + .ribbit-toolbar { background: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px; } + .ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px; align-items: center; } + .ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; } + .ribbit-toolbar button:hover { background: #e8e8e8; } + .ribbit-toolbar button.active { background: #d0d0ff; border-color: #99f; } + .ribbit-toolbar button.disabled { opacity: 0.3; cursor: default; } + .ribbit-toolbar .spacer { width: 12px; } + .ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; border-radius: 4px; padding: 4px; z-index: 10; } + .ribbit-dropdown button { display: block; width: 100%; text-align: left; margin: 1px 0; } diff --git a/jest.config.js b/jest.config.js index a8f7fcf..7eec04f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['/test'], + testPathIgnorePatterns: ['/node_modules/', '/test/integration/'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, diff --git a/package-lock.json b/package-lock.json index 3af59c6..b3407dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "esbuild": "^0.28.0", "happy-dom": "^14.12.3", "jest": "^29.7.0", + "selenium-webdriver": "^4.43.0", "ts-jest": "^29.4.9", "typescript": "^6.0.3" } @@ -472,6 +473,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "dev": true + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -1795,6 +1802,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2268,6 +2281,12 @@ "node": ">=10.17.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -2373,6 +2392,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3073,6 +3098,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3091,6 +3128,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3341,6 +3387,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -3457,6 +3509,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3492,6 +3550,21 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3552,6 +3625,37 @@ "node": ">=10" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/selenium-webdriver": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz", + "integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "dependencies": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.5", + "ws": "^8.20.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3561,6 +3665,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3640,6 +3750,15 @@ "node": ">=10" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -3747,6 +3866,15 @@ "node": ">=8" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -3924,6 +4052,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -4022,6 +4156,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -4403,6 +4558,12 @@ "@babel/helper-validator-identifier": "^7.28.5" } }, + "@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "dev": true + }, "@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -5305,6 +5466,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, "create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -5640,6 +5807,12 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true + }, "import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -5711,6 +5884,12 @@ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6244,6 +6423,18 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6256,6 +6447,15 @@ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "requires": { + "immediate": "~3.0.5" + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6453,6 +6653,12 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6535,6 +6741,12 @@ } } }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6557,6 +6769,21 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6596,12 +6823,36 @@ "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", "dev": true }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "selenium-webdriver": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz", + "integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==", + "dev": true, + "requires": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.5", + "ws": "^8.20.0" + } + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6666,6 +6917,15 @@ "escape-string-regexp": "^2.0.0" } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6740,6 +7000,12 @@ "minimatch": "^3.0.4" } }, + "tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6827,6 +7093,12 @@ "picocolors": "^1.1.1" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -6901,6 +7173,13 @@ "signal-exit": "^3.0.7" } }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "requires": {} + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3a5fa27..0949f55 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js", "build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/", "test": "npm run build && jest --verbose", + "test:integration": "npm run build && node test/integration/test.js", "test:coverage": "npm run build && jest --coverage" }, "license": "MIT", @@ -24,6 +25,7 @@ "esbuild": "^0.28.0", "happy-dom": "^14.12.3", "jest": "^29.7.0", + "selenium-webdriver": "^4.43.0", "ts-jest": "^29.4.9", "typescript": "^6.0.3" } diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 83fbbfa..b3b293c 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -52,6 +52,7 @@ export class RibbitEditor extends Ribbit { this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); } this.view(); + this.emitReady(); } #bindEvents(): void { diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts index db29114..c4b9211 100644 --- a/src/ts/ribbit.ts +++ b/src/ts/ribbit.ts @@ -146,12 +146,7 @@ export class Ribbit { this.emitter.off(event, callback); } - run(): void { - this.element.classList.add('loaded'); - if (this.autoToolbar) { - this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); - } - this.view(); + protected emitReady(): void { this.emitter.emit('ready', { markdown: this.getMarkdown(), html: this.getHTML(), @@ -160,6 +155,15 @@ export class Ribbit { }); } + run(): void { + this.element.classList.add('loaded'); + if (this.autoToolbar) { + this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); + } + this.view(); + this.emitReady(); + } + getState(): string | null { return this.state; } diff --git a/src/ts/toolbar.ts b/src/ts/toolbar.ts index 8d4bfdf..64fbbfe 100644 --- a/src/ts/toolbar.ts +++ b/src/ts/toolbar.ts @@ -281,6 +281,7 @@ export class ToolbarManager { const li = document.createElement('li'); const btn = document.createElement('button'); btn.className = `ribbit-btn-${button.id}`; + btn.textContent = button.label; btn.setAttribute('aria-label', button.label); btn.title = button.shortcut ? `${button.label} (${button.shortcut})` @@ -298,6 +299,7 @@ export class ToolbarManager { const li = document.createElement('li'); const toggle = document.createElement('button'); toggle.className = 'ribbit-btn-group'; + toggle.textContent = group.label + ' ▾'; toggle.setAttribute('aria-label', group.label); toggle.title = group.label; diff --git a/test/integration/index.html b/test/integration/index.html new file mode 100644 index 0000000..29f9769 --- /dev/null +++ b/test/integration/index.html @@ -0,0 +1,46 @@ + + + + + Ribbit Integration Test Page + + + + +
**bold** and *italic* and `code` + +## Heading + +- list item 1 +- list item 2 + +> a blockquote + +| A | B | +|---|---| +| 1 | 2 | +
+ + + + + diff --git a/test/integration/server.js b/test/integration/server.js new file mode 100644 index 0000000..fb55980 --- /dev/null +++ b/test/integration/server.js @@ -0,0 +1,60 @@ +/** + * Minimal static file server for e2e tests. + * Serves the test page and ribbit dist files. + */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.map': 'application/json', +}; + +function createServer(port = 9999) { + const distDir = path.join(__dirname, '..', '..', 'dist', 'ribbit'); + const testDir = __dirname; + + const server = http.createServer((req, res) => { + let filePath; + if (req.url === '/' || req.url === '/index.html') { + filePath = path.join(testDir, 'index.html'); + } else if (req.url.startsWith('/ribbit/')) { + filePath = path.join(distDir, req.url.replace('/ribbit/', '')); + } else { + res.writeHead(404); + res.end('Not found'); + return; + } + + const ext = path.extname(filePath); + const mime = MIME[ext] || 'application/octet-stream'; + + try { + const content = fs.readFileSync(filePath); + res.writeHead(200, { 'Content-Type': mime }); + res.end(content); + } catch { + res.writeHead(404); + res.end('Not found'); + } + }); + + return { + start() { + return new Promise((resolve) => { + server.listen(port, () => resolve()); + }); + }, + stop() { + return new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + url: `http://localhost:${port}`, + }; +} + +module.exports = { createServer }; diff --git a/test/integration/test.js b/test/integration/test.js new file mode 100644 index 0000000..e70abbe --- /dev/null +++ b/test/integration/test.js @@ -0,0 +1,290 @@ +/** + * Integration tests for the ribbit editor using Selenium + Firefox. + * + * Run: npm run test:e2e + */ +const { Builder, By, Key, until } = require('selenium-webdriver'); +const firefox = require('selenium-webdriver/firefox'); +const { createServer } = require('./server'); + +let server; +let driver; + +async function setup() { + server = createServer(9999); + await server.start(); + + const options = new firefox.Options().addArguments('--headless'); + driver = await new Builder() + .forBrowser('firefox') + .setFirefoxOptions(options) + .build(); + + await driver.get(server.url); + // Wait for ribbit to initialize + await driver.wait(async () => { + return driver.executeScript('return window.__ribbitReady === true'); + }, 10000).catch(async () => { + const logs = await driver.manage().logs().get('browser').catch(() => []); + console.log('Browser logs:', logs.map(l => l.message)); + const ready = await driver.executeScript('return { ready: window.__ribbitReady, ribbit: typeof window.ribbit, editor: typeof window.__ribbitEditor }'); + console.log('State:', ready); + throw new Error('Editor did not become ready'); + }); +} + +async function teardown() { + if (driver) await driver.quit(); + if (server) await server.stop(); +} + +// Test helpers +async function getEditorHTML() { + return driver.executeScript('return document.getElementById("ribbit").innerHTML'); +} + +async function getEditorText() { + return driver.executeScript('return document.getElementById("ribbit").textContent'); +} + +async function getState() { + return driver.executeScript('return window.__ribbitEditor.getState()'); +} + +async function clickButton(label) { + const buttons = await driver.findElements(By.css('.ribbit-toolbar button')); + for (const btn of buttons) { + const text = await btn.getText(); + if (text === label) { + await btn.click(); + return; + } + } + throw new Error(`Button "${label}" not found`); +} + +async function clickEditor() { + const editor = await driver.findElement(By.id('ribbit')); + await editor.click(); +} + +// Test runner +let passed = 0; +let failed = 0; +const errors = []; + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + errors.push(name); + console.log(` ✗ ${name}`); + console.log(` ${e.message}`); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +// Tests +async function runTests() { + console.log('\nRibbit Integration Tests\n'); + + await test('page loads', async () => { + const title = await driver.getTitle(); + assert(title === 'Ribbit Integration Test Page', `Title: ${title}`); + }); + + await test('editor renders in view mode', async () => { + const state = await getState(); + assert(state === 'view', `State: ${state}`); + }); + + await test('editor renders markdown as HTML', async () => { + const html = await getEditorHTML(); + assert(html.includes('bold'), 'Missing bold'); + assert(html.includes('italic'), 'Missing italic'); + assert(html.includes('code'), 'Missing code'); + }); + + await test('editor renders headings', async () => { + const html = await getEditorHTML(); + assert(html.includes(' { + const html = await getEditorHTML(); + assert(html.includes('
    '), 'Missing ul'); + assert(html.includes('
  • '), 'Missing li'); + }); + + await test('editor renders tables', async () => { + const html = await getEditorHTML(); + assert(html.includes(''), 'Missing table'); + }); + + await test('editor renders blockquotes', async () => { + const html = await getEditorHTML(); + assert(html.includes('
    '), 'Missing blockquote'); + }); + + await test('toolbar is rendered', async () => { + const toolbar = await driver.findElements(By.css('.ribbit-toolbar')); + assert(toolbar.length > 0, 'No toolbar found'); + }); + + await test('toolbar has buttons with labels', async () => { + const buttons = await driver.findElements(By.css('.ribbit-toolbar button')); + assert(buttons.length > 5, `Only ${buttons.length} buttons`); + const text = await buttons[0].getText(); + assert(text.length > 0, 'Button has no label'); + }); + + await test('toggle button switches to wysiwyg', async () => { + await clickButton('Edit'); + const state = await getState(); + assert(state === 'wysiwyg', `State: ${state}`); + }); + + await test('editor is contentEditable in wysiwyg', async () => { + const editable = await driver.executeScript( + 'return document.getElementById("ribbit").contentEditable' + ); + assert(editable === 'true', `contentEditable: ${editable}`); + }); + + await test('can type in wysiwyg mode', async () => { + await clickEditor(); + // Move to end and type + await driver.actions().keyDown(Key.CONTROL).sendKeys(Key.END).keyUp(Key.CONTROL).perform(); + await driver.actions().sendKeys('\nhello from selenium').perform(); + const text = await getEditorText(); + assert(text.includes('hello from selenium'), 'Typed text not found'); + }); + + await test('source button switches to edit mode', async () => { + await clickButton('Source'); + const state = await getState(); + assert(state === 'edit', `State: ${state}`); + }); + + await test('edit mode shows raw markdown', async () => { + const text = await getEditorText(); + assert(text.includes('**bold**'), 'Missing raw markdown'); + }); + + await test('toggle back to view mode', async () => { + await clickButton('Edit'); + const state = await getState(); + assert(state === 'view', `State: ${state}`); + }); + + await test('view mode renders HTML again', async () => { + const html = await getEditorHTML(); + assert(html.includes('bold'), 'Not rendered as HTML'); + }); + + await test('save button fires save event', async () => { + await driver.executeScript('window.__saved = false; window.__ribbitEditor.on("save", () => { window.__saved = true; })'); + await clickButton('Edit'); + await clickButton('Save'); + const saved = await driver.executeScript('return window.__saved'); + assert(saved === true, 'Save event not fired'); + }); + + await test('enter key creates new line in wysiwyg', async () => { + await driver.executeScript('window.__ribbitEditor.wysiwyg()'); + await clickEditor(); + // Clear and type two lines + await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform(); + await driver.actions().sendKeys(Key.DELETE).perform(); + await driver.actions().sendKeys('line one').perform(); + await driver.actions().sendKeys(Key.ENTER).perform(); + await driver.actions().sendKeys('line two').perform(); + const text = await getEditorText(); + assert(text.includes('line one'), `Missing "line one" in: ${text}`); + assert(text.includes('line two'), `Missing "line two" in: ${text}`); + // Check that they're on separate lines (not concatenated) + const html = await getEditorHTML(); + const hasBreak = html.includes(' { + // Get the markdown from the content typed above + const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()'); + assert(md.includes('line one'), `Missing "line one" in markdown: ${md}`); + assert(md.includes('line two'), `Missing "line two" in markdown: ${md}`); + // Lines should be separate (not on same line) + const lines = md.split('\n').filter(l => l.trim()); + const hasLineOne = lines.some(l => l.includes('line one')); + const hasLineTwo = lines.some(l => l.includes('line two')); + assert(hasLineOne, `"line one" not on its own line in: ${md}`); + assert(hasLineTwo, `"line two" not on its own line in: ${md}`); + }); + + await test('multiple enters create blank lines in wysiwyg', async () => { + await driver.executeScript('window.__ribbitEditor.wysiwyg()'); + await clickEditor(); + await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform(); + await driver.actions().sendKeys(Key.DELETE).perform(); + await driver.actions().sendKeys('para one').perform(); + await driver.actions().sendKeys(Key.ENTER, Key.ENTER).perform(); + await driver.actions().sendKeys('para two').perform(); + const text = await getEditorText(); + assert(text.includes('para one'), `Missing "para one" in: ${text}`); + assert(text.includes('para two'), `Missing "para two" in: ${text}`); + }); + + await test('enter after heading in wysiwyg', async () => { + await driver.executeScript('window.__ribbitEditor.wysiwyg()'); + await clickEditor(); + await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform(); + await driver.actions().sendKeys(Key.DELETE).perform(); + await driver.actions().sendKeys('## My Heading').perform(); + await driver.actions().sendKeys(Key.ENTER).perform(); + await driver.actions().sendKeys('paragraph text').perform(); + const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()'); + assert(md.includes('Heading') || md.includes('heading'), `Missing heading in: ${md}`); + assert(md.includes('paragraph'), `Missing paragraph in: ${md}`); + }); + + await test('Ctrl+B shortcut works in wysiwyg', async () => { + // Switch to wysiwyg + await driver.executeScript('window.__ribbitEditor.wysiwyg()'); + await clickEditor(); + // Type and select + await driver.actions().sendKeys('test text').perform(); + await driver.actions() + .keyDown(Key.SHIFT) + .sendKeys(Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT) + .keyUp(Key.SHIFT) + .perform(); + // Ctrl+B + await driver.actions().keyDown(Key.CONTROL).sendKeys('b').keyUp(Key.CONTROL).perform(); + const html = await getEditorHTML(); + assert(html.includes('**'), 'Bold delimiter not inserted'); + }); +} + +(async () => { + try { + await setup(); + await runTests(); + } catch (e) { + console.error('Setup failed:', e.message); + failed++; + } finally { + console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`); + if (errors.length) { + console.log('\nFailed:'); + errors.forEach(e => console.log(` • ${e}`)); + } + await teardown(); + process.exit(failed > 0 ? 1 : 0); + } +})();