fix: handle Shopee update confirmation and upload edge cases

This commit is contained in:
chengma
2026-06-30 11:44:27 +08:00
parent cd1256bc5e
commit 580a7a570d
6 changed files with 877 additions and 33 deletions
+348 -2
View File
@@ -68,8 +68,108 @@ def cover_rects(count, prefix="old", start=0):
]
class FakeUpdateCDP:
def __init__(
self,
confirm_present=True,
confirm_click=True,
require_content_selector=False,
post_url=None,
post_toasts=None,
):
self.confirm_present = confirm_present
self.confirm_click = confirm_click
self.require_content_selector = require_content_selector
self.post_url = post_url
self.post_toasts = post_toasts
self.main_clicked = False
self.confirm_clicked = False
def val(self, expr):
if expr == editor.JS_FIND_UPDATE:
return json.dumps({"found": True, "disabled": False})
if expr == editor.JS_CLICK_UPDATE:
self.main_clicked = True
return "CLICKED"
if expr == editor.JS_FIND_UPDATE_CONFIRM:
if self.require_content_selector and ".eds-modal__content" not in expr:
return json.dumps({"present": False})
return json.dumps(
{
"present": self.confirm_present,
"title": "確定您要更新商品嗎?" if self.confirm_present else "",
"buttons": ["立即優化", "更新"] if self.confirm_present else [],
}
)
if expr == editor.JS_CLICK_UPDATE_CONFIRM:
if self.require_content_selector and ".eds-modal__content" not in expr:
return json.dumps(
{
"present": False,
"clicked": False,
"reason": "NO_UPDATE_CONFIRM_MODAL",
}
)
if not self.confirm_present:
return json.dumps(
{
"present": False,
"clicked": False,
"reason": "NO_UPDATE_CONFIRM_MODAL",
}
)
if not self.confirm_click:
return json.dumps(
{
"present": True,
"clicked": False,
"reason": "NO_UPDATE_CONFIRM_BUTTON",
"buttons": ["立即優化"],
}
)
self.confirm_clicked = True
self.confirm_present = False
return json.dumps(
{
"present": True,
"clicked": True,
"reason": None,
"text": "更新",
"buttons": ["立即優化", "更新"],
}
)
if expr == editor.JS_POST_UPDATE_STATE:
if self.post_url is None and self.post_toasts is None:
return None
url = self.post_url or "https://seller.shopee.tw/portal/product/51100639510"
toasts = self.post_toasts or []
error_words = ("失敗", "失败", "錯誤", "错误", "不支援", "不支持", "無法", "无法", "error", "fail", "invalid")
return json.dumps(
{
"url": url,
"redirected_to_list": "/portal/product/list/" in url,
"toasts": toasts,
"error_toasts": [t for t in toasts if any(word in t for word in error_words)],
"success_toasts": [t for t in toasts if "成功" in t or "success" in t.lower()],
}
)
if expr == editor.JS_TOASTS:
return json.dumps([])
return None
class FakeCoverCDP:
def __init__(self, count=9, delete_click=True, confirm_click=True):
def __init__(
self,
count=9,
delete_click=True,
confirm_click=True,
upload_mode="success",
post_delete_busy_polls=0,
upload_input_ready=True,
restore_upload_after_delete=False,
page_error_toasts=None,
):
self.initial = cover_rects(count)
self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1)
self.new_rect = {
@@ -84,14 +184,26 @@ class FakeCoverCDP:
}
self.delete_click = delete_click
self.confirm_click = confirm_click
self.upload_mode = upload_mode
self.post_delete_busy_polls = post_delete_busy_polls
self.upload_input_ready = upload_input_ready
self.restore_upload_after_delete = restore_upload_after_delete
self.page_error_toasts = page_error_toasts or []
self.delete_clicked = False
self.confirm_clicked = False
self.uploaded = False
self.uploaded_after_stable = None
self.dragged = False
self.sent = []
self.drag_calls = []
def _rects(self):
if self.uploaded and self.upload_mode == "processing":
blob_rect = dict(self.new_rect)
blob_rect["src"] = "blob:https://seller.shopee.tw/uploading"
return self.after_delete + [blob_rect]
if self.uploaded and self.upload_mode == "error":
return self.after_delete
if self.uploaded and self.dragged:
return [self.new_rect] + self.after_delete
if self.uploaded:
@@ -100,9 +212,42 @@ class FakeCoverCDP:
return self.after_delete
return self.initial
def _upload_ready(self):
return self.upload_input_ready or (self.restore_upload_after_delete and self.confirm_clicked)
def _state(self, busy_count=0, errors=None, crop_modal=False):
rects = self._rects()
srcs = [r["src"] for r in rects]
upload_ready = self._upload_ready()
return {
"manager_exists": True,
"count": len(rects),
"srcs": srcs,
"blob_count": len([src for src in srcs if src.startswith("blob:")]),
"cdn_count": len([src for src in srcs if "susercontent" in src]),
"upload_input_exists": upload_ready,
"upload_input_disabled": (not upload_ready) or busy_count > 0,
"upload_tile_visible": True,
"busy_count": busy_count,
"errors": errors or [],
"crop_modal": crop_modal,
"toasts": list(self.page_error_toasts),
"error_toasts": [],
"page_error_toasts": list(self.page_error_toasts),
}
def val(self, expr):
if expr == editor.JS_RECTS:
return json.dumps(self._rects())
if expr == editor.JS_UPLOAD_STATE:
if self.uploaded and self.upload_mode == "processing":
return json.dumps(self._state(busy_count=1))
if self.uploaded and self.upload_mode == "error":
return json.dumps(self._state(errors=["圖片格式錯誤"]))
if self.confirm_clicked and not self.uploaded and self.post_delete_busy_polls > 0:
self.post_delete_busy_polls -= 1
return json.dumps(self._state(busy_count=1))
return json.dumps(self._state())
if expr == editor.JS_CLICK_FIRST_DELETE:
if not self.delete_click:
return json.dumps({"clicked": False, "reason": "NO_DELETE_BUTTON"})
@@ -116,11 +261,12 @@ class FakeCoverCDP:
return True
def object_id(self, expr):
return "upload-input"
return "upload-input" if self._upload_ready() else None
def send(self, method, params=None):
self.sent.append((method, params or {}))
if method == "DOM.setFileInputFiles":
self.uploaded_after_stable = self.post_delete_busy_polls == 0
self.uploaded = True
return {}
@@ -322,6 +468,103 @@ class EditorLoginTests(unittest.TestCase):
self.assertTrue(cdp.closed)
close_tab.assert_not_called()
def test_click_update_confirms_shopee_update_modal(self):
cdp = FakeUpdateCDP(confirm_present=True, confirm_click=True)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(cdp.main_clicked)
self.assertTrue(cdp.confirm_clicked)
self.assertEqual("更新", result["confirm"]["text"])
self.assertIn("立即優化", result["confirm"]["buttons"])
def test_click_update_handles_real_eds_modal_content_confirm_dom(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
require_content_selector=True,
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(cdp.confirm_clicked)
self.assertIn(".eds-modal__content", editor.JS_FIND_UPDATE_CONFIRM)
self.assertIn(".eds-modal__content", editor.JS_CLICK_UPDATE_CONFIRM)
self.assertIn("hasUpdateButton", editor.JS_CLICK_UPDATE_CONFIRM)
self.assertEqual("更新", result["confirm"]["text"])
def test_click_update_records_product_list_redirect_after_confirm(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
post_url="https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time",
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1, post_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(result["post_update"]["redirected_to_list"])
self.assertEqual(
"https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time",
result["post_update"]["url"],
)
def test_click_update_treats_list_redirect_as_success_even_with_stale_error_toast(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
post_url="https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time",
post_toasts=["此物流選項不支援較長備貨商品"],
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1, post_timeout=0.1)
self.assertTrue(result["clicked"])
self.assertTrue(result["post_update"]["redirected_to_list"])
self.assertIn("此物流選項不支援較長備貨商品", result["post_update"]["error_toasts"])
def test_click_update_reports_logistics_validation_after_confirm(self):
cdp = FakeUpdateCDP(
confirm_present=True,
confirm_click=True,
post_toasts=["此物流選項不支援較長備貨商品"],
)
with mock.patch("app.editor.time.sleep"):
result = editor.click_update(cdp, confirm_timeout=0.1, post_timeout=0.1)
self.assertFalse(result["clicked"])
self.assertEqual("POST_UPDATE_ERROR", result["reason"])
self.assertIn("此物流選項不支援較長備貨商品", result["post_update"]["error_toasts"])
def test_click_update_fails_when_shopee_update_modal_is_not_confirmed(self):
cdp = FakeUpdateCDP(confirm_present=True, confirm_click=False)
result = editor.click_update(cdp, confirm_timeout=0.01)
self.assertFalse(result["clicked"])
self.assertTrue(cdp.main_clicked)
self.assertFalse(cdp.confirm_clicked)
self.assertEqual("NO_UPDATE_CONFIRM_BUTTON", result["reason"])
self.assertIn("立即優化", result["confirm"]["buttons"])
def test_click_update_keeps_existing_success_when_no_shopee_confirm_modal(self):
cdp = FakeUpdateCDP(confirm_present=False)
result = editor.click_update(cdp, confirm_timeout=0.01)
self.assertTrue(result["clicked"])
self.assertTrue(cdp.main_clicked)
self.assertFalse(cdp.confirm_clicked)
self.assertFalse(result["confirm"]["present"])
def test_apply_task_closes_auto_created_product_tab_after_success_when_enabled(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
@@ -434,6 +677,109 @@ class EditorLoginTests(unittest.TestCase):
self.assertEqual(8, result["delete"]["count_after"])
self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"])
def test_replace_cover_full_slots_deletes_before_upload_input_is_ready(self):
cdp = FakeCoverCDP(
count=9,
upload_input_ready=False,
restore_upload_after_delete=True,
)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertTrue(result["ok"])
self.assertTrue(cdp.delete_clicked)
self.assertTrue(cdp.confirm_clicked)
self.assertTrue(cdp.uploaded)
self.assertTrue(cdp.dragged)
self.assertEqual(9, result["count_before"])
self.assertEqual(8, result["delete"]["count_after"])
def test_replace_cover_ignores_unrelated_logistics_page_error_toast(self):
cdp = FakeCoverCDP(
count=9,
page_error_toasts=["此物流選項不支援較長備貨商品"],
)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertTrue(result["ok"])
self.assertTrue(cdp.delete_clicked)
self.assertTrue(cdp.uploaded)
self.assertTrue(cdp.dragged)
def test_replace_cover_waits_for_deleted_images_to_settle_before_uploading(self):
cdp = FakeCoverCDP(count=9, post_delete_busy_polls=2)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertTrue(result["ok"])
self.assertTrue(cdp.uploaded_after_stable)
self.assertEqual(0, cdp.post_delete_busy_polls)
def test_replace_cover_reports_upload_still_processing_with_state(self):
cdp = FakeCoverCDP(count=8, upload_mode="processing")
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(cdp, __file__, timeout=0.01)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_STILL_PROCESSING", result["reason"])
self.assertTrue(result["blob_seen"])
self.assertEqual(1, result["upload_state"]["busy_count"])
self.assertIn("blob:", "\n".join(result["upload_state"]["srcs"]))
def test_replace_cover_reports_page_upload_error_with_state(self):
cdp = FakeCoverCDP(count=8, upload_mode="error")
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(cdp, __file__, timeout=1)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_PAGE_ERROR", result["reason"])
self.assertIn("圖片格式錯誤", result["upload_state"]["errors"])
def test_apply_task_cover_failure_returns_readable_upload_error(self):
cdp = FakeProductCDP("ws-new")
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={
"ok": False,
"reason": "UPLOAD_STILL_PROCESSING",
"upload_state": {"busy_count": 1, "errors": [], "toasts": []},
},
), mock.patch("app.editor.click_update") as click_update:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_cover_path": "new.jpg"},
)
self.assertFalse(result["committed"])
self.assertIn("新封面上传仍在处理中", result["error"])
click_update.assert_not_called()
def test_replace_cover_full_slots_reports_missing_delete_button(self):
cdp = FakeCoverCDP(count=9, delete_click=False)