mirror of
https://ghfast.top/https://github.com/discourse/discourse-shared-edits.git
synced 2026-05-12 19:42:28 +08:00
Major overhaul of the shared edits plugin to improve reliability, robustness, and developer experience: **Backend** - Extract Revise service for cleaner controller orchestration - Add StateValidator for base64/Yjs safety, corruption detection, and recovery - Centralize protocol constants (Ruby + JS) to avoid hardcoded strings - Add state hash sync verification and vector validation endpoints - Harden security (guardian checks), error handling, and resource cleanup - Resize shared edit columns migration; add state_hash column **Frontend** - Decompose shared-edit-manager into focused modules: yjs-document, markdown-sync, rich-mode-sync, network-manager, encoding-utils - Add cursor overlay and caret coordinate tracking for selection sharing - Add ProseMirror extension for rich-mode collaborative editing - Cache-busted Yjs bundle loading via hashed filenames - Fix scroll drift during sync **Testing & Tooling** - Extensive new specs: state_validator, revision_controller, model, revise service - New Ember acceptance tests: cursor, lifecycle, sync flows - Add support scripts: fake_writer (Playwright), state_corruptor, debug_recovery - Add support/lint wrapper for full CI lint suite - Update dependencies and rebuild Yjs/y-prosemirror bundles
246 lines
7.1 KiB
JavaScript
246 lines
7.1 KiB
JavaScript
import { setupTest } from "ember-qunit";
|
|
import { module, test } from "qunit";
|
|
import CursorOverlay from "discourse/plugins/discourse-shared-edits/discourse/lib/cursor-overlay";
|
|
|
|
module("Unit | Lib | cursor-overlay", function (hooks) {
|
|
setupTest(hooks);
|
|
|
|
let textarea;
|
|
let overlay;
|
|
|
|
hooks.beforeEach(function () {
|
|
// Create a mock textarea
|
|
textarea = document.createElement("textarea");
|
|
textarea.style.width = "400px";
|
|
textarea.style.height = "200px";
|
|
textarea.style.fontFamily = "monospace";
|
|
textarea.style.fontSize = "14px";
|
|
textarea.value = "Hello world\nSecond line\nThird line";
|
|
|
|
// Create a parent container with relative positioning
|
|
const container = document.createElement("div");
|
|
container.style.position = "relative";
|
|
container.appendChild(textarea);
|
|
document.body.appendChild(container);
|
|
|
|
overlay = new CursorOverlay(textarea);
|
|
});
|
|
|
|
hooks.afterEach(function () {
|
|
if (overlay) {
|
|
overlay.destroy();
|
|
}
|
|
if (textarea && textarea.parentElement) {
|
|
textarea.parentElement.remove();
|
|
}
|
|
});
|
|
|
|
test("creates cursor element with correct structure", function (assert) {
|
|
const cursor = overlay.createCursorElement({
|
|
user_id: 123,
|
|
username: "testuser",
|
|
});
|
|
|
|
assert.true(Boolean(cursor.element), "Cursor element was created");
|
|
assert.true(
|
|
cursor.element.classList.contains("shared-edits-cursor"),
|
|
"Element has correct class"
|
|
);
|
|
assert.true(Boolean(cursor.label), "Label was created");
|
|
assert.true(
|
|
cursor.label.classList.contains("shared-edits-cursor__label"),
|
|
"Label has correct class"
|
|
);
|
|
assert.strictEqual(
|
|
cursor.label.textContent,
|
|
"testuser",
|
|
"Label shows username"
|
|
);
|
|
assert.strictEqual(cursor.user.username, "testuser", "User data is stored");
|
|
assert.strictEqual(cursor.user.user_id, 123, "User ID is stored");
|
|
});
|
|
|
|
test("getColor returns CSS variable based on user id", function (assert) {
|
|
// Test cycling through 7 colors
|
|
assert.strictEqual(
|
|
overlay.getColor(0),
|
|
"var(--shared-edit-color-1)",
|
|
"User ID 0 gets color 1"
|
|
);
|
|
assert.strictEqual(
|
|
overlay.getColor(1),
|
|
"var(--shared-edit-color-2)",
|
|
"User ID 1 gets color 2"
|
|
);
|
|
assert.strictEqual(
|
|
overlay.getColor(6),
|
|
"var(--shared-edit-color-7)",
|
|
"User ID 6 gets color 7"
|
|
);
|
|
assert.strictEqual(
|
|
overlay.getColor(7),
|
|
"var(--shared-edit-color-1)",
|
|
"User ID 7 wraps to color 1"
|
|
);
|
|
assert.strictEqual(
|
|
overlay.getColor(14),
|
|
"var(--shared-edit-color-1)",
|
|
"User ID 14 wraps to color 1"
|
|
);
|
|
|
|
// Test null/undefined handling
|
|
assert.strictEqual(
|
|
overlay.getColor(null),
|
|
"var(--shared-edit-color-1)",
|
|
"Null user ID defaults to color 1"
|
|
);
|
|
assert.strictEqual(
|
|
overlay.getColor(undefined),
|
|
"var(--shared-edit-color-1)",
|
|
"Undefined user ID defaults to color 1"
|
|
);
|
|
});
|
|
|
|
test("removeCursor cleans up DOM and Map entry", function (assert) {
|
|
// Create a cursor manually
|
|
const cursor = overlay.createCursorElement({
|
|
user_id: 456,
|
|
username: "toremove",
|
|
});
|
|
overlay.cursors.set("client-to-remove", cursor);
|
|
overlay.container.appendChild(cursor.element);
|
|
|
|
assert.true(
|
|
overlay.cursors.has("client-to-remove"),
|
|
"Cursor exists in Map before removal"
|
|
);
|
|
assert.true(
|
|
overlay.container.contains(cursor.element),
|
|
"Element is in DOM before removal"
|
|
);
|
|
|
|
overlay.removeCursor("client-to-remove");
|
|
|
|
assert.false(
|
|
overlay.cursors.has("client-to-remove"),
|
|
"Cursor is removed from Map"
|
|
);
|
|
assert.false(
|
|
overlay.container.contains(cursor.element),
|
|
"Element is removed from DOM"
|
|
);
|
|
});
|
|
|
|
test("destroy removes all cursors and event listeners", function (assert) {
|
|
// Add some cursors
|
|
const cursor1 = overlay.createCursorElement({
|
|
user_id: 1,
|
|
username: "user1",
|
|
});
|
|
const cursor2 = overlay.createCursorElement({
|
|
user_id: 2,
|
|
username: "user2",
|
|
});
|
|
overlay.cursors.set("client1", cursor1);
|
|
overlay.cursors.set("client2", cursor2);
|
|
overlay.container.appendChild(cursor1.element);
|
|
overlay.container.appendChild(cursor2.element);
|
|
|
|
// Track typists
|
|
overlay.activeTypists.set("client1", { lastTyped: Date.now() });
|
|
overlay.activeTypists.set("client2", { lastTyped: Date.now() });
|
|
|
|
assert.strictEqual(overlay.cursors.size, 2, "Has 2 cursors before destroy");
|
|
assert.strictEqual(
|
|
overlay.activeTypists.size,
|
|
2,
|
|
"Has 2 typists before destroy"
|
|
);
|
|
|
|
const containerParent = overlay.container.parentElement;
|
|
|
|
overlay.destroy();
|
|
|
|
assert.strictEqual(overlay.cursors.size, 0, "Cursors Map is cleared");
|
|
assert.strictEqual(
|
|
overlay.activeTypists.size,
|
|
0,
|
|
"ActiveTypists Map is cleared"
|
|
);
|
|
assert.false(
|
|
containerParent.contains(overlay.container),
|
|
"Container is removed from DOM"
|
|
);
|
|
});
|
|
|
|
test("removeCursor also clears activeTypists entry and timeout", function (assert) {
|
|
const cursor = overlay.createCursorElement({
|
|
user_id: 456,
|
|
username: "toremove",
|
|
});
|
|
overlay.cursors.set("client-to-remove", cursor);
|
|
overlay.container.appendChild(cursor.element);
|
|
overlay.markTypist("client-to-remove");
|
|
|
|
assert.true(
|
|
overlay.activeTypists.has("client-to-remove"),
|
|
"Typist entry exists before removal"
|
|
);
|
|
|
|
overlay.removeCursor("client-to-remove");
|
|
|
|
assert.false(
|
|
overlay.cursors.has("client-to-remove"),
|
|
"Cursor is removed from Map"
|
|
);
|
|
assert.false(
|
|
overlay.activeTypists.has("client-to-remove"),
|
|
"Typist entry is removed from Map"
|
|
);
|
|
});
|
|
|
|
test("destroy cancels pending typist timeouts", function (assert) {
|
|
const cursor1 = overlay.createCursorElement({
|
|
user_id: 1,
|
|
username: "user1",
|
|
});
|
|
overlay.cursors.set("client1", cursor1);
|
|
overlay.container.appendChild(cursor1.element);
|
|
overlay.markTypist("client1");
|
|
|
|
const typist = overlay.activeTypists.get("client1");
|
|
assert.true(Boolean(typist.timeout), "Timeout exists before destroy");
|
|
|
|
overlay.destroy();
|
|
|
|
assert.strictEqual(overlay.activeTypists.size, 0, "ActiveTypists cleared");
|
|
assert.strictEqual(overlay.cursors.size, 0, "Cursors cleared");
|
|
});
|
|
|
|
test("markTypist sets timeout for cursor hiding", async function (assert) {
|
|
const cursor = overlay.createCursorElement({
|
|
user_id: 789,
|
|
username: "typist",
|
|
});
|
|
overlay.cursors.set("typist-client", cursor);
|
|
overlay.container.appendChild(cursor.element);
|
|
|
|
// Initially mark as typing
|
|
overlay.markTypist("typist-client");
|
|
|
|
const typist = overlay.activeTypists.get("typist-client");
|
|
assert.true(Boolean(typist), "Typist entry was created");
|
|
assert.true(Boolean(typist.lastTyped), "lastTyped timestamp is set");
|
|
assert.true(Boolean(typist.timeout), "Timeout is set");
|
|
|
|
// Verify timeout was set (we won't wait 5 seconds, just check it exists)
|
|
assert.strictEqual(
|
|
typeof typist.timeout,
|
|
"number",
|
|
"Timeout ID is a number"
|
|
);
|
|
|
|
// Clear the timeout to avoid test pollution
|
|
clearTimeout(typist.timeout);
|
|
});
|
|
});
|