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);
+ }
+})();