mirror of
https://ghfast.top/https://github.com/discourse/discourse-shared-edits.git
synced 2026-07-15 11:17:01 +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
279 lines
8.1 KiB
JavaScript
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"
|
|
);
|
|
});
|
|
});
|