diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba823205..8faf3c09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,49 @@ jobs: if: ${{ matrix.coverage }} uses: davelosert/vitest-coverage-report-action@3c054a2d2e2ca45446417ad5d6d5eb33092af8f1 # v2.12.1 + # Reproduces the macOS WebKit smart-dash bug that the Playwright based browser + # tests cannot see. Playwright drives WebKit through the automation session, + # which never runs the WebContent editor's text substitution. This job drives + # the system WebKit (same engine as Safari) through a real WKWebView with + # synthesized AppKit key events, so typing "--" is substituted with an em dash + # exactly as it is in Safari. The step fails (red) while the bug is present. + webkit-dash: + runs-on: macos-latest + name: reproduce-webkit-smart-dash + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Reproduce smart-dash substitution in system WebKit + run: | + # WebKit's TextChecker falls back to this global setting, so enabling + # it makes system WebKit substitute "--" with an em dash on typing. + defaults write -g NSAutomaticDashSubstitutionEnabled -bool true + + pnpm dev > /tmp/meowdown-dev.log 2>&1 & + dev_pid=$! + + ready="" + for _ in $(seq 1 60); do + if curl -sf http://localhost:5173/ >/dev/null 2>&1; then ready=1; break; fi + sleep 1 + done + if [ -z "$ready" ]; then + echo "dev server did not start" + cat /tmp/meowdown-dev.log + kill "$dev_pid" 2>/dev/null || true + exit 1 + fi + + repro_status=0 + swift test/webkit-dash/repro.swift http://localhost:5173/ || repro_status=$? + kill "$dev_pid" 2>/dev/null || true + exit "$repro_status" + publish-snapshot: runs-on: ubuntu-latest diff --git a/test/webkit-dash/README.md b/test/webkit-dash/README.md new file mode 100644 index 00000000..564577e7 --- /dev/null +++ b/test/webkit-dash/README.md @@ -0,0 +1,43 @@ +# WebKit smart-dash reproduction + +Reproduces the macOS WebKit "smart dash" bug: with automatic dash substitution +enabled, typing two ASCII hyphens (`--`) becomes an em dash (`—`), which corrupts +meowdown's HTML comment markers such as ``. + +## Why not a Playwright test + +The Playwright based browser tests (vitest, `webkit` project) cannot see this +bug, and no macOS `defaults` value or Playwright option changes that: + +- The substitution is applied by WebKit's WebContent editor (its + `AlternativeTextController` / text-checking path), not by the AppKit input + layer. Verified by intercepting `-[WKWebView insertText:replacementRange:]`: + the input system delivers raw `-` `-`, yet the DOM ends up with `—`. +- Playwright drives WebKit through the WebKit **automation session**, which + injects key events without running that substitution path. Confirmed with raw + Playwright typing (slow, deliberate) and with the WebKit-specific defaults + (`WebAutomaticDashSubstitutionEnabled`, `WebContinuousSpellCheckingEnabled`) set + in Playwright's `org.webkit.Playwright` domain. The WebKit automation protocol + exposes no text-checking setting (`Page.Setting` has none). + +## What this harness does + +`repro.swift` drives the **system** WebKit (same engine as Safari) through a real +`WKWebView`, typing with synthesized AppKit key events sent down the responder +chain (`NSApp.sendEvent` → `keyDown:` → `interpretKeyEvents:`). That is the normal +user-typing path, so the substitution fires exactly as in Safari. + +``` +swift test/webkit-dash/repro.swift [--expect present|absent] +``` + +It loads ``, waits for a `.ProseMirror` editor, types `a -- b`, and reads the +result. Default expectation is `absent` (the fixed behavior): it exits non-zero +when an em dash is found, so CI is red while the bug is present and green once the +editor stops using spell checking. + +The macOS setting must be enabled first (WebKit's `TextChecker` falls back to it): + +``` +defaults write -g NSAutomaticDashSubstitutionEnabled -bool true +``` diff --git a/test/webkit-dash/repro.swift b/test/webkit-dash/repro.swift new file mode 100644 index 00000000..5858084a --- /dev/null +++ b/test/webkit-dash/repro.swift @@ -0,0 +1,178 @@ +// Reproduce the macOS WebKit "smart dash" substitution bug in CI. +// +// In Safari / system WebKit, when macOS automatic dash substitution is enabled, +// typing two ASCII hyphens ("--") is replaced by an em dash ("—"). Inside +// meowdown this corrupts HTML comments such as the image size marker +// ``. +// +// Playwright's WebKit build never applies this substitution: it drives the page +// through the WebKit automation session, which bypasses the WebContent editor's +// text-checking / AlternativeTextController path. No macOS `defaults` value or +// Playwright option changes that, so the bug is invisible to the Playwright +// based browser tests. +// +// This harness drives the *system* WebKit (the same engine as Safari) through a +// real WKWebView using synthesized AppKit key events sent down the responder +// chain (`NSApp.sendEvent` -> `keyDown:` -> `interpretKeyEvents:`). That is the +// normal user-typing path, so the substitution fires exactly as it does in +// Safari. +// +// Usage: +// swift repro.swift [--expect present|absent] +// +// It loads , waits for a `.ProseMirror` editor, types "a -- b", then reads +// the editor text back. Default expectation is `absent` (the fixed behavior): +// the process exits non-zero when an em dash is found, so CI turns red while the +// bug is present and green once it is fixed. + +import AppKit +import WebKit + +let arguments = CommandLine.arguments +guard arguments.count > 1 else { + FileHandle.standardError.write(Data("usage: swift repro.swift [--expect present|absent]\n".utf8)) + exit(2) +} +let targetURL = arguments[1] +let expectPresent: Bool = { + if let index = arguments.firstIndex(of: "--expect"), index + 1 < arguments.count { + return arguments[index + 1] == "present" + } + return false +}() + +let EM_DASH = "\u{2014}" + +final class Reproducer: NSObject, WKNavigationDelegate { + private var webView: WKWebView! + private var window: NSWindow! + + func run() { + let configuration = WKWebViewConfiguration() + webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 1000, height: 700), configuration: configuration) + webView.navigationDelegate = self + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1000, height: 700), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.contentView = webView + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + webView.load(URLRequest(url: URL(string: targetURL)!)) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + waitForEditor(attempt: 0) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + fail("navigation failed: \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + fail("provisional navigation failed: \(error.localizedDescription)") + } + + private func waitForEditor(attempt: Int) { + if attempt > 60 { + fail("timed out waiting for a non-empty .ProseMirror editor") + return + } + let probe = "(function(){var e=document.querySelector('.ProseMirror');return e?e.textContent.length:0})()" + webView.evaluateJavaScript(probe) { result, _ in + let length = (result as? Int) ?? 0 + if length > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.typeIntoEditor() } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { self.waitForEditor(attempt: attempt + 1) } + } + } + } + + private func sendKey(_ characters: String, _ keyCode: UInt16) { + for phase in [NSEvent.EventType.keyDown, .keyUp] { + guard let event = NSEvent.keyEvent( + with: phase, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode) + else { continue } + NSApp.sendEvent(event) + } + } + + private func typeIntoEditor() { + let focusScript = """ + (function(){ + var editor = document.querySelector('.ProseMirror'); + editor.focus(); + var range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + var selection = getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + return editor.getAttribute('spellcheck'); + })() + """ + webView.evaluateJavaScript(focusScript) { result, _ in + print("[repro] editor spellcheck attribute = \(String(describing: result))") + self.window.makeFirstResponder(self.webView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Start a fresh paragraph, then type "a -- b". + self.sendKey("\r", 36) + self.sendKey("a", 0) + self.sendKey(" ", 49) + self.sendKey("-", 27) + self.sendKey("-", 27) + self.sendKey(" ", 49) + self.sendKey("b", 11) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.readResult() } + } + } + } + + private func readResult() { + webView.evaluateJavaScript("document.querySelector('.ProseMirror').textContent") { result, _ in + let text = (result as? String) ?? "" + let hasEmDash = text.contains(EM_DASH) + print("[repro] typed \"a -- b\"; em dash present = \(hasEmDash)") + print("[repro] editor text tail = \(String(text.suffix(60)).debugDescription)") + self.finish(hasEmDash: hasEmDash) + } + } + + private func finish(hasEmDash: Bool) { + let passed = hasEmDash == expectPresent + if passed { + print("[repro] PASS (expected em dash \(expectPresent ? "present" : "absent"))") + } else if hasEmDash { + print("[repro] FAIL: bug reproduced. \"--\" became an em dash in system WebKit.") + } else { + print("[repro] FAIL: expected the smart-dash substitution but it did not occur.") + } + exit(passed ? 0 : 1) + } + + private func fail(_ message: String) { + FileHandle.standardError.write(Data("[repro] ERROR: \(message)\n".utf8)) + exit(3) + } +} + +let application = NSApplication.shared +application.setActivationPolicy(.regular) +let reproducer = Reproducer() +reproducer.run() +DispatchQueue.main.asyncAfter(deadline: .now() + 30) { + FileHandle.standardError.write(Data("[repro] ERROR: global timeout\n".utf8)) + exit(3) +} +application.run() diff --git a/website/src/app.tsx b/website/src/app.tsx index 69541f77..305e165b 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -35,6 +35,8 @@ function handleWikilinkClick({ target }: { target: string }): void { const INITIAL_CONTENT = ` # Welcome to Meowdown +![](https://static.photos/yellow/16x16/3) + A hybrid Markdown editor that renders as you type, so you never break your flow. Weave in **bold**, *italic*, \`inline code\`, or ~~strikethrough~~ without reaching for a toolbar. @@ -262,7 +264,7 @@ export function App() {