Files
cmshoppe/tests/test_editor_login.py
T

1635 lines
62 KiB
Python

import os
import json
import sys
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(__file__))
from _helpers import REPO_ROOT
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from app import editor
class FakeCDP:
instances = []
def __init__(self, ws, url="", cookies=None):
self.ws = ws
self.url = url
self.cookies = cookies or []
self.closed = False
FakeCDP.instances.append(self)
def val(self, expr):
if expr == "location.href":
return self.url
return None
def send(self, method, params=None):
if method == "Network.getAllCookies":
return {"cookies": self.cookies}
return {}
def close(self):
self.closed = True
class FakeProductCDP:
def __init__(self, ws, ready=True, toasts=None, rects=None, alerts=None, url=""):
self.ws = ws
self.ready = ready
self.toasts = list(toasts or [])
self.rects = list(rects or [])
self.alerts = list(alerts or [])
self.url = url
self.closed = False
self.sent = []
self.evaluated = []
self.toast_observer_installed = False
def val(self, expr):
self.evaluated.append(expr)
if expr == editor.JS_READY:
return json.dumps(self.ready) if isinstance(self.ready, dict) else self.ready
if expr == "location.href":
return self.url
if expr == editor.JS_INSTALL_TOAST_OBSERVER:
self.toast_observer_installed = True
return True
if expr == editor.JS_PAGE_TOASTS:
return json.dumps(self.toasts)
if expr == editor.JS_RECTS:
return json.dumps(self.rects)
if expr == editor.JS_PRODUCT_STATUS_ALERTS:
return json.dumps(self.alerts)
return None
def send(self, method, params=None):
self.sent.append((method, params or {}))
return {}
def close(self):
self.closed = True
class FakeTitleCDP:
def __init__(self, title="短标题"):
self.title = title
self.write_expr = ""
def val(self, expr):
if expr == editor.JS_TITLE_STATE:
return json.dumps(
{
"found": True,
"candidate_count": 1,
"source": "business",
"field_exists": True,
"value": self.title,
"modelvalue": self.title,
}
)
self.write_expr = expr
marker = "s.call(el,"
if marker in expr:
encoded = expr.split(marker, 1)[1].split(");", 1)[0]
self.title = json.loads(encoded)
return self.title
return None
def cover_rects(count, prefix="old", start=0):
return [
{
"i": index,
"x": 100 + index * 10,
"y": 100,
"left": 60 + index * 10,
"top": 60,
"w": 80,
"h": 80,
"src": f"https://susercontent.com/{prefix}-{start + index}.jpg",
}
for index in range(count)
]
def ready_snapshot(**overrides):
state = {
"ready": True,
"title_count": 1,
"title_source": "business",
"title_field_exists": True,
"title_ambiguous": False,
"image_field_exists": True,
"image_manager_exists": True,
"image_count": 5,
"cdn_count": 5,
"blob_count": 0,
"upload_input_exists": True,
"upload_input_count": 1,
"current_url": "https://seller.shopee.tw/portal/product/48363984966",
}
state.update(overrides)
return state
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,
upload_mode="success",
post_delete_busy_polls=0,
upload_input_ready=True,
restore_upload_after_delete=False,
page_error_toasts=None,
upload_tile_click=True,
require_upload_tile_click=False,
):
self.initial = cover_rects(count)
self.after_delete = cover_rects(max(0, count - 1), prefix="kept", start=1)
self.new_rect = {
"i": max(0, count - 1),
"x": 300,
"y": 100,
"left": 260,
"top": 60,
"w": 80,
"h": 80,
"src": "https://susercontent.com/new-cover.jpg",
}
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.upload_tile_click = upload_tile_click
self.require_upload_tile_click = require_upload_tile_click
self.delete_clicked = False
self.confirm_clicked = False
self.upload_tile_clicked = False
self.uploaded = False
self.uploaded_after_stable = None
self.upload_tile_clicked_before_file_set = 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:
return self.after_delete + [self.new_rect]
if self.confirm_clicked:
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.uploaded and self.upload_mode == "duplicate":
state = self._state()
state["toasts"] = ["有1張重複的圖片"]
state["error_toasts"] = ["有1張重複的圖片"]
state["page_error_toasts"] = ["有1張重複的圖片"]
return json.dumps(state)
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"})
self.delete_clicked = True
return json.dumps({"clicked": True, "reason": None})
if expr == editor.JS_CLICK_DELETE_CONFIRM:
if not self.confirm_click:
return json.dumps({"clicked": False, "reason": "NO_CONFIRM_BUTTON"})
self.confirm_clicked = True
return json.dumps({"clicked": True, "reason": None, "text": "刪除"})
if expr == editor.JS_CLICK_UPLOAD_TILE:
if not self.upload_tile_click:
return json.dumps({"clicked": False, "reason": "NO_UPLOAD_TILE"})
self.upload_tile_clicked = True
return json.dumps({"clicked": True, "reason": None})
return True
def object_id(self, expr):
if self.require_upload_tile_click and not self.upload_tile_clicked:
return None
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.upload_tile_clicked_before_file_set = self.upload_tile_clicked
self.uploaded_after_stable = self.post_delete_busy_polls == 0
self.uploaded = True
return {}
def drag(self, *args, **kwargs):
self.dragged = True
self.drag_calls.append((args, kwargs))
def cookie(name, domain=".shopee.tw"):
return {"name": name, "domain": domain}
class EditorLoginTests(unittest.TestCase):
def setUp(self):
FakeCDP.instances = []
def patch_cdp(self, url="", cookies=None):
def factory(ws):
return FakeCDP(ws, url=url, cookies=cookies)
return mock.patch("app.editor.CDP", side_effect=factory)
def test_login_status_false_for_login_page_url(self):
with mock.patch(
"app.editor.http_get",
return_value=[
{
"type": "page",
"url": "https://seller.shopee.tw/account/signin",
"webSocketDebuggerUrl": "ws-login",
}
],
):
status = editor.login_status({"debug_port": 9222}, timeout=0)
self.assertFalse(status["logged_in"])
self.assertEqual("LOGIN_PAGE", status["reason"])
self.assertEqual([], FakeCDP.instances)
def test_login_status_false_for_accounts_seller_login_url(self):
login_url = "https://accounts.shopee.tw/seller/login?next=https%3A%2F%2Fseller.shopee.tw%2Fportal"
with mock.patch(
"app.editor.http_get",
return_value=[
{
"type": "page",
"url": login_url,
"webSocketDebuggerUrl": "ws-login",
}
],
):
status = editor.login_status({"debug_port": 9222}, timeout=0)
self.assertFalse(status["logged_in"])
self.assertEqual("LOGIN_PAGE", status["reason"])
self.assertEqual(login_url, status["url"])
self.assertEqual([], FakeCDP.instances)
def test_login_status_true_with_session_cookie(self):
with mock.patch(
"app.editor.http_get",
return_value=[
{
"type": "page",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-portal",
}
],
), self.patch_cdp(
url="https://seller.shopee.tw/portal/",
cookies=[cookie("SPC_ST"), cookie("csrftoken")],
):
status = editor.login_status({"debug_port": 9222}, timeout=1)
self.assertTrue(status["logged_in"])
self.assertIsNone(status["reason"])
self.assertIn("SPC_ST", status["cookie_names"])
self.assertTrue(FakeCDP.instances[0].closed)
def test_login_status_reselects_live_target_after_closing_target_fails(self):
pages = [
{
"id": "target-closing",
"type": "page",
"url": "https://seller.shopee.tw/portal/product/48663456321",
"webSocketDebuggerUrl": "ws-closing",
},
{
"id": "target-live",
"type": "page",
"url": "https://seller.shopee.tw/portal/product/52313423890",
"webSocketDebuggerUrl": "ws-live",
},
]
def cdp_factory(ws):
instance = FakeCDP(
ws,
url=next(page["url"] for page in pages if page["webSocketDebuggerUrl"] == ws),
cookies=[cookie("SPC_ST")] if ws == "ws-live" else [],
)
if ws == "ws-closing":
original_send = instance.send
def send(method, params=None):
if method == "Network.getAllCookies":
raise RuntimeError("Target closed")
return original_send(method, params)
instance.send = send
return instance
with mock.patch("app.editor.http_get", return_value=pages), mock.patch(
"app.editor.CDP",
side_effect=cdp_factory,
), mock.patch("app.editor.time.sleep"):
status = editor.login_status({"debug_port": 9222}, timeout=1)
self.assertTrue(status["logged_in"])
self.assertIsNone(status["reason"])
self.assertEqual(2, status["probe_attempts"])
self.assertEqual(["ws-closing", "ws-live"], [item.ws for item in FakeCDP.instances])
self.assertTrue(all(item.closed for item in FakeCDP.instances))
def test_login_status_target_failure_is_not_no_session_cookie(self):
page = {
"id": "target-closing",
"type": "page",
"url": "https://seller.shopee.tw/portal/product/48663456321",
"webSocketDebuggerUrl": "ws-closing",
}
def cdp_factory(ws):
instance = FakeCDP(ws, url=page["url"])
def send(method, params=None):
if method == "Network.getAllCookies":
raise RuntimeError("Target closed")
return {}
instance.send = send
return instance
with mock.patch("app.editor.http_get", return_value=[page]), mock.patch(
"app.editor.CDP",
side_effect=cdp_factory,
):
status = editor.login_status({"debug_port": 9222}, timeout=0)
self.assertFalse(status["logged_in"])
self.assertEqual("LOGIN_CHECK_TARGET_UNAVAILABLE", status["reason"])
self.assertFalse(status["cookie_read_succeeded"])
self.assertNotEqual("NO_SESSION_COOKIE", status["reason"])
def test_login_status_url_probe_failure_is_not_no_session_cookie(self):
page = {
"id": "target-closing",
"type": "page",
"url": "https://seller.shopee.tw/portal/product/48663456321",
"webSocketDebuggerUrl": "ws-closing",
}
instance = FakeCDP("ws-closing", url=page["url"], cookies=[])
def fail_url_probe(_expression):
raise RuntimeError("Target closed")
instance.val = fail_url_probe
with mock.patch("app.editor.http_get", return_value=[page]), mock.patch(
"app.editor.CDP",
return_value=instance,
):
status = editor.login_status({"debug_port": 9222}, timeout=0)
self.assertFalse(status["logged_in"])
self.assertEqual("LOGIN_CHECK_TARGET_UNAVAILABLE", status["reason"])
self.assertFalse(status["cookie_read_succeeded"])
def test_login_status_false_without_session_cookie(self):
with mock.patch(
"app.editor.http_get",
return_value=[
{
"type": "page",
"url": "https://seller.shopee.tw/portal/",
"webSocketDebuggerUrl": "ws-portal",
}
],
), self.patch_cdp(
url="https://seller.shopee.tw/portal/",
cookies=[cookie("csrftoken")],
):
status = editor.login_status({"debug_port": 9222}, timeout=0)
self.assertFalse(status["logged_in"])
self.assertEqual("NO_SESSION_COOKIE", status["reason"])
def test_login_status_opens_seller_home_when_no_shopee_page(self):
with mock.patch("app.editor.http_get", return_value=[]), mock.patch(
"app.editor.create_tab", return_value="ws-new"
) as create_tab, self.patch_cdp(
url="https://seller.shopee.tw/",
cookies=[cookie("SPC_U")],
):
status = editor.login_status(
{"debug_port": 9222, "region_host": "seller.shopee.tw"},
timeout=1,
)
self.assertTrue(status["logged_in"])
create_tab.assert_called_once_with(
"https://seller.shopee.tw/",
host="127.0.0.1:9222",
)
def test_is_logged_in_returns_boolean(self):
with mock.patch(
"app.editor.login_status",
return_value={"logged_in": True},
):
self.assertTrue(editor.is_logged_in({"debug_port": 9222}))
def test_open_product_marks_reused_existing_tab(self):
fake = FakeProductCDP("ws-existing")
with mock.patch(
"app.editor.find_product_tab",
return_value={"id": "target-existing", "webSocketDebuggerUrl": "ws-existing"},
) as find_product_tab, mock.patch(
"app.editor.create_tab_info",
) as create_tab_info, mock.patch(
"app.editor.CDP",
return_value=fake,
), mock.patch("app.editor._ensure_page_domains"), mock.patch("app.editor._wait_ready"):
cdp = editor.open_product({"debug_port": 9222}, "51100639510")
self.assertIs(fake, cdp)
self.assertEqual("target-existing", cdp.target_id)
self.assertFalse(cdp.created_by_app)
self.assertEqual("127.0.0.1:9222", cdp.cdp_host)
find_product_tab.assert_called_once_with("51100639510", host="127.0.0.1:9222")
create_tab_info.assert_not_called()
self.assertIn(("Page.bringToFront", {}), fake.sent)
self.assertIn(
(
"Page.navigate",
{
"url": (
"https://seller.shopee.tw/portal/product/51100639510"
"?pageEntry=product_list&ignore-html-cache=1"
)
},
),
fake.sent,
)
def test_open_product_marks_auto_created_tab(self):
fake = FakeProductCDP("ws-new")
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
) as create_tab_info, mock.patch(
"app.editor.CDP",
return_value=fake,
), mock.patch("app.editor._ensure_page_domains"), mock.patch("app.editor._wait_ready"):
cdp = editor.open_product({"debug_port": 9223}, "51100639511")
self.assertEqual("target-new", cdp.target_id)
self.assertTrue(cdp.created_by_app)
self.assertEqual("127.0.0.1:9223", cdp.cdp_host)
self.assertIn(("Page.bringToFront", {}), fake.sent)
create_tab_info.assert_called_once_with(
(
"https://seller.shopee.tw/portal/product/51100639511"
"?pageEntry=product_list&ignore-html-cache=1"
),
host="127.0.0.1:9223",
background=False,
)
def test_open_product_can_skip_bring_to_front_for_collection(self):
fake = FakeProductCDP("ws-new")
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
) as create_tab_info, mock.patch(
"app.editor.CDP",
return_value=fake,
), mock.patch("app.editor._ensure_page_domains"), mock.patch("app.editor._wait_ready"):
cdp = editor.open_product(
{"debug_port": 9223},
"51100639511",
bring_to_front=False,
)
self.assertIs(fake, cdp)
self.assertNotIn(("Page.bringToFront", {}), fake.sent)
self.assertIn(
(
"Page.navigate",
{
"url": (
"https://seller.shopee.tw/portal/product/51100639511"
"?pageEntry=product_list&ignore-html-cache=1"
)
},
),
fake.sent,
)
create_tab_info.assert_called_once_with(
(
"https://seller.shopee.tw/portal/product/51100639511"
"?pageEntry=product_list&ignore-html-cache=1"
),
host="127.0.0.1:9223",
background=True,
)
def test_open_product_installs_toast_observer(self):
fake = FakeProductCDP("ws-new", ready=True)
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
), mock.patch("app.editor.CDP", return_value=fake), mock.patch(
"app.editor._ensure_page_domains"
), mock.patch("app.editor.time.sleep"):
cdp = editor.open_product({"debug_port": 9223}, "51100639511")
self.assertIs(fake, cdp)
self.assertTrue(fake.toast_observer_installed)
self.assertIn(
("Page.addScriptToEvaluateOnNewDocument", {"source": editor.JS_TOAST_OBSERVER_SOURCE}),
fake.sent,
)
def test_editor_business_selectors_replace_title_length_guess(self):
self.assertIn('data-product-edit-field-unique-id="name"', editor.TITLE_FIELD_SELECTOR)
self.assertIn("data-product-edit-field-unique-id='name'", editor.TITLE_XPATH)
self.assertNotIn("string-length", editor.TITLE_XPATH)
self.assertIn("string-length", editor.LEGACY_TITLE_XPATH)
self.assertIn('data-product-edit-field-unique-id="images"', editor.IMAGE_FIELD_SELECTOR)
self.assertIn("data-product-edit-field-unique-id='images'", editor.ITEMBOX_XPATH)
self.assertIn("source:'legacy'", editor.JS_EDITOR_FIELD_HELPERS)
def test_wait_ready_accepts_short_title_business_field_snapshot(self):
fake = FakeProductCDP("ws-new", ready=ready_snapshot())
with mock.patch("app.editor.time.sleep"):
result = editor._wait_ready(fake, timeout=0)
self.assertTrue(result)
self.assertIn(editor.JS_SCROLL_MAIN_IMAGE_MANAGER, fake.evaluated)
def test_wait_ready_reports_missing_title_without_hidden_logistics_toast(self):
state = ready_snapshot(ready=False, title_count=0)
fake = FakeProductCDP(
"ws-new",
ready=state,
url=state["current_url"],
toasts=[
{
"text": "此物流選項不支援較長備貨商品",
"url": state["current_url"],
"visible": False,
}
],
)
with self.assertRaises(editor.EditorError) as ctx:
editor._wait_ready(fake, timeout=0)
message = str(ctx.exception)
self.assertIn("未识别商品名称输入框", message)
self.assertIn("商品图片5张", message)
self.assertIn("主图上传入口正常", message)
self.assertNotIn("物流選項", message)
def test_wait_ready_reports_ambiguous_business_title_inputs(self):
fake = FakeProductCDP("ws-new", ready=ready_snapshot(ready=False, title_count=2))
with self.assertRaises(editor.EditorError) as ctx:
editor._wait_ready(fake, timeout=0)
self.assertIn("商品名称输入框匹配到2个,无法确定唯一字段", str(ctx.exception))
def test_ready_error_appends_only_visible_same_page_toast_as_hint(self):
url = "https://seller.shopee.tw/portal/product/48363984966"
fake = FakeProductCDP(
"ws-new",
url=url,
toasts=[{"text": "页面暂时繁忙", "url": url, "visible": True}],
)
message = editor._open_product_ready_error(fake, "商品编辑器未就绪", current_url=url)
self.assertEqual("商品编辑器未就绪;页面提示(可能无关):页面暂时繁忙", message)
self.assertIn("Object.assign({},item,{visible:false})", editor.JS_PAGE_TOASTS)
def test_read_and_change_title_share_business_field_locator(self):
fake = FakeTitleCDP(title="二十字以内的有效短标题")
self.assertEqual("二十字以内的有效短标题", editor.read_title(fake))
with mock.patch("app.editor.time.sleep"):
result = editor.change_title(fake, "更新后的短标题")
self.assertTrue(result["ok"])
self.assertEqual("business", result["source"])
self.assertIn(editor.JS_EDITOR_FIELD_HELPERS, fake.write_expr)
self.assertNotIn(editor.TITLE_XPATH, fake.write_expr)
def test_main_image_scripts_share_scoped_business_field_helpers(self):
scripts = (
editor.JS_READY,
editor.JS_RECTS,
editor.JS_UPLOAD_STATE,
editor.JS_CLICK_UPLOAD_TILE,
editor.JS_FIRST_COVER,
editor.JS_SCROLL_MAIN_IMAGE_MANAGER,
editor.JS_MAIN_UPLOAD_INPUT,
editor.JS_DISPATCH_MAIN_UPLOAD_INPUT_EVENTS,
editor.JS_CLICK_FIRST_DELETE,
)
for script in scripts:
with self.subTest(script=script[:40]):
self.assertIn(editor.JS_EDITOR_FIELD_HELPERS, script)
self.assertIn(json.dumps(editor.IMAGE_FIELD_SELECTOR), script)
def test_wait_ready_raises_product_unavailable_from_hidden_toast(self):
fake = FakeProductCDP(
"ws-new",
ready=False,
toasts=[
{
"text": "please input correct product id",
"html": "<div class='eds-toast__content'>please input correct product id</div>",
"url": "https://seller.shopee.tw/portal/product/invalid",
"visible": False,
"created_at": "2026-07-02T00:00:00Z",
}
],
)
with self.assertRaises(editor.EditorError) as ctx:
editor._wait_ready(fake, timeout=0)
self.assertIn("商品失效", str(ctx.exception))
self.assertIn("please input correct product id", str(ctx.exception))
toasts = editor.read_page_toasts(fake)
self.assertEqual("please input correct product id", toasts[0]["text"])
self.assertFalse(toasts[0]["visible"])
def test_open_product_closes_auto_created_tab_when_unavailable(self):
fake = FakeProductCDP(
"ws-new",
ready=False,
toasts=[{"text": "please input correct product id", "visible": False}],
)
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
), mock.patch("app.editor.CDP", return_value=fake), mock.patch(
"app.editor._ensure_page_domains"
), mock.patch("app.editor.close_tab", return_value=True) as close_tab:
with self.assertRaises(editor.EditorError) as ctx:
editor.open_product({"debug_port": 9223}, "bad-item")
self.assertIn("商品失效", str(ctx.exception))
self.assertTrue(fake.closed)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9223")
def test_open_product_keeps_reused_tab_when_unavailable(self):
fake = FakeProductCDP(
"ws-existing",
ready=False,
toasts=[{"text": "please input correct product id", "visible": False}],
)
with mock.patch(
"app.editor.find_product_tab",
return_value={"id": "target-existing", "webSocketDebuggerUrl": "ws-existing"},
), mock.patch("app.editor.create_tab_info") as create_tab_info, mock.patch(
"app.editor.CDP",
return_value=fake,
), mock.patch("app.editor._ensure_page_domains"), mock.patch(
"app.editor.close_tab",
return_value=True,
) as close_tab:
with self.assertRaises(editor.EditorError):
editor.open_product({"debug_port": 9223}, "bad-item")
self.assertTrue(fake.closed)
close_tab.assert_not_called()
create_tab_info.assert_not_called()
def test_open_product_background_failure_confirms_created_target_closed(self):
fake = FakeProductCDP(
"ws-new",
ready=False,
toasts=[{"text": "please input correct product id", "visible": False}],
)
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
"app.editor.create_tab_info",
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
), mock.patch("app.editor.CDP", return_value=fake), mock.patch(
"app.editor._ensure_page_domains"
), mock.patch(
"app.editor.close_tab_and_wait",
return_value=True,
) as close_tab_and_wait:
with self.assertRaises(editor.EditorError):
editor.open_product(
{"debug_port": 9223},
"bad-item",
bring_to_front=False,
)
self.assertTrue(fake.closed)
close_tab_and_wait.assert_called_once_with(
"target-new",
host="127.0.0.1:9223",
timeout=2.0,
)
def test_collect_closes_only_auto_created_product_tab(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp) as open_product, mock.patch(
"app.editor.read_title",
return_value="旧标题",
), mock.patch(
"app.editor.read_cover_src",
return_value="https://down-ws-sg.vod.susercontent.com/cover.jpg",
), mock.patch(
"app.editor.download_cover",
return_value="images/main/51100639510_old.jpg",
), mock.patch("app.editor.close_tab_and_wait", return_value=True) as close_tab_and_wait:
result = editor.collect(
{"debug_port": 9222},
{"item_id": "51100639510", "old_cover_path": "old.jpg"},
)
self.assertEqual("旧标题", result["old_title"])
open_product.assert_called_once_with(
{"debug_port": 9222},
"51100639510",
on_step=None,
bring_to_front=False,
)
self.assertTrue(cdp.closed)
self.assertTrue(result["close_target_confirmed"])
close_tab_and_wait.assert_called_once_with(
"target-new",
host="127.0.0.1:9222",
timeout=2.0,
)
def test_read_product_status_uses_warning_alerts_without_vue_scope_selectors(self):
cdp = FakeProductCDP(
"ws-status",
alerts=[
{"title": "无关提示", "description": "保留为未知"},
{"title": " 審核中 ", "description": " 商品等待审核 "},
],
)
result = editor.read_product_status(cdp)
self.assertEqual("reviewing", result["product_status"])
self.assertEqual("标题:審核中;说明:商品等待审核", result["product_status_note"])
self.assertIsNone(result["product_status_error"])
self.assertIn(".eds-alert.eds-alert--warning", editor.JS_PRODUCT_STATUS_ALERTS)
self.assertNotIn("data-v-", editor.JS_PRODUCT_STATUS_ALERTS)
def test_read_product_status_degrades_to_unknown_when_cdp_response_is_invalid(self):
class BrokenStatusCDP:
def val(self, _expr):
raise RuntimeError("状态读取失败")
result = editor.read_product_status(BrokenStatusCDP())
self.assertEqual("unknown", result["product_status"])
self.assertIsNone(result["product_status_note"])
self.assertIn("状态读取失败", result["product_status_error"])
def test_collect_reads_product_status_before_title_and_returns_snapshot(self):
cdp = FakeProductCDP("ws-new", alerts=[])
steps = []
def read_title_after_status(_cdp):
self.assertIn("read_product_status", steps)
return "旧标题"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.read_title",
side_effect=read_title_after_status,
), mock.patch(
"app.editor.read_cover_src",
return_value="https://down-ws-sg.vod.susercontent.com/cover.jpg",
), mock.patch(
"app.editor.download_cover",
return_value="images/main/51100639510_old.jpg",
):
result = editor.collect(
{"debug_port": 9222},
{"item_id": "51100639510", "old_cover_path": "old.jpg"},
on_step=steps.append,
)
self.assertEqual("normal", result["product_status"])
self.assertEqual(
["read_product_status", "read_title", "read_cover", "download_cover"],
steps,
)
def test_collect_keeps_reused_product_tab_open(self):
cdp = FakeProductCDP("ws-existing")
cdp.target_id = "target-existing"
cdp.created_by_app = False
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.read_title",
return_value="旧标题",
), mock.patch(
"app.editor.read_cover_src",
return_value="https://down-ws-sg.vod.susercontent.com/cover.jpg",
), mock.patch(
"app.editor.download_cover",
return_value="images/main/51100639510_old.jpg",
), mock.patch("app.editor.close_tab_and_wait") as close_tab_and_wait:
result = editor.collect(
{"debug_port": 9222},
{"item_id": "51100639510", "old_cover_path": "old.jpg"},
)
self.assertTrue(cdp.closed)
self.assertIsNone(result["close_target_confirmed"])
close_tab_and_wait.assert_not_called()
def test_read_product_image_urls_returns_all_images_in_page_order(self):
cdp = FakeProductCDP("ws-existing", rects=cover_rects(3, prefix="main"))
images = editor.read_product_image_urls(cdp)
self.assertEqual(
[
{"index": 1, "src": "https://susercontent.com/main-0.jpg"},
{"index": 2, "src": "https://susercontent.com/main-1.jpg"},
{"index": 3, "src": "https://susercontent.com/main-2.jpg"},
],
images,
)
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"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
return_value={"ok": True, "reason": None},
) as change_title, mock.patch("app.editor.replace_cover") as replace_cover, mock.patch(
"app.editor.click_update",
return_value={"clicked": True, "reason": None},
), mock.patch("app.editor.close_tab", return_value=True) as close_tab, mock.patch(
"app.editor.time.sleep"
) as sleep:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=True,
)
self.assertTrue(result["committed"])
change_title.assert_called_once_with(cdp, "新标题")
replace_cover.assert_not_called()
self.assertTrue(cdp.closed)
sleep.assert_called_once_with(2)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
def test_apply_task_closes_auto_created_product_tab_after_success_even_with_legacy_disabled_flag(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
return_value={"ok": True, "reason": None},
), mock.patch(
"app.editor.click_update",
return_value={"clicked": True, "reason": None},
), mock.patch("app.editor.close_tab") as close_tab, mock.patch(
"app.editor.time.sleep"
) as sleep:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=False,
)
self.assertTrue(result["committed"])
self.assertTrue(cdp.closed)
sleep.assert_called_once_with(2)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
def test_apply_task_closes_auto_created_product_tab_after_failed_update(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
return_value={"ok": True, "reason": None},
), mock.patch(
"app.editor.click_update",
return_value={"clicked": False, "reason": "UPDATE_DISABLED"},
), mock.patch("app.editor.close_tab") as close_tab, mock.patch(
"app.editor.time.sleep"
) as sleep:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=False,
)
self.assertFalse(result["committed"])
self.assertEqual("UPDATE_DISABLED", result["error"])
self.assertTrue(cdp.closed)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
sleep.assert_not_called()
def test_apply_task_keeps_reused_product_tab_after_failed_update(self):
cdp = FakeProductCDP("ws-existing")
cdp.target_id = "target-existing"
cdp.created_by_app = False
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
return_value={"ok": True, "reason": None},
), mock.patch(
"app.editor.click_update",
return_value={"clicked": False, "reason": "UPDATE_DISABLED"},
), mock.patch("app.editor.close_tab") as close_tab, mock.patch(
"app.editor.time.sleep"
) as sleep:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=True,
)
self.assertFalse(result["committed"])
self.assertEqual("UPDATE_DISABLED", result["error"])
self.assertTrue(cdp.closed)
close_tab.assert_not_called()
sleep.assert_not_called()
def test_apply_task_closes_auto_created_product_tab_after_cover_failure(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={"ok": False, "reason": "UPLOAD_TIMEOUT"},
), mock.patch("app.editor.click_update") as click_update, mock.patch(
"app.editor.close_tab",
return_value=True,
) as close_tab, mock.patch("app.editor.time.sleep") as sleep:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_cover_path": "new.jpg"},
close_success_tab=False,
)
self.assertFalse(result["committed"])
self.assertIn("新封面上传超时", result["error"])
click_update.assert_not_called()
self.assertTrue(cdp.closed)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
sleep.assert_not_called()
def test_apply_task_closes_auto_created_product_tab_after_exception(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
cdp.cdp_host = "127.0.0.1:9222"
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.change_title",
side_effect=RuntimeError("写标题失败"),
), mock.patch("app.editor.close_tab", return_value=True) as close_tab, mock.patch(
"app.editor.time.sleep"
) as sleep:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_title": "新标题"},
close_success_tab=False,
)
self.assertFalse(result["committed"])
self.assertEqual("写标题失败", result["error"])
self.assertTrue(cdp.closed)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
sleep.assert_not_called()
def test_apply_task_passes_old_cover_backup_to_replace_cover(self):
cdp = FakeProductCDP("ws-new")
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={"ok": True, "reason": None},
) as replace_cover, mock.patch(
"app.editor.click_update",
return_value={"clicked": True, "reason": None},
):
result = editor.apply_task(
{"debug_port": 9222},
{
"item_id": "51100639510",
"new_cover_path": "new.jpg",
"old_cover_path": "old.jpg",
},
)
self.assertTrue(result["committed"])
replace_cover.assert_called_once_with(
cdp,
"new.jpg",
old_cover_path="old.jpg",
)
def test_replace_cover_requires_old_cover_backup_before_delete(self):
cdp = FakeCoverCDP(count=8)
missing_backup = os.path.join(os.path.dirname(__file__), "missing-old-cover.jpg")
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=missing_backup,
)
self.assertFalse(result["ok"])
self.assertEqual("OLD_COVER_BACKUP_MISSING", result["reason"])
self.assertFalse(cdp.delete_clicked)
self.assertFalse(cdp.uploaded)
def test_replace_cover_full_slots_deletes_first_then_uploads_and_drags(self):
cdp = FakeCoverCDP(count=9)
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(9, result["count_after"])
self.assertEqual(8, result["delete"]["count_after"])
self.assertEqual("https://susercontent.com/new-cover.jpg", result["new_src"])
def test_replace_cover_deletes_first_for_non_full_image_slots(self):
cdp = FakeCoverCDP(count=8)
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(8, result["count_before"])
self.assertEqual(8, result["count_after"])
self.assertEqual(7, result["delete"]["count_after"])
def test_replace_cover_clicks_upload_tile_before_setting_files(self):
cdp = FakeCoverCDP(count=9, require_upload_tile_click=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.upload_tile_clicked)
self.assertTrue(cdp.uploaded)
self.assertIn("DOM.setFileInputFiles", [method for method, _params in cdp.sent])
self.assertTrue(cdp.upload_tile_clicked_before_file_set)
self.assertEqual({"clicked": True, "reason": None}, result["upload_click"])
def test_replace_cover_reports_upload_tile_not_ready(self):
cdp = FakeCoverCDP(count=8, upload_tile_click=False)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_TILE_NOT_READY", result["reason"])
self.assertFalse(cdp.uploaded)
self.assertEqual("NO_UPLOAD_TILE", result["upload_click"]["reason"])
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__,
old_cover_path=__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"]))
self.assertIn("before_srcs", result)
self.assertTrue(result["before_srcs"])
self.assertFalse(any(str(src).startswith("blob:") for src in result["before_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__,
old_cover_path=__file__,
timeout=1,
)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_PAGE_ERROR", result["reason"])
self.assertIn("圖片格式錯誤", result["upload_state"]["errors"])
def test_replace_cover_reports_duplicate_upload_toast(self):
cdp = FakeCoverCDP(count=8, upload_mode="duplicate")
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertFalse(result["ok"])
self.assertEqual("UPLOAD_DUPLICATE_IMAGE", result["reason"])
self.assertIn("有1張重複的圖片", result["upload_state"]["error_toasts"])
self.assertIn("新封面与现有商品图片重复", editor._cover_upload_error_message(result))
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_apply_task_background_drag_failure_recovers_foreground_without_replacing_again(self):
cdp = FakeCoverCDP(count=8)
cdp.confirm_clicked = True
cdp.uploaded = True
cdp.closed = False
cdp.close = lambda: setattr(cdp, "closed", True)
events = []
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={
"ok": False,
"reason": "DRAG_NOT_FIRST",
"new_src": cdp.new_rect["src"],
},
) as replace_cover, mock.patch(
"app.editor.click_update",
return_value={"clicked": True, "reason": None},
):
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_cover_path": "new.jpg"},
bring_to_front=False,
on_step=events.append,
)
self.assertTrue(result["committed"])
replace_cover.assert_called_once()
self.assertIn(("Page.bringToFront", {}), cdp.sent)
self.assertTrue(cdp.dragged)
self.assertEqual(1, len(cdp.drag_calls))
self.assertTrue(result["cover"]["foreground_recovery"]["ok"])
self.assertTrue(any(event.get("step") == "cover_retry_foreground" for event in events))
def test_apply_task_background_upload_processing_recovers_by_waiting_current_upload(self):
cdp = FakeCoverCDP(count=8)
cdp.confirm_clicked = True
cdp.uploaded = True
cdp.closed = False
cdp.close = lambda: setattr(cdp, "closed", True)
before_srcs = [rect["src"] for rect in cdp.after_delete]
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={
"ok": False,
"reason": "UPLOAD_STILL_PROCESSING",
"before_srcs": before_srcs,
"upload_state": {"busy_count": 1},
"blob_seen": True,
},
) as replace_cover, mock.patch(
"app.editor.click_update",
return_value={"clicked": True, "reason": None},
), mock.patch("app.editor.time.sleep"):
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_cover_path": "new.jpg"},
bring_to_front=False,
)
self.assertTrue(result["committed"])
replace_cover.assert_called_once()
self.assertIn(("Page.bringToFront", {}), cdp.sent)
self.assertTrue(cdp.dragged)
self.assertTrue(result["cover"]["foreground_recovery"]["ok"])
self.assertEqual("UPLOAD_STILL_PROCESSING", result["cover"]["original_reason"])
def test_apply_task_background_upload_recovery_requires_before_srcs(self):
cdp = FakeCoverCDP(count=8)
cdp.confirm_clicked = True
cdp.uploaded = True
cdp.closed = False
cdp.close = lambda: setattr(cdp, "closed", True)
with mock.patch("app.editor.open_product", return_value=cdp), mock.patch(
"app.editor.replace_cover",
return_value={
"ok": False,
"reason": "UPLOAD_TIMEOUT",
"upload_state": {"busy_count": 0},
},
) as replace_cover, mock.patch("app.editor.click_update") as click_update:
result = editor.apply_task(
{"debug_port": 9222},
{"item_id": "51100639510", "new_cover_path": "new.jpg"},
bring_to_front=False,
)
self.assertFalse(result["committed"])
replace_cover.assert_called_once()
self.assertIn(("Page.bringToFront", {}), cdp.sent)
self.assertFalse(cdp.dragged)
self.assertEqual(
"MISSING_BEFORE_SRCS",
result["cover"]["foreground_recovery"]["reason"],
)
click_update.assert_not_called()
def test_replace_cover_full_slots_reports_missing_delete_button(self):
cdp = FakeCoverCDP(count=9, delete_click=False)
with mock.patch("app.editor.time.sleep"):
result = editor.replace_cover(
cdp,
__file__,
old_cover_path=__file__,
timeout=1,
)
self.assertFalse(result["ok"])
self.assertEqual("NO_DELETE_BUTTON", result["reason"])
self.assertFalse(cdp.uploaded)
if __name__ == "__main__":
unittest.main()