From 4e4947eef3a18e1f389b1349f4fff59496662d89 Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Sun, 12 Apr 2026 12:42:09 -0700 Subject: [PATCH] fix: harden Facebook post deletion checkbox and modal state handling --- .../FacebookViewModel/jobs_delete.test.ts | 377 +++++++++++++++--- .../FacebookViewModel/jobs_delete.ts | 241 ++++++++++- 2 files changed, 546 insertions(+), 72 deletions(-) diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts index 72de5659..de8dc3cc 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.test.ts @@ -348,43 +348,113 @@ describe("FacebookViewModel Delete Jobs", () => { const vm = createMockFacebookViewModel(); const mockWebview = vm.getWebview()!; - let callCount = 0; - vi.mocked(mockWebview.executeJavaScript).mockImplementation(async () => { - callCount++; - // 1. clickManagePostsButton - if (callCount === 1) return true; - // 2. waitForManagePostsDialog - if (callCount === 2) return true; - // 3. getListsAndItems - two items - if (callCount === 3) - return [ - { listIndex: 0, itemIndex: 0 }, - { listIndex: 0, itemIndex: 1 }, - ]; - // 4. toggleCheckbox item 0 (check) - if (callCount === 4) return true; - // 5. getActionDescription after item 0 — supports delete - if (callCount === 5) - return "You can hide or delete the posts selected."; - // 6. toggleCheckbox item 1 (check) - if (callCount === 6) return true; - // 7. getActionDescription after item 0+1 — combined only supports hide - if (callCount === 7) return "You can hide the posts selected."; - // 8. toggleCheckbox item 1 (uncheck) - if (callCount === 8) return true; - // 9. clickNextButton - if (callCount === 9) return true; - // 10. selectDeletePostsOption - if (callCount === 10) return true; - // 11. clickDoneButton - if (callCount === 11) return true; - // 12. waitForManagePostsDialogToDisappear - dialog gone - if (callCount === 12) return false; - // 13. Second batch: clickManagePostsButton fails -> exit - if (callCount === 13) return false; + let managePostsClicks = 0; + let isDialogOpen = false; + let isActionOptionsVisible = false; + const checkedItems = new Set(); - return false; - }); + vi.mocked(mockWebview.executeJavaScript).mockImplementation( + async (code: string) => { + if ( + code.includes( + `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, + ) + ) { + managePostsClicks++; + isDialogOpen = managePostsClicks === 1; + isActionOptionsVisible = false; + return managePostsClicks <= 2; + } + + if ( + code.includes( + `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, + ) && + code.includes("return !!dialog;") + ) { + return isDialogOpen; + } + + if (code.includes("result.push({ listIndex, itemIndex });")) { + return managePostsClicks === 1 + ? [ + { listIndex: 0, itemIndex: 0 }, + { listIndex: 0, itemIndex: 1 }, + ] + : []; + } + + if (code.includes("const shouldCheck = ")) { + const listMatch = code.match(/const list = lists\[(\d+)\];/); + const itemMatch = code.match(/const item = items\[(\d+)\];/); + const shouldCheckMatch = code.match( + /const shouldCheck = (true|false);/, + ); + + if (!listMatch || !itemMatch || !shouldCheckMatch) { + return false; + } + + const key = `${listMatch[1]}-${itemMatch[1]}`; + const shouldCheck = shouldCheckMatch[1] === "true"; + + if (shouldCheck) { + checkedItems.add(key); + } else { + checkedItems.delete(key); + } + + return true; + } + + if (code.includes("checkbox instanceof HTMLInputElement")) { + const listMatch = code.match(/const list = lists\[(\d+)\];/); + const itemMatch = code.match(/const item = items\[(\d+)\];/); + + if (!listMatch || !itemMatch) { + return null; + } + + return checkedItems.has(`${listMatch[1]}-${itemMatch[1]}`); + } + + if (code.includes('text.startsWith("You can")')) { + if (checkedItems.has("0-0") && !checkedItems.has("0-1")) { + return "You can hide or delete the posts selected."; + } + + if (checkedItems.has("0-0") && checkedItems.has("0-1")) { + return "You can hide the posts selected."; + } + + return ""; + } + + if (code.includes(`aria-label="Next"`)) { + isActionOptionsVisible = true; + return true; + } + + if ( + code.includes("const hasActionOptions =") && + code.includes(`aria-label="Done"`) + ) { + return isActionOptionsVisible; + } + + if (code.includes("text.includes('delete posts')")) { + return checkedItems.size === 1 && checkedItems.has("0-0"); + } + + if (code.includes(`aria-label="Done"`)) { + isDialogOpen = false; + isActionOptionsVisible = false; + return true; + } + + return false; + }, + ); await DeleteJobs.runJobDeleteWallPosts(vm, 3); @@ -401,33 +471,86 @@ describe("FacebookViewModel Delete Jobs", () => { const vm = createMockFacebookViewModel(); const mockWebview = vm.getWebview()!; - let callCount = 0; - vi.mocked(mockWebview.executeJavaScript).mockImplementation(async () => { - callCount++; - // 1. clickManagePostsButton - if (callCount === 1) return true; - // 2. waitForManagePostsDialog - if (callCount === 2) return true; - // 3. getListsAndItems - one item - if (callCount === 3) return [{ listIndex: 0, itemIndex: 0 }]; - // 4. toggleCheckbox item 0 (check) - if (callCount === 4) return true; - // 5. getActionDescription — untag+hide available - if (callCount === 5) - return "You can untag yourself from or hide the posts selected."; - // 6. clickNextButton - if (callCount === 6) return true; - // 7. selectUntagPostsOption - if (callCount === 7) return true; - // 8. clickDoneButton - if (callCount === 8) return true; - // 9. waitForManagePostsDialogToDisappear - dialog gone - if (callCount === 9) return false; - // 10. Second batch: clickManagePostsButton fails -> exit - if (callCount === 10) return false; + let managePostsClicks = 0; + let isDialogOpen = false; + let isActionOptionsVisible = false; + const checkedItems = new Set(); - return false; - }); + vi.mocked(mockWebview.executeJavaScript).mockImplementation( + async (code: string) => { + if ( + code.includes( + `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, + ) + ) { + managePostsClicks++; + isDialogOpen = managePostsClicks === 1; + isActionOptionsVisible = false; + return managePostsClicks <= 2; + } + + if ( + code.includes( + `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, + ) && + code.includes("return !!dialog;") + ) { + return isDialogOpen; + } + + if (code.includes("result.push({ listIndex, itemIndex });")) { + return managePostsClicks === 1 + ? [{ listIndex: 0, itemIndex: 0 }] + : []; + } + + if (code.includes("const shouldCheck = ")) { + if (code.includes("const shouldCheck = true;")) { + checkedItems.add("0-0"); + } else { + checkedItems.delete("0-0"); + } + return true; + } + + if (code.includes("checkbox instanceof HTMLInputElement")) { + return checkedItems.has("0-0"); + } + + if (code.includes('text.startsWith("You can")')) { + return checkedItems.has("0-0") + ? "You can untag yourself from or hide the posts selected." + : ""; + } + + if (code.includes(`aria-label="Next"`)) { + isActionOptionsVisible = true; + return true; + } + + if ( + code.includes("const hasActionOptions =") && + code.includes(`aria-label="Done"`) + ) { + return isActionOptionsVisible; + } + + if ( + code.includes("text.includes('untag')") || + code.includes("text.includes('remove tags')") + ) { + return true; + } + + if (code.includes(`aria-label="Done"`)) { + isDialogOpen = false; + isActionOptionsVisible = false; + return true; + } + + return false; + }, + ); await DeleteJobs.runJobDeleteWallPosts(vm, 3); @@ -437,6 +560,136 @@ describe("FacebookViewModel Delete Jobs", () => { ); expect(vm.progress.wallPostsDeleted).toBe(1); }); + + it("unchecks the last item before clicking Next when delete is no longer allowed", async () => { + const vm = createMockFacebookViewModel(); + const mockWebview = vm.getWebview()!; + + let managePostsClicks = 0; + let isDialogOpen = false; + let isActionOptionsVisible = false; + const checkedItems = new Set(); + const itemCount = 9; + + vi.mocked(mockWebview.executeJavaScript).mockImplementation( + async (code: string) => { + if ( + code.includes( + `querySelectorAll('div[aria-label="Manage posts"][role="button"]')`, + ) + ) { + managePostsClicks++; + isDialogOpen = managePostsClicks <= 2; + isActionOptionsVisible = false; + return isDialogOpen; + } + + if ( + code.includes( + `document.querySelector('div[aria-label="Manage posts"][role="dialog"]')`, + ) && + code.includes("return !!dialog;") + ) { + return isDialogOpen; + } + + if (code.includes("result.push({ listIndex, itemIndex });")) { + if (managePostsClicks === 1) { + return Array.from({ length: itemCount }, (_, itemIndex) => ({ + listIndex: 0, + itemIndex, + })); + } + + return []; + } + + if (code.includes("const shouldCheck = ")) { + const listMatch = code.match(/const list = lists\[(\d+)\];/); + const itemMatch = code.match(/const item = items\[(\d+)\];/); + const shouldCheckMatch = code.match( + /const shouldCheck = (true|false);/, + ); + + if (!listMatch || !itemMatch || !shouldCheckMatch) { + return false; + } + + const key = `${listMatch[1]}-${itemMatch[1]}`; + const shouldCheck = shouldCheckMatch[1] === "true"; + + // Regression guard: the real DOM exposes checkbox.checked, not aria-checked on the input. + const currentChecked = code.includes("checkbox.checked") + ? checkedItems.has(key) + : false; + + if (currentChecked !== shouldCheck) { + if (shouldCheck) { + checkedItems.add(key); + } else { + checkedItems.delete(key); + } + } + + return true; + } + + if (code.includes("checkbox instanceof HTMLInputElement")) { + const listMatch = code.match(/const list = lists\[(\d+)\];/); + const itemMatch = code.match(/const item = items\[(\d+)\];/); + + if (!listMatch || !itemMatch) { + return null; + } + + return checkedItems.has(`${listMatch[1]}-${itemMatch[1]}`); + } + + if (code.includes('text.startsWith("You can")')) { + return checkedItems.size <= 8 + ? "You can hide or delete the posts selected." + : "You can hide the posts selected."; + } + + if (code.includes(`aria-label="Next"`)) { + isActionOptionsVisible = true; + return true; + } + + if ( + code.includes("const hasActionOptions =") && + code.includes(`aria-label="Done"`) + ) { + return isActionOptionsVisible; + } + + if (code.includes("text.includes('delete posts')")) { + return checkedItems.size <= 8; + } + + if (code.includes(`aria-label="Done"`)) { + isDialogOpen = false; + isActionOptionsVisible = false; + return true; + } + + return false; + }, + ); + + await DeleteJobs.runJobDeleteWallPosts(vm, 3); + + expect(vm.progress.wallPostsDeleted).toBe(8); + expect(vm.error).not.toHaveBeenCalledWith( + AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, + expect.anything(), + expect.anything(), + ); + expect(vm.log).toHaveBeenCalledWith( + "runJobDeleteWallPosts", + 'Selected 8 items for action "delete"', + ); + }); }); describe("parseActions", () => { diff --git a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts index 488eb82c..4bb97989 100644 --- a/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts +++ b/src/renderer/src/view_models/FacebookViewModel/jobs_delete.ts @@ -130,6 +130,134 @@ async function getActionDescription(vm: FacebookViewModel): Promise { type PostAction = "delete" | "untag" | "hide"; +async function getCheckboxState( + vm: FacebookViewModel, + listIndex: number, + itemIndex: number, +): Promise { + const result = await vm.safeExecuteJavaScript( + `(() => { + const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); + if (!dialog) return null; + + const lists = dialog.querySelectorAll('div[role="list"]'); + if (${listIndex} >= lists.length) return null; + + const list = lists[${listIndex}]; + const items = list.querySelectorAll('div[role="listitem"]'); + if (${itemIndex} >= items.length) return null; + + const item = items[${itemIndex}]; + const checkbox = item.querySelector('input[type="checkbox"]'); + const checkboxControl = item.querySelector('[role="checkbox"]'); + const ariaChecked = + checkboxControl?.getAttribute('aria-checked') ?? + checkbox?.getAttribute('aria-checked'); + + if (ariaChecked === 'true') return true; + if (ariaChecked === 'false') return false; + if (checkbox instanceof HTMLInputElement) return checkbox.checked; + + return null; + })()`, + "getCheckboxState", + ); + + return result.success ? result.value : null; +} + +async function waitForCheckboxState( + vm: FacebookViewModel, + listIndex: number, + itemIndex: number, + expectedChecked: boolean, + timeoutMs: number = 5000, +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const checked = await getCheckboxState(vm, listIndex, itemIndex); + if (checked === expectedChecked) { + return true; + } + await vm.sleep(200); + } + + return false; +} + +async function waitForActionDescriptionStable( + vm: FacebookViewModel, + timeoutMs: number = 5000, +): Promise { + const startTime = Date.now(); + let lastDescription = ""; + + while (Date.now() - startTime < timeoutMs) { + const description = await getActionDescription(vm); + if (description !== "" && description === lastDescription) { + return description; + } + lastDescription = description; + await vm.sleep(200); + } + + return lastDescription; +} + +async function waitForBatchAction( + vm: FacebookViewModel, + expectedAction: PostAction, + timeoutMs: number = 5000, +): Promise<{ success: boolean; actionDescription: string }> { + const startTime = Date.now(); + let lastDescription = ""; + + while (Date.now() - startTime < timeoutMs) { + const actionDescription = await getActionDescription(vm); + lastDescription = actionDescription; + + if ( + getHighestPriority(parseActions(actionDescription)) === expectedAction + ) { + return { success: true, actionDescription }; + } + + await vm.sleep(200); + } + + return { success: false, actionDescription: lastDescription }; +} + +async function waitForActionOptionsDialog( + vm: FacebookViewModel, + timeoutMs: number = 10000, +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const result = await vm.safeExecuteJavaScript( + `(() => { + const dialog = document.querySelector('div[aria-label="Manage posts"][role="dialog"]'); + if (!dialog) return false; + + const hasActionOptions = dialog.querySelector('div[aria-disabled]'); + const hasDoneButton = dialog.querySelector('div[aria-label="Done"][role="button"]'); + return Boolean(hasActionOptions && hasDoneButton); + })()`, + "waitForActionOptionsDialog", + ); + + if (result.success && result.value) { + return true; + } + + await vm.sleep(200); + } + + return false; +} + /** * Parse the available actions from an action description string. * e.g. "You can hide or delete the posts selected." -> ['delete', 'hide'] @@ -137,6 +265,10 @@ type PostAction = "delete" | "untag" | "hide"; * "You can hide the posts selected." -> ['hide'] */ export function parseActions(actionDescription: string): PostAction[] { + if (typeof actionDescription !== "string") { + return []; + } + const actions: PostAction[] = []; const text = actionDescription.toLowerCase(); if (text.includes("delete")) actions.push("delete"); @@ -179,17 +311,34 @@ async function toggleCheckbox( const item = items[${itemIndex}]; const checkbox = item.querySelector('input[type="checkbox"]'); - if (!checkbox) return false; + const checkboxControl = item.querySelector('[role="checkbox"]'); + if (!checkbox && !checkboxControl) return false; + + const ariaChecked = + checkboxControl?.getAttribute('aria-checked') ?? + checkbox?.getAttribute('aria-checked'); + let isChecked; + + if (ariaChecked === 'true') { + isChecked = true; + } else if (ariaChecked === 'false') { + isChecked = false; + } else if (checkbox instanceof HTMLInputElement) { + isChecked = checkbox.checked; + } else { + return false; + } - const isChecked = checkbox.getAttribute('aria-checked') === 'true'; const shouldCheck = ${shouldCheck}; + const clickTarget = checkboxControl ?? checkbox; + if (!clickTarget) return false; // Only click if we need to change the state if (isChecked !== shouldCheck) { - checkbox.click(); + clickTarget.click(); return true; } - return false; + return true; })()`, "toggleCheckbox", ); @@ -520,11 +669,22 @@ export async function runJobDeleteWallPosts( continue; } - // Wait a moment for the UI to update - await vm.sleep(300); + const checkboxChecked = await waitForCheckboxState( + vm, + listIndex, + itemIndex, + true, + ); + if (!checkboxChecked) { + vm.log( + "runJobDeleteWallPosts", + `Timed out waiting for item [${listIndex}][${itemIndex}] to become checked`, + ); + continue; + } // Read the combined action description (reflects all currently-checked items) - const actionDescription = await getActionDescription(vm); + const actionDescription = await waitForActionDescriptionStable(vm); vm.log( "runJobDeleteWallPosts", `Action description: "${actionDescription}"`, @@ -543,7 +703,7 @@ export async function runJobDeleteWallPosts( `Item [${listIndex}][${itemIndex}] has unrecognized action description, unchecking`, ); await toggleCheckbox(vm, listIndex, itemIndex, false); - await vm.sleep(300); + await waitForCheckboxState(vm, listIndex, itemIndex, false); continue; } batchAction = combinedPriority; @@ -566,7 +726,33 @@ export async function runJobDeleteWallPosts( `Item [${listIndex}][${itemIndex}] changes priority from "${batchAction}" to "${combinedPriority}", unchecking and stopping`, ); await toggleCheckbox(vm, listIndex, itemIndex, false); - await vm.sleep(300); + const checkboxUnchecked = await waitForCheckboxState( + vm, + listIndex, + itemIndex, + false, + ); + if (!checkboxUnchecked) { + vm.log( + "runJobDeleteWallPosts", + `Timed out waiting for item [${listIndex}][${itemIndex}] to become unchecked`, + ); + } + + const batchActionRestored = await waitForBatchAction(vm, batchAction); + if (!batchActionRestored.success) { + await reportDeleteWallPostsError( + vm, + jobIndex, + AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, + { + batchNumber, + message: `Batch action did not return to "${batchAction}" after unchecking item [${listIndex}][${itemIndex}]`, + actionDescription: batchActionRestored.actionDescription, + }, + ); + return; + } break; } } @@ -582,8 +768,31 @@ export async function runJobDeleteWallPosts( break; } + if (batchAction === null) { + vm.log( + "runJobDeleteWallPosts", + "Checked items were selected but no batch action was determined", + ); + break; + } + await vm.waitForPause(); + const batchActionReady = await waitForBatchAction(vm, batchAction); + if (!batchActionReady.success) { + await reportDeleteWallPostsError( + vm, + jobIndex, + AutomationErrorType.facebook_runJob_deleteWallPosts_SelectDeleteOptionFailed, + { + batchNumber, + message: `Action description did not settle on "${batchAction}" before clicking Next`, + actionDescription: batchActionReady.actionDescription, + }, + ); + return; + } + // Click the Next button vm.log("runJobDeleteWallPosts", "Clicking Next button"); const nextClicked = await clickNextButton(vm); @@ -601,7 +810,19 @@ export async function runJobDeleteWallPosts( } // Wait for the dialog to update with the action options - await vm.sleep(1000); + const actionOptionsReady = await waitForActionOptionsDialog(vm); + if (!actionOptionsReady) { + await reportDeleteWallPostsError( + vm, + jobIndex, + AutomationErrorType.facebook_runJob_deleteWallPosts_DialogNotFound, + { + batchNumber, + message: "Action options did not appear after clicking Next", + }, + ); + return; + } await vm.waitForPause();