diff --git a/.gitignore b/.gitignore index 62c1b09..65ceac2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .worktrees/ .old-reference/ +.local/ dist/ +dist-release/ +release/ node_modules/ # Local debug captures diff --git a/README.md b/README.md index 0b86272..512e9e4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ npm test npm run build ``` +## Release Build + +```bash +npm run build:release +npm run package:internal +``` + +- `npm run build` outputs the development bundle to `dist/` +- `npm run build:release` outputs the internal distribution bundle to `dist-release/` +- `npm run package:internal` creates `release/star-chart-search-enhancer-internal.zip` +- The extension ID is fixed to `pkjopdibdnomhogjheclhnknmejccffg` + ## Load The Extension 1. Run `npm run build` @@ -37,7 +49,7 @@ Replace these before real sign-in testing: - `apiResource` - Any extra scopes beyond `openid`, `profile`, and `offline_access` -The popup dev panel is controlled by `enableDevAuthPanel`. +The popup dev panel is controlled by `enableDevAuthPanel` and is disabled by default. ## Popup Behavior @@ -66,7 +78,7 @@ The popup dev panel is controlled by `enableDevAuthPanel`. 6. Click `提交批次` 7. Enter a batch name in the browser prompt 8. Confirm the toolbar shows `批次提交成功` -9. Confirm the mock batch response accepts the payload and reports the submitted `batchId` +9. Confirm the mock batch response accepts the payload ## Market Auth Gate @@ -91,7 +103,6 @@ When the market page is opened without a valid auth state, the content script re "creatorName": "王少卿", "resource": "https://talent-search.intelligrow.cn", "batchName": "自动验证批次", - "batchId": "p7pdhhtde8kj-<提交当时的 ISO 时间戳>", "createdAt": "<提交当时的 ISO 时间戳>", "authors": [ { "authorId": "7041184989643276324", "authorName": "旖旖小虎🐯" }, @@ -116,4 +127,7 @@ When the market page is opened without a valid auth state, the content script re { "authorId": "7319160797236559882", "authorName": "郑皓文" } ] } -``` \ No newline at end of file +``` + +Internal distribution steps are documented in +`docs/internal-extension-distribution.md`. diff --git a/docs/internal-extension-distribution.md b/docs/internal-extension-distribution.md new file mode 100644 index 0000000..6d1cf4f --- /dev/null +++ b/docs/internal-extension-distribution.md @@ -0,0 +1,31 @@ +# Internal Extension Distribution + +## Fixed Extension ID + +- Manifest key is fixed in the project. +- The unpacked extension ID is `pkjopdibdnomhogjheclhnknmejccffg`. +- Update Logto with: + - `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/callback` + - `https://pkjopdibdnomhogjheclhnknmejccffg.chromiumapp.org/` + - `chrome-extension://pkjopdibdnomhogjheclhnknmejccffg` + +## Internal Package + +1. Run `npm test`. +2. Run `npm run package:internal`. +3. Send `release/star-chart-search-enhancer-internal.zip` to coworkers. + +## Coworker Install Steps + +1. Unzip `star-chart-search-enhancer-internal.zip`. +2. Open `chrome://extensions`. +3. Enable developer mode. +4. Click `Load unpacked`. +5. Select the unzipped folder. +6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`. + +## Notes + +- Keep `.local/extension-key.pem` private and backed up internally. +- Do not commit or share the private key with people who only need to install the extension. +- If the batch submit backend changes away from `192.168.31.21:8083`, update `scripts/manifest.mjs` before packaging. diff --git a/package.json b/package.json index 8b95f4d..527b6db 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "private": true, "scripts": { "build": "node scripts/build.mjs", + "build:release": "BUILD_TARGET=release node scripts/build.mjs", "mock:protected-api": "node scripts/mock-protected-api.mjs", + "package:internal": "npm run build:release && node scripts/package-release.mjs", + "package:release": "npm run build:release && node scripts/package-release.mjs", "test": "vitest run --passWithNoTests", "test:watch": "vitest --passWithNoTests" }, diff --git a/scripts/build.mjs b/scripts/build.mjs index d6179b1..c6d01af 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,12 +1,17 @@ -import { cp, mkdir, rm } from "node:fs/promises"; +import { cp, mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "tsup"; +import { createManifest } from "./manifest.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, ".."); -const distDir = path.join(projectRoot, "dist"); +const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development"; +const distDir = path.join( + projectRoot, + buildTarget === "release" ? "dist-release" : "dist" +); await rm(distDir, { recursive: true, force: true }); await mkdir(path.join(distDir, "content"), { recursive: true }); @@ -68,11 +73,16 @@ await build({ } }); -await cp( - path.join(projectRoot, "src/manifest.json"), - path.join(distDir, "manifest.json") +await writeFile( + path.join(distDir, "manifest.json"), + `${JSON.stringify(createManifest({ target: buildTarget }), null, 2)}\n` ); await cp( path.join(projectRoot, "src/popup/index.html"), path.join(distDir, "popup/index.html") ); +await cp( + path.join(projectRoot, "src/assets"), + path.join(distDir, "assets"), + { recursive: true } +); diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs new file mode 100644 index 0000000..1de02bf --- /dev/null +++ b/scripts/manifest.mjs @@ -0,0 +1,76 @@ +const sharedIcons = { + 16: "assets/icons/icon-16.png", + 32: "assets/icons/icon-32.png", + 48: "assets/icons/icon-48.png", + 128: "assets/icons/icon-128.png" +}; +const extensionKey = + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB"; + +const sharedManifest = { + action: { + default_icon: { + 16: sharedIcons[16], + 32: sharedIcons[32] + }, + default_popup: "popup/index.html" + }, + background: { + service_worker: "background/index.js" + }, + content_scripts: [ + { + js: ["content/index.js"], + matches: [ + "https://xingtu.cn/ad/creator/market*", + "https://*.xingtu.cn/ad/creator/market*" + ], + run_at: "document_start" + } + ], + description: "Bootstraps the Xingtu creator market content script.", + icons: sharedIcons, + key: extensionKey, + manifest_version: 3, + name: "Star Chart Search Enhancer", + permissions: ["downloads", "identity", "storage"], + version: "0.2.0421.2", + web_accessible_resources: [ + { + matches: [ + "https://xingtu.cn/*", + "https://*.xingtu.cn/*" + ], + resources: ["content/market-page-bridge.js"] + } + ] +}; + +const hostPermissionsByTarget = { + development: [ + "http://*/*", + "https://login-api.intelligrow.cn/*", + "http://127.0.0.1:4319/*", + "https://*/*" + ], + release: [ + "https://xingtu.cn/ad/creator/market*", + "https://*.xingtu.cn/ad/creator/market*", + "https://login-api.intelligrow.cn/*", + "https://talent-search.intelligrow.cn/*", + "http://192.168.31.21:8083/*" + ] +}; + +export function createManifest(options = {}) { + const target = options.target ?? "development"; + const hostPermissions = hostPermissionsByTarget[target]; + if (!hostPermissions) { + throw new Error(`Unsupported manifest target: ${target}`); + } + + return { + ...sharedManifest, + host_permissions: hostPermissions + }; +} diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs new file mode 100644 index 0000000..96ff8fd --- /dev/null +++ b/scripts/package-release.mjs @@ -0,0 +1,24 @@ +import { mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); +const sourceDir = path.join(projectRoot, "dist-release"); +const releaseDir = path.join(projectRoot, "release"); +const archivePath = path.join( + releaseDir, + "star-chart-search-enhancer-internal.zip" +); + +await mkdir(releaseDir, { recursive: true }); +await rm(archivePath, { force: true }); +await execFileAsync("zip", ["-X", "-r", archivePath, "."], { + cwd: sourceDir +}); + +console.log(`Internal archive created at ${archivePath}`); diff --git a/src/assets/icons/icon-128.png b/src/assets/icons/icon-128.png new file mode 100644 index 0000000..8cc212d Binary files /dev/null and b/src/assets/icons/icon-128.png differ diff --git a/src/assets/icons/icon-16.png b/src/assets/icons/icon-16.png new file mode 100644 index 0000000..b2d62c6 Binary files /dev/null and b/src/assets/icons/icon-16.png differ diff --git a/src/assets/icons/icon-32.png b/src/assets/icons/icon-32.png new file mode 100644 index 0000000..c87d772 Binary files /dev/null and b/src/assets/icons/icon-32.png differ diff --git a/src/assets/icons/icon-48.png b/src/assets/icons/icon-48.png new file mode 100644 index 0000000..ed5f38f Binary files /dev/null and b/src/assets/icons/icon-48.png differ diff --git a/src/assets/icons/icon-source.svg b/src/assets/icons/icon-source.svg new file mode 100644 index 0000000..4c81685 --- /dev/null +++ b/src/assets/icons/icon-source.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/content/market/batch-payload.ts b/src/content/market/batch-payload.ts index fbff457..a6268c6 100644 --- a/src/content/market/batch-payload.ts +++ b/src/content/market/batch-payload.ts @@ -6,7 +6,6 @@ export interface BatchPayload { authorId: string; authorName: string; }>; - batchId: string; batchName: string; createdAt: string; creatorName: string; @@ -40,7 +39,6 @@ export function createBatchPayload(options: { authorId: record.authorId, authorName: record.authorName })), - batchId: `${logtoUserId}-${options.createdAt}`, batchName, createdAt: options.createdAt, creatorName: diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 390f717..ae6d8ce 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -79,15 +79,12 @@ export function createMarketController(options: CreateMarketControllerOptions) { options.submitBatch ?? ((payload: BatchPayload) => readBatchSubmitAck(sendRuntimeMessage, payload)); + let activeProgressLabel = "导出中"; + let shouldShowDetailedProgress = true; const exportRangeController = createExportRangeController({ document: options.document, onProgress: ({ currentPage, totalPages }) => { - setToolbarExportStatus( - toolbar, - totalPages - ? `导出中 ${currentPage}/${totalPages} 页...` - : `导出中 第${currentPage}页...` - ); + updateToolbarProgress(currentPage, totalPages); }, prepareCurrentPageForExport: prepareCurrentPageForExport, readCurrentPageRecords: () => getVisibleOrderedRecords(), @@ -97,12 +94,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { const silentExportController = createSilentExportController({ document: options.document, onProgress: ({ currentPage, totalPages }) => { - setToolbarExportStatus( - toolbar, - totalPages - ? `导出中 ${currentPage}/${totalPages} 页...` - : `导出中 第${currentPage}页...` - ); + updateToolbarProgress(currentPage, totalPages); } }); let activeSort: MarketSortState | undefined; @@ -155,7 +147,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, true); try { const records = filterRecordsBySelection( - await exportRecords(exportTarget.target) + await exportRecords(exportTarget.target, "导出中", { + showDetailedProgress: selectedAuthorIds.size === 0 + }) ); options.onCsvReady?.(buildCsv(records)); setToolbarExportStatus(toolbar, ""); @@ -477,8 +471,13 @@ export function createMarketController(options: CreateMarketControllerOptions) { async function exportRecords( target: MarketExportTarget, - inProgressLabel = "导出中" + inProgressLabel = "导出中", + progressOptions: { + showDetailedProgress?: boolean; + } = {} ): Promise { + activeProgressLabel = inProgressLabel; + shouldShowDetailedProgress = progressOptions.showDetailedProgress ?? true; setToolbarExportStatus(toolbar, `${inProgressLabel}...`); if (target.mode === "count" && target.pageCount <= 1) { @@ -499,6 +498,23 @@ export function createMarketController(options: CreateMarketControllerOptions) { return exportRangeController.exportRecords(target); } + function updateToolbarProgress( + currentPage: number, + totalPages: number | undefined + ): void { + if (!shouldShowDetailedProgress) { + setToolbarExportStatus(toolbar, `${activeProgressLabel}...`); + return; + } + + setToolbarExportStatus( + toolbar, + totalPages + ? `${activeProgressLabel} ${currentPage}/${totalPages} 页...` + : `${activeProgressLabel} 第${currentPage}页...` + ); + } + function filterRecordsBySelection(records: MarketRecord[]): MarketRecord[] { if (selectedAuthorIds.size === 0) { return records; diff --git a/src/manifest.json b/src/manifest.json index bb53cd5..d0aafba 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -3,7 +3,14 @@ "name": "Star Chart Search Enhancer", "version": "0.2.0421.2", "description": "Bootstraps the Xingtu creator market content script.", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB", "permissions": ["downloads", "identity", "storage"], + "icons": { + "16": "assets/icons/icon-16.png", + "32": "assets/icons/icon-32.png", + "48": "assets/icons/icon-48.png", + "128": "assets/icons/icon-128.png" + }, "host_permissions": [ "http://*/*", "https://login-api.intelligrow.cn/*", @@ -11,6 +18,10 @@ "https://*/*" ], "action": { + "default_icon": { + "16": "assets/icons/icon-16.png", + "32": "assets/icons/icon-32.png" + }, "default_popup": "popup/index.html" }, "background": { diff --git a/src/shared/auth-config.ts b/src/shared/auth-config.ts index 99fcd5c..3390c2c 100644 --- a/src/shared/auth-config.ts +++ b/src/shared/auth-config.ts @@ -9,7 +9,7 @@ export interface AuthConfig { const defaultAuthConfig: AuthConfig = { apiResource: "https://talent-search.intelligrow.cn", appId: "i4jkllbvih0554r4n0fd3", - enableDevAuthPanel: true, + enableDevAuthPanel: false, logtoEndpoint: "https://login-api.intelligrow.cn", scopes: ["openid", "profile", "offline_access", "talent-search:read"] }; diff --git a/tests/auth-config.test.ts b/tests/auth-config.test.ts index 2fca477..4219109 100644 --- a/tests/auth-config.test.ts +++ b/tests/auth-config.test.ts @@ -7,7 +7,7 @@ describe("auth-config", () => { expect(readAuthConfig()).toEqual({ apiResource: "https://talent-search.intelligrow.cn", appId: "i4jkllbvih0554r4n0fd3", - enableDevAuthPanel: true, + enableDevAuthPanel: false, logtoEndpoint: "https://login-api.intelligrow.cn", scopes: [ "openid", diff --git a/tests/background-index.test.ts b/tests/background-index.test.ts index 5d166f9..64ab09f 100644 --- a/tests/background-index.test.ts +++ b/tests/background-index.test.ts @@ -164,7 +164,6 @@ describe("background-index", () => { { payload: { authors: [{ authorId: "111", authorName: "达人A" }], - batchId: "批次A-2026-04-22T12:30:00.000Z", batchName: "批次A", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", @@ -182,9 +181,10 @@ describe("background-index", () => { expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ - batchId: "批次A-2026-04-22T12:30:00.000Z" + batchName: "批次A" }) ); + expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId"); expect(sendResponse).toHaveBeenCalledWith({ ok: true, type: "batch:ack", diff --git a/tests/batch-payload.test.ts b/tests/batch-payload.test.ts index b93d8a0..22ab26b 100644 --- a/tests/batch-payload.test.ts +++ b/tests/batch-payload.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createBatchPayload } from "../src/content/market/batch-payload"; describe("batch-payload", () => { - test("builds a batch id from the user id and timestamp", () => { + test("builds the batch payload without a client-side batch id", () => { const payload = createBatchPayload({ authState: { isAuthenticated: true, @@ -26,7 +26,6 @@ describe("batch-payload", () => { { authorId: "111", authorName: "达人A" }, { authorId: "222", authorName: "达人B" } ], - batchId: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z", batchName: "618达人筛选第一批", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", diff --git a/tests/batch-submit-client.test.ts b/tests/batch-submit-client.test.ts index ddc8c7e..fbef987 100644 --- a/tests/batch-submit-client.test.ts +++ b/tests/batch-submit-client.test.ts @@ -36,7 +36,6 @@ describe("batch-submit-client", () => { await client.submitBatch({ authors: [{ authorId: "111", authorName: "达人A" }], - batchId: "批次A-2026-04-22T12:30:00.000Z", batchName: "批次A", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", @@ -49,7 +48,6 @@ describe("batch-submit-client", () => { expect.objectContaining({ body: JSON.stringify({ authors: [{ authorId: "111", authorName: "达人A" }], - batchId: "批次A-2026-04-22T12:30:00.000Z", batchName: "批次A", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", @@ -87,7 +85,6 @@ describe("batch-submit-client", () => { await expect( client.submitBatch({ authors: [], - batchId: "批次A-2026-04-22T12:30:00.000Z", batchName: "批次A", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", @@ -115,7 +112,6 @@ describe("batch-submit-client", () => { await expect( client.submitBatch({ authors: [], - batchId: "批次A-2026-04-22T12:30:00.000Z", batchName: "批次A", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 97c0bac..80f8694 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import manifest from "../src/manifest.json"; +import { createManifest } from "../scripts/manifest.mjs"; describe("manifest", () => { test("injects the content script on the www Xingtu market page", () => { @@ -16,6 +17,9 @@ describe("manifest", () => { expect(manifest.permissions).toEqual( expect.arrayContaining(["downloads", "identity", "storage"]) ); + expect(manifest.key).toBe( + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB" + ); expect(manifest.host_permissions).toEqual( expect.arrayContaining([ "http://*/*", @@ -27,4 +31,38 @@ describe("manifest", () => { expect(manifest.background?.service_worker).toBe("background/index.js"); expect(manifest.action?.default_popup).toBe("popup/index.html"); }); + + test("builds a release manifest with narrowed host permissions", () => { + const releaseManifest = createManifest({ target: "release" }); + + expect(releaseManifest.permissions).toEqual( + expect.arrayContaining(["downloads", "identity", "storage"]) + ); + expect(releaseManifest.host_permissions).toEqual([ + "https://xingtu.cn/ad/creator/market*", + "https://*.xingtu.cn/ad/creator/market*", + "https://login-api.intelligrow.cn/*", + "https://talent-search.intelligrow.cn/*", + "http://192.168.31.21:8083/*" + ]); + expect(releaseManifest.host_permissions).not.toEqual( + expect.arrayContaining(["http://*/*", "https://*/*", "http://127.0.0.1:4319/*"]) + ); + }); + + test("builds a release manifest with extension icons", () => { + const releaseManifest = createManifest({ target: "release" }); + + expect(releaseManifest.icons).toEqual({ + "16": "assets/icons/icon-16.png", + "32": "assets/icons/icon-32.png", + "48": "assets/icons/icon-48.png", + "128": "assets/icons/icon-128.png" + }); + expect(releaseManifest.key).toBe(manifest.key); + expect(releaseManifest.action?.default_icon).toEqual({ + "16": "assets/icons/icon-16.png", + "32": "assets/icons/icon-32.png" + }); + }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 126bc81..b4278c2 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1535,6 +1535,117 @@ describe("market-content-entry", () => { ]); }); + test( + "selected export keeps a generic loading status while exporting the default paged range", + async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], + [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], + [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }], + [{ authorId: "666", authorName: "达人 F", price21To60s: "¥66,000" }] + ]; + const secondPageDeferred = createDeferred<{ + json(): Promise; + ok: boolean; + }>(); + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const buildCsv = vi.fn(() => "csv-output"); + const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number }; + const pageNumber = body.page ?? 1; + const response = { + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[pageNumber - 1] ?? []), + totalPages: 5 + } + }), + ok: true + }; + + if (pageNumber === 2) { + return secondPageDeferred.promise; + } + + return response; + }); + + ( + globalThis as typeof globalThis & { + fetch?: typeof fetchMock; + } + ).fetch = fetchMock; + document.documentElement.setAttribute( + "data-sces-market-request-snapshot", + JSON.stringify({ + body: JSON.stringify({ + page: 1 + }), + method: "POST", + url: "https://xingtu.cn/api/mock-market-search" + }) + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + + click('[data-plugin-export="button"]'); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ( + fetchMock.mock.calls.some(([, init]) => { + const body = JSON.parse( + String((init as RequestInit | undefined)?.body ?? "{}") + ) as { page?: number }; + return body.page === 2; + }) + ) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + await Promise.resolve(); + } + + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toBe("导出中..."); + + secondPageDeferred.resolve({ + json: async () => ({ + data: { + marketList: buildMarketListResponseRows(pages[1]), + totalPages: 5 + } + }), + ok: true + }); + + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ + "111" + ]); + }, + 15000 + ); + test("selected export falls back to all creators in the current range when no selection matches", async () => { const pages = [ [ @@ -1610,11 +1721,11 @@ describe("market-content-entry", () => { expect(promptBatchName).toHaveBeenCalledTimes(1); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ - batchId: expect.stringContaining("p7pdhhtde8kj-"), batchName: "618达人筛选第一批", logtoUserId: "p7pdhhtde8kj" }) ); + expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId"); }); test("selected batch submit uses only creators selected in the current range", async () => { diff --git a/tests/mock-protected-api.test.ts b/tests/mock-protected-api.test.ts index 8081dfa..0037081 100644 --- a/tests/mock-protected-api.test.ts +++ b/tests/mock-protected-api.test.ts @@ -53,7 +53,6 @@ describe("mock-protected-api", () => { const response = await fetch(`${server.baseUrl}/api/mock/batches`, { body: JSON.stringify({ authors: [{ authorId: "111", authorName: "达人A" }], - batchId: "批次A-2026-04-22T12:30:00.000Z", batchName: "批次A", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", @@ -71,7 +70,7 @@ describe("mock-protected-api", () => { await expect(response.json()).resolves.toEqual( expect.objectContaining({ acceptedCount: 1, - batchId: "批次A-2026-04-22T12:30:00.000Z", + batchId: null, ok: true, source: "mock-batch-submit" })