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): self.ws = ws self.ready = ready self.toasts = list(toasts or []) self.closed = False self.sent = [] self.toast_observer_installed = False def val(self, expr): if expr == editor.JS_READY: return self.ready 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) return None def send(self, method, params=None): self.sent.append((method, params or {})) return {} def close(self): self.closed = True 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) ] 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_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) 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_wait_ready_raises_product_unavailable_from_hidden_toast(self): fake = FakeProductCDP( "ws-new", ready=False, toasts=[ { "text": "please input correct product id", "html": "
please input correct product id
", "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_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", return_value=True) as close_tab: 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) close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222") 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") as close_tab: editor.collect( {"debug_port": 9222}, {"item_id": "51100639510", "old_cover_path": "old.jpg"}, ) 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" 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_keeps_auto_created_product_tab_after_success_when_disabled(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) close_tab.assert_not_called() sleep.assert_not_called() 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()