discourse-shared-edits/test/javascripts/acceptance/shared-edits-lifecycle-test.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

279 lines
8.1 KiB
JavaScript

import { waitUntil } from "@ember/test-helpers";
import { test } from "qunit";
import { parsePostData } from "discourse/tests/helpers/create-pretender";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import {
openSharedEditComposer,
waitForSharedEditManager,
waitForYjs,
} from "../helpers/shared-edits-helpers";
acceptance("Discourse Shared Edits | Lifecycle", function (needs) {
let getRequests;
let putRequests;
let commitCalls;
needs.user();
needs.settings({ shared_edits_enabled: true });
needs.pretender((server, helper) => {
getRequests = [];
putRequests = [];
commitCalls = [];
server.put("/shared_edits/p/:id/enable.json", () =>
helper.response({ success: "OK" })
);
server.get("/posts/:id.json", () =>
helper.response({
id: 398,
raw: "initial post content",
})
);
server.get("/shared_edits/p/:id", (request) => {
getRequests.push(request.url);
return helper.response({
state: "",
raw: "initial post content",
version: 1,
message_bus_last_id: 0,
});
});
server.put("/shared_edits/p/:id", (request) => {
if (request.requestBody) {
putRequests.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" })
);
});
test("subscribe fetches initial state and creates Y.Doc", async function (assert) {
getRequests.length = 0;
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
assert.true(getRequests.length > 0, "GET request was made to fetch state");
assert.true(
getRequests.some((url) => url.includes("/shared_edits/p/")),
"Request was to shared_edits endpoint"
);
assert.true(Boolean(manager.doc), "Y.Doc was created");
assert.true(Boolean(manager.text), "Y.Text was created");
assert.strictEqual(
manager.text.toString(),
"initial post content",
"Y.Text contains initial content"
);
});
test("subscribe with existing state returns cached reply", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
const initialGetCount = getRequests.length;
// Call subscribe again for the same post
const result = await manager.subscribe(398);
assert.strictEqual(
getRequests.length,
initialGetCount,
"No additional GET requests made"
);
assert.true(Boolean(result.reply), "Cached reply was returned");
});
test("subscribe handles empty state and raw content", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
assert.true(Boolean(manager.text), "Y.Text was created");
assert.strictEqual(
manager.text.toString(),
"initial post content",
"Y.Text was initialized from raw content when state is empty"
);
});
test("finalizeSubscription attaches composer observer", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
// Verify the composer observer is attached by checking internal flag
assert.notStrictEqual(
manager._composerObserverAttached,
false,
"Composer observer should be attached after finalization"
);
});
test("commit flushes pending updates before closing", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
putRequests.length = 0;
// Make a local change to create pending updates
await waitForYjs();
manager.doc.transact(() => {
manager.text.insert(manager.text.length, " edited");
}, manager);
// Wait for throttled send
await waitUntil(() => putRequests.length > 0, { timeout: 1000 });
const putCountBeforeCommit = putRequests.length;
// Commit should send any remaining updates
await manager.commit();
assert.true(
putRequests.length >= putCountBeforeCommit,
"PUT requests were made during/before commit"
);
assert.true(commitCalls.length > 0, "Commit endpoint was called");
});
test("commit unsubscribes from message bus", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
await manager.commit();
// After commit, doc should be torn down
assert.strictEqual(manager.doc, null, "Doc is nullified after commit");
assert.strictEqual(manager.text, null, "Text is nullified after commit");
});
test("commit calls /commit endpoint and tears down doc", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
commitCalls.length = 0;
assert.true(Boolean(manager.doc), "Doc exists before commit");
assert.true(Boolean(manager.text), "Text exists before commit");
await manager.commit();
assert.strictEqual(
commitCalls.length,
1,
"Commit endpoint was called once"
);
assert.strictEqual(manager.doc, null, "Doc is nullified after commit");
assert.strictEqual(manager.text, null, "Text is nullified after commit");
assert.strictEqual(
manager.currentPostId,
null,
"currentPostId is cleared after commit"
);
});
test("session state transitions across lifecycle", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
assert.strictEqual(
manager.sessionState,
"active",
"Session is active after subscribe/finalize"
);
await manager.commit();
assert.strictEqual(
manager.sessionState,
"idle",
"Session returns to idle after cleanup"
);
});
test("multiple subscribes to different posts clean up previous", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
assert.strictEqual(manager.currentPostId, 398, "First post ID is set");
assert.true(Boolean(manager.doc), "First doc exists");
// Now close and try subscribing to a different post
// (In practice this would be a different topic, but we simulate)
await manager.commit();
assert.strictEqual(manager.doc, null, "Doc is cleaned up after commit");
assert.strictEqual(manager.currentPostId, null, "Post ID is cleared");
});
});
acceptance("Discourse Shared Edits | Lifecycle with State", function (needs) {
needs.user();
needs.settings({ shared_edits_enabled: true });
needs.pretender((server, helper) => {
server.put("/shared_edits/p/:id/enable.json", () =>
helper.response({ success: "OK" })
);
server.get("/posts/:id.json", () =>
helper.response({
id: 398,
raw: "initial post content",
})
);
// Pre-generated Yjs state for "content from server state"
// Generated via: DiscourseSharedEdits::Yjs.state_from_text('content from server state')[:state]
const serverState = "AQGcAQAEAQRwb3N0GWNvbnRlbnQgZnJvbSBzZXJ2ZXIgc3RhdGUA";
server.get("/shared_edits/p/:id", () =>
helper.response({
state: serverState,
raw: "initial post content",
version: 2,
message_bus_last_id: 5,
})
);
server.put("/shared_edits/p/:id", () => helper.response({ success: "OK" }));
server.put("/shared_edits/p/:id/commit.json", () =>
helper.response({ success: "OK" })
);
server.put("/shared_edits/p/:id/selection", () =>
helper.response({ success: "OK" })
);
});
test("subscribe applies server state when provided", async function (assert) {
await openSharedEditComposer();
const manager = await waitForSharedEditManager(this.container);
assert.strictEqual(
manager.text.toString(),
"content from server state",
"Y.Text reflects server state, not raw content"
);
});
});