Codex gate found a SILENT hazard: if a workspace drag is abandoned without ondragend firing (cancel/drop-outside/blur), _wsActiveDragPath lingered and a later FOREIGN text/plain drag could be misread as a workspace move. Hardening: - _wsDragSrcPath now only trusts the active flag when the drop's own text/plain EQUALS _wsActiveDragPath (a foreign/empty text drag resolves to '' -> no move). - Shared _clearWsDragData() called from ondragend, a drop finally, and window-level dragend/drop/pagehide/blur fallbacks (lost-dragend cleanup). - 2 regression tests (stale-flag foreign-text -> empty; _clearWsDragData resets), proven non-vacuous. Co-authored-by: Rod Boev <rod.boev@gmail.com>
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""Regression tests for #4757 — macOS workspace drag-drop never lands.
|
|
|
|
WebKit strips custom MIME types from DataTransfer during dragover/drop, so
|
|
_isWorkspaceTreeMoveDrag must accept text/plain when the module-scoped active-drag
|
|
flag is set. The node-driver extraction pattern runs all logic in Node.js without
|
|
a browser.
|
|
"""
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
NODE = shutil.which("node")
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
|
|
|
|
|
|
def _extract_drag_functions():
|
|
"""Extract the six drag-drop functions from ui.js."""
|
|
src = open(UI_JS, encoding='utf-8').read()
|
|
|
|
# Extract module-scoped let declarations
|
|
_let_re = re.compile(r'^let\s+(_wsActiveDragPath|_wsActiveDragType)\s*=\s*null\s*;$')
|
|
lines = src.split('\n')
|
|
let_lines = []
|
|
for line in lines:
|
|
s = line.strip()
|
|
if _let_re.match(s):
|
|
let_lines.append(s)
|
|
|
|
def extract_function(name):
|
|
start = src.find(f'function {name}(')
|
|
if start < 0:
|
|
raise AssertionError(f'{name} not found in ui.js')
|
|
i = src.find('{', start)
|
|
depth = 1
|
|
i += 1
|
|
while i < len(src) and depth:
|
|
if src[i] == '{':
|
|
depth += 1
|
|
elif src[i] == '}':
|
|
depth -= 1
|
|
i += 1
|
|
return src[start:i]
|
|
|
|
fns = '\n'.join(
|
|
extract_function(name)
|
|
for name in (
|
|
'_setWsDragData',
|
|
'_clearWsDragData',
|
|
'_isWorkspaceTreeMoveDrag',
|
|
'_wsDragSrcPath',
|
|
'_wsDragSrcType',
|
|
)
|
|
)
|
|
return '\n'.join(let_lines), fns
|
|
|
|
|
|
def _run_node(test_js):
|
|
"""Run a Node.js snippet that includes the extracted functions, return stdout."""
|
|
lets, fns = _extract_drag_functions()
|
|
harness = """\
|
|
// Fake DataTransfer
|
|
class FakeDataTransfer {
|
|
constructor(typesArr, dataMap) {
|
|
this._map = dataMap || {};
|
|
this.types = typesArr || Object.keys(this._map);
|
|
}
|
|
getData(mime) { return this._map[mime] || ''; }
|
|
setData(mime, val) { this._map[mime] = val; if(!this.types.includes(mime)) this.types.push(mime); }
|
|
}
|
|
"""
|
|
js_code = harness + '\n' + lets + '\n' + fns + '\n' + test_js
|
|
|
|
tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8')
|
|
tf.write(js_code)
|
|
tf.close()
|
|
try:
|
|
result = subprocess.run(
|
|
[NODE, tf.name],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f'node error: {result.stderr}')
|
|
return result.stdout.strip()
|
|
finally:
|
|
os.unlink(tf.name)
|
|
|
|
|
|
class TestMacosDragDropMime:
|
|
"""Module-scoped active-drag flags allow workspace drag-drop on macOS."""
|
|
|
|
def test_macos_stripped_drag_accepted(self):
|
|
"""After _setWsDragData, gate accepts text/plain-only (WebKit strips custom MIME)."""
|
|
js = """\
|
|
const item = {path: 'docs/readme.md', type: 'file'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
|
|
// Simulate macOS: only text/plain survives into dragover/drop
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path});
|
|
const accepted = _isWorkspaceTreeMoveDrag({dataTransfer: dropDt});
|
|
process.stdout.write(String(accepted));
|
|
"""
|
|
assert _run_node(js) == 'true', \
|
|
'macOS-stripped drag must be accepted when active-drag flag is set'
|
|
|
|
def test_normal_drag_accepted(self):
|
|
"""Custom MIME present — gate returns true without needing the flag."""
|
|
js = """\
|
|
const dropDt = new FakeDataTransfer(
|
|
['application/ws-path', 'text/plain'],
|
|
{'application/ws-path': 'notes.txt', 'text/plain': 'notes.txt'}
|
|
);
|
|
process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
|
|
"""
|
|
assert _run_node(js) == 'true', \
|
|
'normal drag (custom MIME present) must be accepted'
|
|
|
|
def test_foreign_text_drag_rejected(self):
|
|
"""Foreign text (text/plain only, no active-drag flag) must be rejected."""
|
|
js = """\
|
|
// Do NOT call _setWsDragData — flag stays null
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': 'hello world'});
|
|
process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
|
|
"""
|
|
assert _run_node(js) == 'false', \
|
|
'foreign text drag must be rejected when no active-drag flag is set'
|
|
|
|
def test_stale_flag_foreign_text_resolves_empty(self):
|
|
"""If the active-drag flag lingers (lost dragend) and a FOREIGN text/plain
|
|
drag arrives whose content differs from the tracked path, _wsDragSrcPath
|
|
must return '' (not the stale path) so no spurious workspace move runs.
|
|
|
|
Regression guard for the gate-found SILENT hazard: the stripped-MIME
|
|
fallback now requires text/plain === _wsActiveDragPath."""
|
|
js = """\
|
|
const item = {path: 'docs/readme.md', type: 'file'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
// Flag still set (dragend never fired). A foreign text drag arrives with
|
|
// DIFFERENT content and no custom MIME.
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': 'some pasted text'});
|
|
dropDt.getData = function(mime){ if(mime==='application/ws-path') return ''; return this._map[mime]||''; };
|
|
process.stdout.write('[' + _wsDragSrcPath({dataTransfer: dropDt}) + ']');
|
|
"""
|
|
assert _run_node(js) == '[]', \
|
|
'stale flag + foreign text (content != tracked path) must resolve to empty'
|
|
|
|
def test_clear_ws_drag_data_resets_flags(self):
|
|
"""_clearWsDragData nulls both flags so a later text/plain-only drag is rejected."""
|
|
js = """\
|
|
const item = {path: 'tmp.txt', type: 'file'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
_clearWsDragData();
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path});
|
|
process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
|
|
"""
|
|
assert _run_node(js) == 'false', \
|
|
'_clearWsDragData must reset flags so a stripped-MIME drag is no longer accepted'
|
|
|
|
|
|
def test_files_drag_rejected_even_with_active_flag(self):
|
|
"""Files drop must be rejected regardless of active-drag state."""
|
|
js = """\
|
|
const item = {path: 'img.png', type: 'file'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
|
|
const dropDt = new FakeDataTransfer(['Files', 'text/plain'], {});
|
|
process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
|
|
"""
|
|
assert _run_node(js) == 'false', \
|
|
'Files drop must be rejected even when active-drag flag is set'
|
|
|
|
def test_resolver_returns_custom_mime_when_present(self):
|
|
"""_wsDragSrcPath returns custom MIME value when available."""
|
|
js = """\
|
|
const dropDt = new FakeDataTransfer(
|
|
['application/ws-path', 'text/plain'],
|
|
{'application/ws-path': 'src/index.js', 'text/plain': 'fallback'}
|
|
);
|
|
process.stdout.write(_wsDragSrcPath({dataTransfer: dropDt}));
|
|
"""
|
|
assert _run_node(js) == 'src/index.js', \
|
|
'_wsDragSrcPath must prefer custom MIME over text/plain'
|
|
|
|
def test_resolver_falls_back_to_active_drag_path(self):
|
|
"""_wsDragSrcPath falls back to active-drag flag when custom MIME stripped."""
|
|
js = """\
|
|
const item = {path: 'my file.txt', type: 'file'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
|
|
// Custom MIME stripped; text/plain has the path too (as set by _setWsDragData)
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path});
|
|
// Override getData so custom MIME returns empty
|
|
dropDt.getData = function(mime){ if(mime==='application/ws-path') return ''; return this._map[mime]||''; };
|
|
process.stdout.write(_wsDragSrcPath({dataTransfer: dropDt}));
|
|
"""
|
|
assert _run_node(js) == 'my file.txt', \
|
|
'_wsDragSrcPath must fall back to active-drag flag (supports paths with spaces)'
|
|
|
|
def test_resolver_type_returns_custom_mime_type(self):
|
|
"""_wsDragSrcType returns custom type when available."""
|
|
js = """\
|
|
const dropDt = new FakeDataTransfer(
|
|
['application/ws-type'],
|
|
{'application/ws-type': 'dir'}
|
|
);
|
|
process.stdout.write(_wsDragSrcType({dataTransfer: dropDt}));
|
|
"""
|
|
assert _run_node(js) == 'dir', \
|
|
'_wsDragSrcType must return custom MIME type value'
|
|
|
|
def test_resolver_type_falls_back_to_active_drag_type(self):
|
|
"""_wsDragSrcType falls back to active-drag flag when custom MIME stripped."""
|
|
js = """\
|
|
const item = {path: 'projects/', type: 'dir'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path});
|
|
dropDt.getData = function(mime){ if(mime==='application/ws-type') return ''; return this._map[mime]||''; };
|
|
process.stdout.write(_wsDragSrcType({dataTransfer: dropDt}));
|
|
"""
|
|
assert _run_node(js) == 'dir', \
|
|
'_wsDragSrcType must fall back to active-drag type flag'
|
|
|
|
def test_ondragend_equivalent_clears_state(self):
|
|
"""Clearing flags causes text/plain-only drag to be rejected (ondragend effect)."""
|
|
js = """\
|
|
const item = {path: 'tmp.txt', type: 'file'};
|
|
const startDt = new FakeDataTransfer([], {});
|
|
startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); };
|
|
_setWsDragData({dataTransfer: startDt}, item);
|
|
|
|
// Simulate ondragend clearing flags
|
|
_wsActiveDragPath = null;
|
|
_wsActiveDragType = null;
|
|
|
|
const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path});
|
|
process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt})));
|
|
"""
|
|
assert _run_node(js) == 'false', \
|
|
'after ondragend clears flags, text/plain-only drag must be rejected'
|