discourse-shared-edits/test/javascripts/helpers/shared-edits-helpers.js
Sam 50d9317272
FEATURE: yjs-based collaborative editing with state recovery and cursor sync
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
2026-02-13 11:34:52 +11:00

176 lines
5.3 KiB
JavaScript

import { click, getContext, visit, waitUntil } from "@ember/test-helpers";
import { publishToMessageBus } from "discourse/tests/helpers/qunit-helpers";
import { resetYjsModuleState } from "discourse/plugins/discourse-shared-edits/discourse/lib/shared-edits/yjs-document";
import { resetProsemirrorExtensionState } from "discourse/plugins/discourse-shared-edits/discourse/lib/shared-edits-prosemirror-extension";
const TEXTAREA_SELECTOR = "#reply-control textarea.d-editor-input";
// Reset shared edits module state - call this from acceptance test hooks
export function resetSharedEditsState(container) {
resetYjsModuleState();
resetProsemirrorExtensionState();
// Also reset the service if we have access to the container
if (container) {
try {
const manager = container.lookup("service:shared-edit-manager");
if (manager && !manager.isDestroying && !manager.isDestroyed) {
manager.resetForTests();
}
} catch {
// Service might not be available - that's fine
}
}
}
// Open shared edit composer for topic 280
export async function openSharedEditComposer() {
// Reset module state before each test to prevent state leakage
// Try to get the container from test context to reset service state
const context = getContext();
const container = context?.owner;
resetSharedEditsState(container);
await visit("/t/internationalization-localization/280");
await click(".show-more-actions");
await click(".show-post-admin-menu");
await click(".admin-toggle-shared-edits");
await click(".shared-edit");
}
// Wait for SharedEditManager service to be ready with doc initialized
export async function waitForSharedEditManager(container) {
const manager = container.lookup("service:shared-edit-manager");
await waitUntil(() => manager.doc && manager.text);
return manager;
}
// Wait for Yjs to be loaded globally
export async function waitForYjs() {
await waitUntil(() => window.Y);
return window.Y;
}
// Create a Yjs update by inserting text into a doc
export function createYjsInsertUpdate(Y, initialContent, insertText, atIndex) {
const doc = new Y.Doc();
const text = doc.getText("post");
if (initialContent) {
text.insert(0, initialContent);
}
const prevState = Y.encodeStateVector(doc);
text.insert(atIndex ?? text.length, insertText);
return Y.encodeStateAsUpdate(doc, prevState);
}
// Create a full Yjs state from content
export function createYjsState(Y, content) {
const doc = new Y.Doc();
const text = doc.getText("post");
text.insert(0, content);
return Y.encodeStateAsUpdate(doc);
}
// Simulate a remote edit via message bus
export async function simulateRemoteEdit(postId, updateBase64, options = {}) {
await publishToMessageBus(`/shared_edits/${postId}`, {
client_id: options.clientId || "remote-client",
user_id: options.userId || 999,
username: options.username || "remote_user",
update: updateBase64,
cursor: options.cursor,
awareness: options.awareness,
});
}
// Simulate a resync action via message bus
export async function simulateResync(postId) {
await publishToMessageBus(`/shared_edits/${postId}`, {
action: "resync",
});
}
// Base64 encoding helpers
export function uint8ArrayToBase64(uint8) {
let binary = "";
uint8.forEach((b) => (binary += String.fromCharCode(b)));
return btoa(binary);
}
export function base64ToUint8Array(str) {
return Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
}
// Base64url encoding (URL-safe)
export function uint8ArrayToBase64url(uint8) {
return uint8ArrayToBase64(uint8)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function base64urlToUint8Array(str) {
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) {
base64 += "=";
}
return base64ToUint8Array(base64);
}
// Get the composer textarea element
export function getTextarea() {
return document.querySelector(TEXTAREA_SELECTOR);
}
// Standard pretender route setup
export function setupSharedEditsPretender(server, helper, options = {}) {
const requestBodies = options.requestBodies || [];
const commitCalls = options.commitCalls || [];
server.put("/shared_edits/p/:id/enable.json", () =>
helper.response({ success: "OK" })
);
server.get("/posts/:id.json", () =>
helper.response({
id: 398,
raw: options.initialRaw || "initial post content",
})
);
server.get("/shared_edits/p/:id", () =>
helper.response({
state: options.initialState || "",
raw: options.initialRaw || "initial post content",
version: options.initialVersion || 1,
message_bus_last_id: options.messageBusLastId || 0,
})
);
server.put("/shared_edits/p/:id", (request) => {
if (options.onPut) {
const result = options.onPut(request);
if (result) {
return result;
}
}
if (request.requestBody) {
const {
parsePostData,
} = require("discourse/tests/helpers/create-pretender");
requestBodies.push(parsePostData(request.requestBody));
}
return helper.response({ success: "OK" });
});
server.put("/shared_edits/p/:id/commit.json", () => {
commitCalls.push(Date.now());
return helper.response({ success: "OK" });
});
server.put("/shared_edits/p/:id/selection", () =>
helper.response({ success: "OK" })
);
return { requestBodies, commitCalls };
}