Pastes over 4000 chars or 100 lines attach as a timestamped pasted-text-*.md in the upload tray instead of flooding the composer. Image-paste path unchanged; an oversize paste that exceeds MAX_UPLOAD_BYTES falls through to a normal inline paste so nothing is lost. Co-authored-by: santastabber <204424800+santastabber@users.noreply.github.com>
140 lines
7.1 KiB
Python
140 lines
7.1 KiB
Python
"""Tests for #1620 — Cmd+V always attaches an image when clipboard contains both text and image.
|
|
|
|
The composer paste handler in `static/boot.js` previously intercepted any paste
|
|
event whose clipboard carried an `image/*` item, called `e.preventDefault()`,
|
|
and attached the image as a screenshot. When the clipboard came from a rich-text
|
|
source (Notes, Word, Slack, browser selection), macOS/Windows/Linux attach a
|
|
rendered preview image alongside the plain text — so the handler swallowed the
|
|
text payload and only the rogue image was attached.
|
|
|
|
The fix:
|
|
• Skip image-attach when the clipboard also carries `text/plain` or `text/html`
|
|
string items (rich-text source — let the browser paste text normally).
|
|
• Tighten the image filter to `kind === 'file'` so string items advertising an
|
|
image MIME are not misclassified as a true screenshot paste.
|
|
|
|
These tests guard the handler shape against regression by static-analyzing
|
|
`static/boot.js`. They follow the same pattern as `test_issue1095_pasted_images.py`.
|
|
"""
|
|
import os
|
|
import re
|
|
|
|
|
|
def _read_boot_js() -> str:
|
|
with open(os.path.join('static', 'boot.js')) as f:
|
|
return f.read()
|
|
|
|
|
|
def _paste_handler_body() -> str:
|
|
"""Extract the body of the #msg paste handler for assertions."""
|
|
src = _read_boot_js()
|
|
m = re.search(r"\$\('msg'\)\.addEventListener\('paste',\s*e\s*=>\s*\{", src)
|
|
assert m, "#msg paste handler not found in static/boot.js"
|
|
# Walk braces from the opening { to find the matching close.
|
|
start = m.end() - 1
|
|
depth = 0
|
|
for i in range(start, len(src)):
|
|
c = src[i]
|
|
if c == '{':
|
|
depth += 1
|
|
elif c == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start:i + 1]
|
|
raise AssertionError("Unbalanced braces in #msg paste handler")
|
|
|
|
|
|
class TestPasteHandlerTextWithImage:
|
|
"""Regression suite for #1620."""
|
|
|
|
def test_handler_detects_text_in_clipboard(self):
|
|
"""Handler must inspect string items for text/plain or text/html so it can
|
|
defer to the browser's default text-paste behavior when text is present."""
|
|
body = _paste_handler_body()
|
|
assert "kind==='string'" in body or 'kind === "string"' in body or "kind === 'string'" in body, (
|
|
"paste handler must check items[].kind === 'string' to detect text payload"
|
|
)
|
|
assert "'text/plain'" in body, "paste handler must check for text/plain"
|
|
assert "'text/html'" in body, "paste handler must check for text/html"
|
|
|
|
def test_image_filter_requires_kind_file(self):
|
|
"""Image filter must require kind === 'file' to avoid misclassifying string
|
|
items that advertise an image MIME (e.g. text/html with embedded data URIs)."""
|
|
body = _paste_handler_body()
|
|
# The image filter line must combine kind==='file' with type.startsWith('image/').
|
|
assert re.search(
|
|
r"kind\s*===\s*'file'\s*&&\s*[a-zA-Z_$][\w$]*\.type\.startsWith\('image/'\)",
|
|
body,
|
|
), "imageItems filter must use kind === 'file' && type.startsWith('image/')"
|
|
|
|
def test_handler_skips_preventDefault_when_text_present(self):
|
|
"""When text is in the clipboard alongside an image, preventDefault() must
|
|
NOT be called so the browser's native text-paste runs. The handler should
|
|
still attach the image via addFiles(). The #1620 intent (don't swallow text)
|
|
is preserved by gating preventDefault on !hasText instead of bailing entirely.
|
|
Additionally, text-only clipboards (no image items) must early-return before
|
|
reaching the attach path."""
|
|
body = _paste_handler_body()
|
|
# Text-only small pastes should return before preventDefault/attach;
|
|
# large text-only pastes may continue into the markdown-attachment path.
|
|
assert "if(!_shouldAttachLargePastedText(plainText))return;" in body, (
|
|
"handler must leave normal text-only paste to the browser before any preventDefault"
|
|
)
|
|
# The new text-only branch must run after the image path has returned,
|
|
# so image+text paste still uses the #1620 path below.
|
|
assert body.index("return;\n }\n const plainText") > body.index("addFiles(files)"), (
|
|
"text-only large-paste handling must not intercept the image+text attach path"
|
|
)
|
|
# preventDefault must be gated on !hasText so text+image pastes let the
|
|
# browser insert text natively.
|
|
assert re.search(
|
|
r"if\s*\(\s*!\s*hasText\s*\)\s*e\.preventDefault\s*\(\s*\)\s*;",
|
|
body,
|
|
), "preventDefault must be gated on !hasText (only intercept image-only paste)"
|
|
|
|
def test_handler_attaches_image_when_both_text_and_image_present(self):
|
|
"""When the clipboard carries both text and image items (e.g. browser
|
|
Copy Image), addFiles() must still be reached so the image is attached.
|
|
The image attach path must NOT be guarded by hasText."""
|
|
body = _paste_handler_body()
|
|
# addFiles must appear AFTER the preventDefault gate, reachable regardless
|
|
# of whether text is present.
|
|
prevent_idx = body.find("preventDefault")
|
|
addfiles_idx = body.find("addFiles(files)")
|
|
assert addfiles_idx > prevent_idx, (
|
|
"addFiles(files) must come after the preventDefault gate and be "
|
|
"reachable even when hasText is true"
|
|
)
|
|
# No guard that bails before addFiles when hasText is true.
|
|
assert not re.search(
|
|
r"hasText\s*\)\s*return\s*;",
|
|
body,
|
|
), "handler must NOT early-return on hasText before addFiles (regression for #1620 fix)"
|
|
|
|
def test_handler_still_intercepts_pure_screenshot_paste(self):
|
|
"""Pure-screenshot paste (image-only clipboard) must still call preventDefault()
|
|
and route through addFiles() so the screenshot attaches as a file."""
|
|
body = _paste_handler_body()
|
|
assert 'e.preventDefault()' in body, "handler must still preventDefault on image-only paste"
|
|
assert 'addFiles(files)' in body, "handler must still call addFiles(files) for screenshots"
|
|
assert 'screenshot-' in body, "handler must still synthesize screenshot-<ts> filename"
|
|
|
|
def test_handler_does_not_use_loose_image_check(self):
|
|
"""The pre-fix loose check `i.type.startsWith('image/')` (without kind==='file')
|
|
must not be the imageItems filter — that was the source of the bug."""
|
|
body = _paste_handler_body()
|
|
# Find the imageItems assignment line.
|
|
m = re.search(r"const\s+imageItems\s*=\s*items\.filter\([^)]*\)", body)
|
|
assert m, "imageItems filter not found"
|
|
filter_expr = m.group(0)
|
|
assert "kind==='file'" in filter_expr or "kind === 'file'" in filter_expr, (
|
|
"imageItems filter must be tightened with kind === 'file' (regression for #1620)"
|
|
)
|
|
|
|
def test_handler_does_not_lose_status_message(self):
|
|
"""The image_pasted status message must still be emitted on the screenshot path."""
|
|
body = _paste_handler_body()
|
|
assert "setStatus(t('image_pasted')" in body, (
|
|
"handler must still emit the image_pasted status on screenshot attach"
|
|
)
|