diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 37c1945..0952fb5 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -16,6 +16,21 @@ import { promptForAudienceProfileFields } from "./audience-profile-field-dialog" import { promptForAuthorIds } from "./author-id-dialog"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; +import { promptForFavoriteFolderName } from "./favorite-folder-dialog"; +import { syncFavoriteRowPickers } from "./favorite-row-picker"; +import { + createFavoritesDrawer, + type FavoritesDrawerController +} from "./favorites-drawer"; +import { toFavoriteMarketRecords } from "./favorites-import"; +import { + createChromeFavoritesStorage, + createFavoritesRepository, + type FavoriteCreatorInput, + type FavoriteFolder, + type FavoritesRepository, + type FavoritesStateV1 +} from "./favorites-store"; import { applyRowOrder, applyRowVisibility, @@ -79,7 +94,10 @@ export interface CreateMarketControllerOptions { options?: AudienceProfileCsvOptions ) => string; buildCsv?: (records: MarketRecord[]) => string; + confirmFavoriteDelete?: (message: string) => boolean; + confirmFavoriteImport?: (message: string) => boolean; document: Document; + favoritesRepository?: FavoritesRepository; getAuthState?: () => Promise; loadAuthorBaseInfo?: (authorId: string) => Promise; loadBusinessAbility?: ( @@ -104,6 +122,12 @@ export interface CreateMarketControllerOptions { onCsvReady?: (csv: string, filename?: string) => void; promptAuthorIds?: () => Promise | string | null; promptBatchName?: () => Promise | string | null; + promptFavoriteFolderName?: ( + options: { + initialValue?: string; + title: "新建收藏夹" | "重命名收藏夹"; + } + ) => Promise | string | null; resultStore?: ReturnType; submitBatch?: (payload: BatchPayload) => Promise; window: Window; @@ -139,6 +163,15 @@ export function createMarketController(options: CreateMarketControllerOptions) { const loadBusinessAbility = options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility; const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage)); + const favoritesRepository = + options.favoritesRepository ?? + createFavoritesRepository({ + storage: createChromeFavoritesStorage() + }); + const confirmFavoriteImport = + options.confirmFavoriteImport ?? options.window.confirm.bind(options.window); + const confirmFavoriteDelete = + options.confirmFavoriteDelete ?? options.window.confirm.bind(options.window); const mutationObserverFactory = options.mutationObserverFactory ?? ((callback: MutationCallback) => new MutationObserver(callback)); @@ -148,6 +181,12 @@ export function createMarketController(options: CreateMarketControllerOptions) { const promptAuthorIds = options.promptAuthorIds ?? (() => promptForAuthorIds(options.document)); + const promptFavoriteFolderName = + options.promptFavoriteFolderName ?? + ((dialogOptions: { + initialValue?: string; + title: "新建收藏夹" | "重命名收藏夹"; + }) => promptForFavoriteFolderName(options.document, dialogOptions)); const submitBatch = options.submitBatch ?? ((payload: BatchPayload) => @@ -190,6 +229,25 @@ export function createMarketController(options: CreateMarketControllerOptions) { let scheduledSyncTimeoutId: number | null = null; const selectedAuthorIds = new Set(); let toolbar: ReturnType | undefined; + let cachedFavoritesState: FavoritesStateV1 | undefined; + let favoritesRefreshVersion = 0; + let favoriteActionButtons: HTMLButtonElement[] = []; + const favoritesDrawer: FavoritesDrawerController = createFavoritesDrawer( + options.document, + { + onCreateFolder: async () => { + await createFavoriteFolder(); + }, + onDeleteFolder: deleteFavoriteFolder, + onImportFolder: async (_folderId, authorIds) => { + await submitFavoriteAuthors(authorIds); + }, + onImportSelected: submitFavoriteAuthors, + onRemoveCreatorFromFolder: removeFavoriteCreatorFromFolder, + onRenameFolder: renameFavoriteFolder + }, + confirmFavoriteImport + ); const observer = mutationObserverFactory(() => { if (isDisposed) { return; @@ -207,10 +265,14 @@ export function createMarketController(options: CreateMarketControllerOptions) { const selectionControlsMissing = !options.document.querySelector('[data-market-selection-checkbox="row"]') || !options.document.querySelector('[data-market-selection-checkbox="header"]'); + const favoriteControlsMissing = + cachedFavoritesState !== undefined && + favoriteActionButtons.some((button) => !button.isConnected); if ( nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount && - !selectionControlsMissing + !selectionControlsMissing && + !favoriteControlsMissing ) { return; } @@ -457,7 +519,11 @@ export function createMarketController(options: CreateMarketControllerOptions) { }; toolbar = ensurePluginToolbar(options.document, toolbarHandlers); - const ready = runSyncCycle(); + const ready = (async () => { + await runSyncCycle(); + await refreshFavorites(); + await waitForDomSettled(); + })(); return { dispose() { @@ -467,6 +533,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { options.window.clearTimeout(scheduledSyncTimeoutId); scheduledSyncTimeoutId = null; } + favoritesDrawer.dispose(); }, ready }; @@ -634,10 +701,199 @@ export function createMarketController(options: CreateMarketControllerOptions) { applyRowOrder(table, records.map((record) => record.authorId)); bindSelectionControls(table); syncMarketSelectionState(table, selectedAuthorIds); + syncFavoriteRowPickersForCurrentTable(table); lastKnownPageSignature = readMarketPageSignature(options.document); }); } + async function refreshFavorites(): Promise { + const refreshVersion = ++favoritesRefreshVersion; + try { + const state = await favoritesRepository.read(); + if (isDisposed || refreshVersion !== favoritesRefreshVersion) { + return; + } + + cachedFavoritesState = state; + favoritesDrawer.setError(""); + favoritesDrawer.render(state); + runWithoutMutationSync(() => { + syncFavoriteRowPickersForCurrentTable(); + }); + } catch { + if (isDisposed || refreshVersion !== favoritesRefreshVersion) { + return; + } + + favoritesDrawer.setError("收藏夹暂不可用"); + } + } + + function syncFavoriteRowPickersForCurrentTable( + table = syncMarketTable(options.document) + ): void { + if (!cachedFavoritesState || !table) { + return; + } + + const folderIdsByAuthorId = new Map>(); + cachedFavoritesState.memberships.forEach((membership) => { + const folderIds = folderIdsByAuthorId.get(membership.authorId) ?? new Set(); + folderIds.add(membership.folderId); + folderIdsByAuthorId.set(membership.authorId, folderIds); + }); + const serializedCoreUserIds = readSerializedFavoriteCoreUserIds(options.document); + + syncFavoriteRowPickers({ + document: options.document, + folderIdsByAuthorId, + folders: cachedFavoritesState.folders, + onCreateFolder: createFavoriteFolder, + onSetCreatorFolderIds: setFavoriteCreatorFolderIds, + rows: table.rows.map((rowDom) => ({ + actionCell: rowDom.actionCell, + authorId: rowDom.authorId, + authorName: rowDom.authorName, + coreUserId: + (rowDom as MarketRowDom & { coreUserId?: string }).coreUserId ?? + serializedCoreUserIds.get(rowDom.authorId) + })) + }); + favoriteActionButtons = Array.from( + options.document.querySelectorAll( + '[data-sces-favorite-row-action="button"]' + ) + ); + } + + async function createFavoriteFolder(): Promise { + const name = await promptFavoriteFolderName({ title: "新建收藏夹" }); + if (name === null) { + return null; + } + + return runFavoriteStorageMutation(async () => { + const folder = await favoritesRepository.createFolder(name); + await refreshFavorites(); + return folder; + }); + } + + async function setFavoriteCreatorFolderIds( + creator: FavoriteCreatorInput, + folderIds: string[] + ): Promise { + await runFavoriteStorageMutation(async () => { + await favoritesRepository.setCreatorFolderIds(creator, folderIds); + await refreshFavorites(); + }); + } + + async function renameFavoriteFolder(folderId: string): Promise { + const folder = cachedFavoritesState?.folders.find((item) => item.id === folderId); + if (!folder) { + return; + } + + const name = await promptFavoriteFolderName({ + initialValue: folder.name, + title: "重命名收藏夹" + }); + if (name === null) { + return; + } + + await runFavoriteStorageMutation(async () => { + await favoritesRepository.renameFolder(folderId, name); + await refreshFavorites(); + }); + } + + async function deleteFavoriteFolder(folderId: string): Promise { + const folder = cachedFavoritesState?.folders.find((item) => item.id === folderId); + if (!folder) { + return; + } + + if (!confirmFavoriteDelete(`确定删除收藏夹“${folder.name}”吗?`)) { + return; + } + + await runFavoriteStorageMutation(async () => { + await favoritesRepository.deleteFolder(folderId); + await refreshFavorites(); + }); + } + + async function removeFavoriteCreatorFromFolder( + authorId: string, + folderId: string + ): Promise { + await runFavoriteStorageMutation(async () => { + await favoritesRepository.removeCreatorFromFolder(authorId, folderId); + await refreshFavorites(); + }); + } + + async function runFavoriteStorageMutation( + mutation: () => Promise + ): Promise { + favoritesDrawer.setBusy(true); + try { + return await mutation(); + } catch (error) { + favoritesDrawer.setError(readFavoriteOperationError(error)); + throw error; + } finally { + favoritesDrawer.setBusy(false); + } + } + + async function submitFavoriteAuthors(authorIds: string[]): Promise { + favoritesDrawer.setBusy(true); + try { + const requestedAuthorIds = new Set( + authorIds.map((authorId) => authorId.trim()).filter(Boolean) + ); + const state = await favoritesRepository.read(); + const records = toFavoriteMarketRecords( + state.creators.filter((creator) => requestedAuthorIds.has(creator.authorId)) + ); + if (records.length === 0) { + favoritesDrawer.setError("当前没有可导入的达人"); + return; + } + + const batchName = await promptBatchName(); + if (batchName === null) { + return; + } + if (!batchName.trim()) { + favoritesDrawer.setError("请输入批次名称"); + return; + } + + const authState = await getAuthState(); + if (!authState.isAuthenticated) { + throw new Error("请先登录插件"); + } + + await submitBatch( + createBatchPayload({ + authState, + batchName, + createdAt: new Date().toISOString(), + records + }) + ); + favoritesDrawer.setError("批次提交成功"); + } catch (error) { + favoritesDrawer.setError(readFavoriteBatchError(error)); + } finally { + favoritesDrawer.setBusy(false); + } + } + function bindSelectionControls(table: ReturnType): void { if (!table) { return; @@ -1629,6 +1885,58 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { }; } +function readSerializedFavoriteCoreUserIds(document: Document): Map { + const coreUserIds = new Map(); + const serializedRows = document.documentElement.getAttribute("data-sces-market-rows"); + if (!serializedRows) { + return coreUserIds; + } + + try { + const rows = JSON.parse(serializedRows) as unknown; + if (!Array.isArray(rows)) { + return coreUserIds; + } + + rows.forEach((row) => { + if (!row || typeof row !== "object") { + return; + } + + const authorId = (row as { authorId?: unknown }).authorId; + const coreUserId = (row as { coreUserId?: unknown }).coreUserId; + if ( + typeof authorId === "string" && + authorId.trim() && + typeof coreUserId === "string" && + coreUserId.trim() + ) { + coreUserIds.set(authorId.trim(), coreUserId.trim()); + } + }); + } catch { + return coreUserIds; + } + + return coreUserIds; +} + +function readFavoriteOperationError(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return "收藏夹操作失败,请稍后重试"; +} + +function readFavoriteBatchError(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return "批次提交失败,请稍后重试"; +} + function getNextSortState( currentSort: MarketSortState | undefined, field: MarketSortState["field"] diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index da159be..ec28c86 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createMarketResultStore } from "../src/content/market/result-store"; +import { + createFavoritesRepository, + type FavoritesStorage +} from "../src/content/market/favorites-store"; import type { SpreadInfoConfig } from "../src/content/market/types"; const disposers: Array<() => void> = []; @@ -20,6 +24,7 @@ describe("market-content-entry", () => { }); afterEach(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); vi.doUnmock("../src/content/market/index"); delete ( globalThis as typeof globalThis & { @@ -4544,6 +4549,291 @@ describe("market-content-entry", () => { ?.textContent ).toBe("0.8% - 1%"); }); + + test("mounts favorites controls without replacing the native action text", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: createTestFavoritesRepository(), + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + + await controller.ready; + + expect(document.querySelector('[data-sces-favorites-tab="button"]')).not.toBeNull(); + expect(document.querySelector('[data-sces-favorites-drawer="root"]')).not.toBeNull(); + expect( + document.querySelector('[data-testid="action-cell-111"] [data-sces-favorite-row-action="button"]') + ).not.toBeNull(); + expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单"); + }); + + test("saves the serialized market row identity through its favorite picker", async () => { + document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ + { authorName: "达人 A", price21To60s: "¥11,000" } + ]); + document.documentElement.setAttribute( + "data-sces-market-rows", + JSON.stringify([ + { authorId: "111", authorName: "达人 A", coreUserId: "core-111" } + ]) + ); + const repository = createTestFavoritesRepository(); + const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository)); + repository.setCreatorFolderIds = setCreatorFolderIds; + const promptFavoriteFolderName = vi.fn(() => "母婴优质达人"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + promptFavoriteFolderName, + window + })); + + await controller.ready; + click('[data-sces-favorite-row-action="button"]'); + click('[data-sces-favorite-row-picker="create-folder"]'); + await waitForMockCall(setCreatorFolderIds); + await (setCreatorFolderIds.mock.results[0]?.value as Promise); + await flush(); + + expect(repository.getStoredState()).toMatchObject({ + creators: [ + { + authorId: "111", + authorName: "达人 A", + coreUserId: "core-111" + } + ], + folders: [{ name: "母婴优质达人" }] + }); + expect(promptFavoriteFolderName).toHaveBeenCalledWith({ title: "新建收藏夹" }); + expect( + document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState + ).toBe("saved"); + }); + + test("renders a creator once in all favorites and in its selected folder", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const firstFolder = await repository.createFolder("母婴优质达人"); + const secondFolder = await repository.createFolder("品牌合作"); + await repository.setCreatorFolderIds( + { authorId: "111", authorName: "达人 A", coreUserId: "core-111" }, + [firstFolder.id, secondFolder.id] + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + + await controller.ready; + click('[data-sces-favorites-tab="button"]'); + expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1); + + click(`[data-sces-favorites-folder-id="${secondFolder.id}"]`); + expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1); + }); + + test("submits selected favorites without scanning or exporting the market range", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const folder = await repository.createFolder("母婴优质达人"); + await repository.setCreatorFolderIds( + { authorId: "111", authorName: "达人 A", coreUserId: "core-111" }, + [folder.id] + ); + const promptBatchName = vi.fn(() => "收藏夹批次"); + const submitBatch = vi.fn(async () => ({ ok: true })); + const nextPage = document.querySelector('[data-testid="next-page"]') as HTMLButtonElement; + const onNextPage = vi.fn(); + nextPage.addEventListener("click", onNextPage); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + getAuthState: async () => authenticatedTestState(), + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + promptBatchName, + submitBatch, + window + })); + + await controller.ready; + click('[data-sces-favorites-tab="button"]'); + const selected = document.querySelector( + '[data-sces-favorites-select-author-id="111"]' + ) as HTMLInputElement; + selected.click(); + click('[data-sces-favorites-drawer="import-selected"]'); + await waitForMockCall(submitBatch); + + expect(promptBatchName).toHaveBeenCalledTimes(1); + expect(submitBatch).toHaveBeenCalledWith( + expect.objectContaining({ + authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }], + batchName: "收藏夹批次" + }) + ); + expect(onNextPage).not.toHaveBeenCalled(); + expect(repository.getStoredState()).toMatchObject({ + creators: [{ authorId: "111" }] + }); + expect( + document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent + ).toBe("批次提交成功"); + }); + + test("does not prompt or submit a full folder when favorite import confirmation is declined", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const folder = await repository.createFolder("母婴优质达人"); + await repository.setCreatorFolderIds({ authorId: "111", authorName: "达人 A" }, [folder.id]); + const promptBatchName = vi.fn(() => "不应调用"); + const submitBatch = vi.fn(async () => ({ ok: true })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + confirmFavoriteImport: () => false, + document, + favoritesRepository: repository, + getAuthState: async () => authenticatedTestState(), + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + promptBatchName, + submitBatch, + window + })); + + await controller.ready; + click('[data-sces-favorites-tab="button"]'); + click(`[data-sces-favorites-folder-id="${folder.id}"]`); + click('[data-sces-favorites-drawer="import-folder"]'); + await flush(); + + expect(promptBatchName).not.toHaveBeenCalled(); + expect(submitBatch).not.toHaveBeenCalled(); + }); + + test("honors favorite folder deletion confirmation", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + const folder = await repository.createFolder("母婴优质达人"); + let shouldDelete = false; + const confirmFavoriteDelete = vi.fn(() => shouldDelete); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + confirmFavoriteDelete, + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + + await controller.ready; + click('[data-sces-favorites-tab="button"]'); + click(`[data-sces-favorites-folder-delete="${folder.id}"]`); + await flush(); + expect(repository.getStoredState()).toMatchObject({ folders: [{ id: folder.id }] }); + + shouldDelete = true; + click(`[data-sces-favorites-folder-delete="${folder.id}"]`); + await waitForCondition( + () => document.querySelector(`[data-sces-favorites-folder-delete="${folder.id}"]`) === null + ); + await flush(); + expect(repository.getStoredState()).toMatchObject({ folders: [] }); + expect(confirmFavoriteDelete).toHaveBeenLastCalledWith( + "确定删除收藏夹“母婴优质达人”吗?" + ); + }); + + test("shows a favorite mutation error without disabling CSV export", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + repository.createFolder = async () => { + throw new Error(); + }; + const buildCsv = vi.fn(() => "csv-output"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + onCsvReady: vi.fn(), + promptFavoriteFolderName: () => "母婴优质达人", + window + })); + + await controller.ready; + click('[data-sces-favorite-row-action="button"]'); + click('[data-sces-favorite-row-picker="create-folder"]'); + await waitForCondition( + () => + document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent === + "收藏夹操作失败,请稍后重试" + ); + + const exportButton = document.querySelector( + '[data-plugin-export="button"]' + ) as HTMLButtonElement; + expect(exportButton.disabled).toBe(false); + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + removeDefaultSpreadMetricFilter(); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 40, 50); + expect(buildCsv).toHaveBeenCalledWith(expect.any(Array)); + }); + + test("keeps the market toolbar available when favorites storage cannot be read", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const repository = createTestFavoritesRepository(); + repository.read = async () => { + throw new Error("storage unavailable"); + }; + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + favoritesRepository: repository, + loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }), + window + })); + + await expect(controller.ready).resolves.toBeUndefined(); + expect( + document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent + ).toBe("收藏夹暂不可用"); + expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); + expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); + }); }); function clearLocalStorage(): void { @@ -5970,6 +6260,39 @@ function trackController void }>(controller: T): T { return controller; } +function createTestFavoritesRepository(initial: unknown = undefined) { + let storedValue = initial; + let nextFolderId = 0; + const storage: FavoritesStorage = { + async get() { + return storedValue; + }, + async set(value) { + storedValue = value; + } + }; + + const repository = createFavoritesRepository({ + createId: () => `test-folder-${++nextFolderId}`, + now: () => "2026-07-17T00:00:00.000Z", + storage + }); + + return Object.assign(repository, { + getStoredState() { + return storedValue; + } + }); +} + +function authenticatedTestState() { + return { + isAuthenticated: true, + resource: "https://talent-search.intelligrow.cn", + userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" } + }; +} + function expectButtonDisabled(selector: string, expected: boolean) { const element = document.querySelector(selector) as HTMLButtonElement | null; if (!element) { @@ -6019,6 +6342,20 @@ async function waitForMockCall( } } +async function waitForCondition( + condition: () => boolean | Promise, + maxAttempts = 40 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (await condition()) { + return; + } + await flushWithTimers(); + } + + throw new Error("Timed out waiting for condition"); +} + function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void;