discourse-shared-edits/support/fake_writer
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

289 lines
8 KiB
Ruby
Executable file

#!/usr/bin/env ruby
# frozen_string_literal: true
# rubocop:disable Discourse/Plugins/NamespaceConstants, Discourse/Plugins/NamespaceMethods
# Browser-based shared edit simulator for testing Yjs operational transformation
#
# Usage: fake_writer POST_ID [OPTIONS]
# or fake_writer TOPIC_ID/POST_NUMBER [OPTIONS]
#
# Options:
# --help, -h Show this help message and exit
# --speed=slow|normal|fast Typing speed (default: normal)
# --headless=true|false Run headless browser (default: true)
# --user=USERNAME Login username (default: admin)
# --base-url=URL Discourse URL (default: http://localhost:4200)
# --post-id=ID Post ID (alternative to positional argument)
# --topic-post=TOPIC/POST Topic ID and post number (alternative to positional)
#
# The shared-edits plugin throttles syncs every 350ms during continuous typing.
rails_root = File.expand_path("../../../..", __FILE__)
ENV["RAILS_ENV"] ||= "development"
# rubocop:disable Discourse/NoChdir
Dir.chdir(rails_root)
require File.expand_path("config/environment", rails_root)
require "playwright"
PHRASES = [
"The quick brown fox jumps over the lazy dog. ",
"Hello world! This is a test. ",
"Testing shared edits functionality. ",
"Multiple users can edit simultaneously. ",
"Real-time synchronization in action! ",
"Lorem ipsum dolor sit amet. ",
].freeze
# Speed configs
SPEEDS = {
"slow" => {
char_ms: 2000..4000,
phrase_ms: 10_000..15_000,
},
"normal" => {
char_ms: 30..80,
phrase_ms: 200..400,
},
"fast" => {
char_ms: 10..30,
phrase_ms: 50..150,
},
}.freeze
def show_help
puts <<~HELP
Fake Writer - Shared Edit Simulator
Browser-based shared edit simulator for testing Yjs operational transformation.
Simulates a user typing continuously in a shared edit session.
Usage:
fake_writer POST_ID [OPTIONS]
fake_writer TOPIC_ID/POST_NUMBER [OPTIONS]
Arguments:
POST_ID The ID of the post to edit
TOPIC_ID/POST_NUMBER Topic ID and post number (e.g., 123/1 for first post)
Options:
--help, -h Show this help message and exit
--speed=SPEED Typing speed: slow, normal, fast (default: normal)
slow: 2-4s per char, 10-15s between phrases
normal: 30-80ms per char, 200-400ms between phrases
fast: 10-30ms per char, 50-150ms between phrases
--headless=BOOL Run headless browser: true, false (default: true)
--user=USERNAME Login username (default: admin)
--base-url=URL Discourse URL (default: http://localhost:4200)
--post-id=ID Post ID (alternative to positional argument)
--topic-post=TOPIC/POST Topic ID and post number (alternative to positional)
Examples:
fake_writer 42 # Edit post ID 42
fake_writer 10/1 # Edit first post in topic 10
fake_writer 42 --speed=fast # Fast typing speed
fake_writer 42 --headless=false # Show browser window
fake_writer --post-id=42 --user=codinghorror
fake_writer --topic-post=10/1 --base-url=http://localhost:3000
HELP
exit 0
end
def parse_args
opts = { speed: "normal", headless: true, user: "admin", base_url: "http://localhost:4200" }
post_id = nil
topic_ref = nil
ARGV.each do |arg|
case arg
when "--help", "-h"
show_help
when /^--speed=(.+)$/
opts[:speed] = $1
when /^--headless=(.+)$/
opts[:headless] = $1 != "false"
when /^--user=(.+)$/
opts[:user] = $1
when /^--base-url=(.+)$/
opts[:base_url] = $1
when /^--post-id=(\d+)$/
post_id = $1.to_i
when %r{^--topic-post=(\d+)/(\d+)$}
topic_ref = [$1.to_i, $2.to_i]
when %r{^(\d+)/(\d+)$}
topic_ref = [$1.to_i, $2.to_i]
when /^\d+$/
post_id = arg.to_i
end
end
[post_id, topic_ref, opts]
end
def resolve_post_id(post_id, topic_ref)
return post_id if post_id
return nil unless topic_ref
topic_id, post_number = topic_ref
post = Post.find_by(topic_id: topic_id, post_number: post_number)
unless post
warn "Post not found for topic #{topic_id} post ##{post_number}"
exit 1
end
post.id
end
def topic_for_post(post_id)
post = Post.includes(:topic).find_by(id: post_id)
unless post
warn "Post #{post_id} not found"
exit 1
end
topic = post.topic
unless topic
warn "Topic for post #{post_id} not found"
exit 1
end
{ id: topic.id, slug: topic.slug, title: topic.title }
end
def focus_editor(page)
textarea = page.query_selector("#reply-control .d-editor-input")
if textarea && textarea.evaluate("el => el && el.tagName === 'TEXTAREA'")
textarea.evaluate(<<~JS)
(el) => {
if (!el) {
return;
}
const value = typeof el.value === "string" ? el.value : "";
el.focus();
const len = value.length;
el.setSelectionRange(len, len);
}
JS
return :markdown
end
pm = page.query_selector("#reply-control .ProseMirror")
if pm
pm.click
pm.evaluate(<<~JS)
(el) => {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
JS
return :rich
end
nil
end
def random_delay(range)
return 0 if range.nil?
min = range.begin
max = range.end
rand(min..max) / 1000.0
end
def type_phrase(page, phrase, speed)
editor_type = focus_editor(page)
raise "Editor not ready" unless editor_type
phrase.each_char do |char|
page.keyboard.insert_text(char)
print char
$stdout.flush
sleep random_delay(speed[:char_ms])
end
end
# Main
post_id, topic_ref, opts = parse_args
post_id = resolve_post_id(post_id, topic_ref)
unless post_id
puts "Usage: fake_writer POST_ID or fake_writer TOPIC_ID/POST_NUMBER"
puts "Run 'fake_writer --help' for more options."
exit 1
end
speed = SPEEDS[opts[:speed]] || SPEEDS["normal"]
topic = topic_for_post(post_id)
puts "=" * 50
puts "Fake Writer - Shared Edit Simulator"
puts "=" * 50
puts "Post: #{post_id}"
puts "Topic: #{topic[:title]}"
puts "Speed: #{opts[:speed]} (char: #{speed[:char_ms]}ms, phrase: #{speed[:phrase_ms]}ms)"
puts "Headless: #{opts[:headless]}"
puts "=" * 50
Playwright.create(playwright_cli_executable_path: "./node_modules/.bin/playwright") do |playwright|
browser =
playwright.chromium.launch(
headless: opts[:headless],
args: %w[--no-sandbox --disable-dev-shm-usage --disable-gpu --mute-audio],
)
page = browser.new_page
begin
# Login
puts "Logging in as #{opts[:user]}..."
page.goto("#{opts[:base_url]}/session/#{opts[:user]}/become")
sleep 2
# Navigate to topic
puts "Opening topic..."
page.goto("#{opts[:base_url]}/t/#{topic[:slug]}/#{topic[:id]}")
sleep 2
# Enable shared edits if not already
unless page.query_selector(".shared-edit")
puts "Enabling shared edits..."
page.query_selector(".show-more-actions")&.click
sleep 0.3
page.query_selector(".show-post-admin-menu")&.click
sleep 0.3
page.query_selector(".admin-toggle-shared-edits")&.click
sleep 1
end
# Open composer
puts "Opening shared edit composer..."
page.wait_for_selector(".shared-edit", timeout: 5000).click
sleep 2
page.wait_for_selector("#reply-control.open", timeout: 10_000)
puts "Composer ready"
puts "-" * 50
puts "Typing... (Ctrl+C to stop)"
puts "-" * 50
focus_editor(page)
# Main typing loop
idx = 0
loop do
phrase = PHRASES[idx % PHRASES.length]
idx += 1
type_phrase(page, phrase, speed)
puts
$stdout.flush
sleep(random_delay(speed[:phrase_ms]))
end
rescue Interrupt
puts "\n\nStopped by user."
ensure
browser.close
end
end