From 6e3ae50adbf48a4f1eeec8fd344a88e8aa0bf7ee Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 1 Jul 2026 15:21:19 +1000 Subject: [PATCH 1/4] wip --- .github/workflows/ci.yml | 36 +++++++++++++++++++ packages/core/src/extensions/image.test.ts | 34 ++++++++++++++++++ packages/core/src/unicode.ts | 2 ++ .../react/src/components/prosekit-editor.tsx | 2 ++ website/src/app.tsx | 4 ++- 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba823205..8de0f1a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,42 @@ jobs: - name: Install run: pnpm run test:install:${{ matrix.browser }} + - name: Check macOS config + if: ${{ matrix.os == 'mac' }} + run: | + # Disable automatic capitalization as it’s annoying when typing code + defaults read -g NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" + + # Disable smart dashes as they’re annoying when typing code + defaults read -g NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" + + # Disable automatic period substitution as it’s annoying when typing code + defaults read -g NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" + + # Disable smart quotes as they’re annoying when typing code + defaults read -g NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" + + # Disable auto-correct + defaults read -g NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" + + - name: Check macOS config + if: ${{ matrix.os == 'mac' }} + run: | + # Disable automatic capitalization as it’s annoying when typing code + defaults write -g NSAutomaticCapitalizationEnabled -bool true + + # Disable smart dashes as they’re annoying when typing code + defaults write -g NSAutomaticDashSubstitutionEnabled -bool true + + # Disable automatic period substitution as it’s annoying when typing code + defaults write -g NSAutomaticPeriodSubstitutionEnabled -bool true + + # Disable smart quotes as they’re annoying when typing code + defaults write -g NSAutomaticQuoteSubstitutionEnabled -bool true + + # Disable auto-correct + defaults write -g NSAutomaticSpellingCorrectionEnabled -bool true + - name: Test run: pnpm run test env: diff --git a/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index 1e18748f..401dba74 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -1,3 +1,4 @@ +import { sleep } from '@ocavue/utils' import { describe, expect, it, vi } from 'vitest' import { page, userEvent } from 'vitest/browser' @@ -9,6 +10,7 @@ import { traceKeySelection, type Fixture, } from '../testing/index.ts' +import { UNICODE_EM_DASH, UNICODE_HYPHEN_MINUS } from '../unicode.ts' import { defineImageClickHandler, type ImageClickHandler } from './image-click.ts' import { defineImage } from './image.ts' @@ -300,6 +302,38 @@ describe('image resize', () => { expect(resizable.element()).toHaveAttribute('data-loading', '') await expect.element(resizable).not.toHaveAttribute('data-loading') }) + + it('does not apply automatic dash substitution', async () => { + using fixture = setupResize( + '![](url)', + getSVGImageURL(10, 10), + ) + + fixture.dom.autocorrect = true + fixture.dom.autocapitalize = 'on' + fixture.dom.spellcheck = true + + // put the text cursor at the end of the document, right after the comment + setCaret(fixture, fixture.doc.textContent.length) + + // In macOS WebKit, typing two ASCII hyphen (-) characters might input an em dash (—). This breaks the HTML comment, so we need to ensure that the editor does not perform automatic dash substitution. + // https://developer.apple.com/documentation/appkit/nstextview/isautomaticdashsubstitutionenabled + await userEvent.keyboard(' ') + await userEvent.keyboard('-') + await userEvent.keyboard('-') + await userEvent.keyboard(' ') + + await sleep(1000) + + const textContent = fixture.doc.textContent + const chars = [...textContent] + const hyphenCount = chars.filter((char) => char === UNICODE_HYPHEN_MINUS).length + const emDashCount = chars.filter((char) => char === UNICODE_EM_DASH).length + + expect(hyphenCount).toBe(6) + expect(emDashCount).toBe(0) + expect(textContent).toMatchInlineSnapshot(`"![](url) -- "`) + }) }) describe('typing after an inline image', () => { diff --git a/packages/core/src/unicode.ts b/packages/core/src/unicode.ts index 3d976429..294eddfe 100644 --- a/packages/core/src/unicode.ts +++ b/packages/core/src/unicode.ts @@ -50,6 +50,8 @@ export const CHAR_9 = 57 /* 9 */ // Boundaries. export const CHAR_MAX_ASCII = 127 +export const UNICODE_HYPHEN_MINUS = '\u{002D}' /* - */ +export const UNICODE_EM_DASH = '\u{2014}' /* — */ export const UNICODE_LEFT_BLACK_LENTICULAR_BRACKET = '\u{3010}' /* 【 */ export const UNICODE_RIGHT_BLACK_LENTICULAR_BRACKET = '\u{3011}' /* 】 */ export const UNICODE_LEFT_HALF_BLOCK = '\u{258C}' /* ▌ */ diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx index ef103a63..ed17b1db 100644 --- a/packages/react/src/components/prosekit-editor.tsx +++ b/packages/react/src/components/prosekit-editor.tsx @@ -244,6 +244,8 @@ export function ProseKitEditor({
+ 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() {
Date: Wed, 1 Jul 2026 15:46:20 +1000 Subject: [PATCH 2/4] try --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8de0f1a6..2506419d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,19 +98,19 @@ jobs: if: ${{ matrix.os == 'mac' }} run: | # Disable automatic capitalization as it’s annoying when typing code - defaults write -g NSAutomaticCapitalizationEnabled -bool true + defaults write NSGlobalDomain NSAutomaticCapitalizationEnabled -bool true # Disable smart dashes as they’re annoying when typing code - defaults write -g NSAutomaticDashSubstitutionEnabled -bool true + defaults write NSGlobalDomain NSAutomaticDashSubstitutionEnabled -bool true # Disable automatic period substitution as it’s annoying when typing code - defaults write -g NSAutomaticPeriodSubstitutionEnabled -bool true + defaults write NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled -bool true # Disable smart quotes as they’re annoying when typing code - defaults write -g NSAutomaticQuoteSubstitutionEnabled -bool true + defaults write NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled -bool true # Disable auto-correct - defaults write -g NSAutomaticSpellingCorrectionEnabled -bool true + defaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool true - name: Test run: pnpm run test From e68bf3136cec50edf52ac38202ace6a943d98371 Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 1 Jul 2026 15:48:16 +1000 Subject: [PATCH 3/4] debug 5 --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2506419d..fecdf837 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: - name: Install run: pnpm run test:install:${{ matrix.browser }} - - name: Check macOS config + - name: Check macOS config 1 if: ${{ matrix.os == 'mac' }} run: | # Disable automatic capitalization as it’s annoying when typing code @@ -94,6 +94,24 @@ jobs: # Disable auto-correct defaults read -g NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" + - name: Check macOS config 2 + if: ${{ matrix.os == 'mac' }} + run: | + # Disable automatic capitalization as it’s annoying when typing code + defaults read NSGlobalDomain NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" + + # Disable smart dashes as they’re annoying when typing code + defaults read NSGlobalDomain NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" + + # Disable automatic period substitution as it’s annoying when typing code + defaults read NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" + + # Disable smart quotes as they’re annoying when typing code + defaults read NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" + + # Disable auto-correct + defaults read NSGlobalDomain NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" + - name: Check macOS config if: ${{ matrix.os == 'mac' }} run: | @@ -112,6 +130,42 @@ jobs: # Disable auto-correct defaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool true + - name: Check macOS config 3 + if: ${{ matrix.os == 'mac' }} + run: | + # Disable automatic capitalization as it’s annoying when typing code + defaults read -g NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" + + # Disable smart dashes as they’re annoying when typing code + defaults read -g NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" + + # Disable automatic period substitution as it’s annoying when typing code + defaults read -g NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" + + # Disable smart quotes as they’re annoying when typing code + defaults read -g NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" + + # Disable auto-correct + defaults read -g NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" + + - name: Check macOS config 4 + if: ${{ matrix.os == 'mac' }} + run: | + # Disable automatic capitalization as it’s annoying when typing code + defaults read NSGlobalDomain NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" + + # Disable smart dashes as they’re annoying when typing code + defaults read NSGlobalDomain NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" + + # Disable automatic period substitution as it’s annoying when typing code + defaults read NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" + + # Disable smart quotes as they’re annoying when typing code + defaults read NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" + + # Disable auto-correct + defaults read NSGlobalDomain NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" + - name: Test run: pnpm run test env: From f1a353d53ea27950ca14238564ed15adfeaef7f8 Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 1 Jul 2026 16:22:24 +1000 Subject: [PATCH 4/4] test: reproduce macOS WebKit smart-dash bug via WKWebView in CI --- .github/workflows/ci.yml | 133 +++++-------- packages/core/src/extensions/image.test.ts | 34 ---- packages/core/src/unicode.ts | 2 - .../react/src/components/prosekit-editor.tsx | 2 - test/webkit-dash/README.md | 43 +++++ test/webkit-dash/repro.swift | 178 ++++++++++++++++++ 6 files changed, 264 insertions(+), 128 deletions(-) create mode 100644 test/webkit-dash/README.md create mode 100644 test/webkit-dash/repro.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fecdf837..8faf3c09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,96 +76,6 @@ jobs: - name: Install run: pnpm run test:install:${{ matrix.browser }} - - name: Check macOS config 1 - if: ${{ matrix.os == 'mac' }} - run: | - # Disable automatic capitalization as it’s annoying when typing code - defaults read -g NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" - - # Disable smart dashes as they’re annoying when typing code - defaults read -g NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" - - # Disable automatic period substitution as it’s annoying when typing code - defaults read -g NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" - - # Disable smart quotes as they’re annoying when typing code - defaults read -g NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" - - # Disable auto-correct - defaults read -g NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" - - - name: Check macOS config 2 - if: ${{ matrix.os == 'mac' }} - run: | - # Disable automatic capitalization as it’s annoying when typing code - defaults read NSGlobalDomain NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" - - # Disable smart dashes as they’re annoying when typing code - defaults read NSGlobalDomain NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" - - # Disable automatic period substitution as it’s annoying when typing code - defaults read NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" - - # Disable smart quotes as they’re annoying when typing code - defaults read NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" - - # Disable auto-correct - defaults read NSGlobalDomain NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" - - - name: Check macOS config - if: ${{ matrix.os == 'mac' }} - run: | - # Disable automatic capitalization as it’s annoying when typing code - defaults write NSGlobalDomain NSAutomaticCapitalizationEnabled -bool true - - # Disable smart dashes as they’re annoying when typing code - defaults write NSGlobalDomain NSAutomaticDashSubstitutionEnabled -bool true - - # Disable automatic period substitution as it’s annoying when typing code - defaults write NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled -bool true - - # Disable smart quotes as they’re annoying when typing code - defaults write NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled -bool true - - # Disable auto-correct - defaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool true - - - name: Check macOS config 3 - if: ${{ matrix.os == 'mac' }} - run: | - # Disable automatic capitalization as it’s annoying when typing code - defaults read -g NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" - - # Disable smart dashes as they’re annoying when typing code - defaults read -g NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" - - # Disable automatic period substitution as it’s annoying when typing code - defaults read -g NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" - - # Disable smart quotes as they’re annoying when typing code - defaults read -g NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" - - # Disable auto-correct - defaults read -g NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" - - - name: Check macOS config 4 - if: ${{ matrix.os == 'mac' }} - run: | - # Disable automatic capitalization as it’s annoying when typing code - defaults read NSGlobalDomain NSAutomaticCapitalizationEnabled || echo "NSAutomaticCapitalizationEnabled not set" - - # Disable smart dashes as they’re annoying when typing code - defaults read NSGlobalDomain NSAutomaticDashSubstitutionEnabled || echo "NSAutomaticDashSubstitutionEnabled not set" - - # Disable automatic period substitution as it’s annoying when typing code - defaults read NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled || echo "NSAutomaticPeriodSubstitutionEnabled not set" - - # Disable smart quotes as they’re annoying when typing code - defaults read NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled || echo "NSAutomaticQuoteSubstitutionEnabled not set" - - # Disable auto-correct - defaults read NSGlobalDomain NSAutomaticSpellingCorrectionEnabled || echo "NSAutomaticSpellingCorrectionEnabled not set" - - name: Test run: pnpm run test env: @@ -176,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/packages/core/src/extensions/image.test.ts b/packages/core/src/extensions/image.test.ts index 401dba74..1e18748f 100644 --- a/packages/core/src/extensions/image.test.ts +++ b/packages/core/src/extensions/image.test.ts @@ -1,4 +1,3 @@ -import { sleep } from '@ocavue/utils' import { describe, expect, it, vi } from 'vitest' import { page, userEvent } from 'vitest/browser' @@ -10,7 +9,6 @@ import { traceKeySelection, type Fixture, } from '../testing/index.ts' -import { UNICODE_EM_DASH, UNICODE_HYPHEN_MINUS } from '../unicode.ts' import { defineImageClickHandler, type ImageClickHandler } from './image-click.ts' import { defineImage } from './image.ts' @@ -302,38 +300,6 @@ describe('image resize', () => { expect(resizable.element()).toHaveAttribute('data-loading', '') await expect.element(resizable).not.toHaveAttribute('data-loading') }) - - it('does not apply automatic dash substitution', async () => { - using fixture = setupResize( - '![](url)', - getSVGImageURL(10, 10), - ) - - fixture.dom.autocorrect = true - fixture.dom.autocapitalize = 'on' - fixture.dom.spellcheck = true - - // put the text cursor at the end of the document, right after the comment - setCaret(fixture, fixture.doc.textContent.length) - - // In macOS WebKit, typing two ASCII hyphen (-) characters might input an em dash (—). This breaks the HTML comment, so we need to ensure that the editor does not perform automatic dash substitution. - // https://developer.apple.com/documentation/appkit/nstextview/isautomaticdashsubstitutionenabled - await userEvent.keyboard(' ') - await userEvent.keyboard('-') - await userEvent.keyboard('-') - await userEvent.keyboard(' ') - - await sleep(1000) - - const textContent = fixture.doc.textContent - const chars = [...textContent] - const hyphenCount = chars.filter((char) => char === UNICODE_HYPHEN_MINUS).length - const emDashCount = chars.filter((char) => char === UNICODE_EM_DASH).length - - expect(hyphenCount).toBe(6) - expect(emDashCount).toBe(0) - expect(textContent).toMatchInlineSnapshot(`"![](url) -- "`) - }) }) describe('typing after an inline image', () => { diff --git a/packages/core/src/unicode.ts b/packages/core/src/unicode.ts index 294eddfe..3d976429 100644 --- a/packages/core/src/unicode.ts +++ b/packages/core/src/unicode.ts @@ -50,8 +50,6 @@ export const CHAR_9 = 57 /* 9 */ // Boundaries. export const CHAR_MAX_ASCII = 127 -export const UNICODE_HYPHEN_MINUS = '\u{002D}' /* - */ -export const UNICODE_EM_DASH = '\u{2014}' /* — */ export const UNICODE_LEFT_BLACK_LENTICULAR_BRACKET = '\u{3010}' /* 【 */ export const UNICODE_RIGHT_BLACK_LENTICULAR_BRACKET = '\u{3011}' /* 】 */ export const UNICODE_LEFT_HALF_BLOCK = '\u{258C}' /* ▌ */ diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx index ed17b1db..ef103a63 100644 --- a/packages/react/src/components/prosekit-editor.tsx +++ b/packages/react/src/components/prosekit-editor.tsx @@ -244,8 +244,6 @@ export function ProseKitEditor({
`. + +## 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()