Skip to content
Closed

fix #185

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions test/webkit-dash/README.md
Original file line number Diff line number Diff line change
@@ -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 `<!-- {"width":100} -->`.

## 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 <url> [--expect present|absent]
```

It loads `<url>`, 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
```
178 changes: 178 additions & 0 deletions test/webkit-dash/repro.swift
Original file line number Diff line number Diff line change
@@ -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
// `<!-- {"width":100} -->`.
//
// 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 <url> [--expect present|absent]
//
// It loads <url>, 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 <url> [--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()
4 changes: 3 additions & 1 deletion website/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ function handleWikilinkClick({ target }: { target: string }): void {
const INITIAL_CONTENT = `
# Welcome to Meowdown

![](https://static.photos/yellow/16x16/3)<!-- {"width": 20, "height": 20} -->

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.
Expand Down Expand Up @@ -262,7 +264,7 @@ export function App() {
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DemoEditor
mode={mode}
spellCheck={false}
spellCheck={true}
initialMarkdown={INITIAL_CONTENT}
onTagSearch={searchTags}
onWikilinkSearch={searchNotes}
Expand Down
Loading