Compare commits

...

12 Commits

19 changed files with 1959 additions and 27 deletions

View File

@ -21,16 +21,65 @@ npm run build
## Current Scope
- Adds two after-search-rate columns to the Xingtu market list
- Adds a popup-based Logto auth entry
- Hydrates the current page immediately
- Provides plugin-owned filter, sort, and CSV export controls
- Gates the market tools until auth is available
- Triggers full-scan flow only when filter, sort, or export is used
## Auth Configuration
The Logto integration is wired with placeholder values in `src/shared/auth-config.ts`.
Replace these before real sign-in testing:
- `logtoEndpoint`
- `appId`
- `apiResource`
- Any extra scopes beyond `openid`, `profile`, and `offline_access`
The popup dev panel is controlled by `enableDevAuthPanel`.
## Popup Behavior
1. Load the unpacked extension from `dist/`
2. Click the extension icon
3. Confirm the popup shows `登录 Logto` when unauthenticated
4. After real Logto config is added, use the popup to sign in and sign out
## Protected API Mock Test
1. Set `enableDevAuthPanel` to `true` in `src/shared/auth-config.ts`
2. Run `npm run mock:protected-api`
3. Run `npm run build`
4. Reload the unpacked extension from `dist/`
5. Open the popup and log in
6. Click `测试受保护接口`
7. Confirm the popup shows JSON containing `"source": "mock-protected-api"` and `"message": "authorized"`
## Batch Submit Mock Test
1. Run `npm run mock:protected-api`
2. Run `npm run build`
3. Reload the unpacked extension from `dist/`
4. Open `https://xingtu.cn/ad/creator/market`
5. Choose an export range in the plugin toolbar
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`
## Market Auth Gate
When the market page is opened without a valid auth state, the content script renders
`请先登录插件` and does not boot the filter, sort, or export toolbar.
## Manual Verification
1. Load the unpacked extension from `dist/`
2. Open `https://xingtu.cn/ad/creator/market`
3. Confirm the two new columns appear
4. Confirm current-page rows move through loading and then render values or failure states
5. Apply a threshold filter and confirm the list hides unmatched rows
6. Apply a sort and confirm row order changes
7. Export CSV and confirm the file includes plugin status and after-search-rate fields
3. Confirm the page shows the auth gate until login is available
4. After authentication is wired, confirm the two new columns appear
5. Confirm current-page rows move through loading and then render values or failure states
6. Apply a threshold filter and confirm the list hides unmatched rows
7. Apply a sort and confirm row order changes
8. Export CSV and confirm the file includes plugin status and after-search-rate fields

View File

@ -0,0 +1,216 @@
# Logto 受保护 API Mock 联调设计
## 背景
当前扩展已经具备基础的 Logto 登录能力:
- popup 可触发登录和登出
- background 可通过 `@logto/chrome-extension` 获取 access token
- 页面功能在未登录时会被 auth gate 拦住
但现阶段业务数据仍然来自页面站内接口,尚未真正验证“扩展拿到 Logto access token 后,请求受保护 API”这条链路。后续真实后端将由其他人提供因此当前阶段的目标不是接入正式接口而是先在本地完成一套可重复验证的模拟联调方案。
## 目标
- 新增一个专用于 Logto 受保护 API 的扩展客户端。
- 扩展请求受保护 API 前,必须先通过 background 获取 access token。
- 请求时自动附加 `Authorization: Bearer <token>` 请求头。
- 提供一个本地 mock 受保护 API用于验证扩展到后端的完整调用链路。
- 提供自动化测试,分别覆盖:
- token 注入逻辑
- mock API 授权成功/失败行为
## 非目标
- 不接入真实业务后端。
- 不做真实 JWT 签名校验、JWKS 拉取或资源权限判定。
- 不修改现有星图页面数据采集逻辑为正式后端模式。
- 不在本阶段设计复杂的 token 刷新监控或重试策略。
## 方案对比
### 方案 A只做本地 mock 后端
- 直接搭一个假接口,扩展请求它并观察返回。
- 优点:最接近最终使用方式。
- 缺点:如果联调失败,不容易快速判断问题出在 token 注入还是 mock 服务本身。
### 方案 B只做代码级测试
- 不起本地服务,只用测试替身验证请求头是否带 token。
- 优点:实现快,定位问题直接。
- 缺点:无法证明完整链路可运行。
### 方案 C先代码级测试再接本地 mock 后端
- 先用单元测试锁定 token 注入行为,再起 mock 服务完成真实联调。
- 优点:定位问题更快,同时保留完整链路验证。
- 缺点:改动稍多于单一方案。
推荐采用方案 C。
## 架构设计
### 1. 扩展受保护 API 客户端
新增一个独立客户端模块,职责如下:
- 向 background 发送 `auth:get-access-token` 消息
- 读取返回的 access token
- 使用该 token 请求指定后端地址
- 将接口成功、未授权、网络失败这三类结果转成明确错误
这个客户端不直接耦合星图 DOM也不依赖 popup。它只负责“拿 token 并带 token 发请求”,这样后续替换正式后端时只需要调整接口地址和返回映射。
### 2. Background 认证桥接
现有 background 已支持 `auth:get-access-token`。本次不改变登录主流程,只把它当作唯一 token 来源:
- content script 不直接接触 Logto SDK
- 所有受保护 API 请求都通过 background 提供 token
这样可以保持认证逻辑集中,符合 MV3 扩展的边界约束。
### 3. 本地 mock 受保护 API
新增一个轻量本地服务作为测试后端,建议职责保持极小:
- 暴露固定测试 endpoint例如 `/api/mock/protected`
- 检查请求头中是否存在 `Authorization`
- 如果请求头形如 `Bearer <非空字符串>`,返回固定假数据
- 如果没有该头,返回 `401`
本阶段不要求验证 token 是否来自真实 Logto只验证“扩展是否按约定附带了 Bearer token”。
## 数据流
完整调用链路如下:
1. 扩展中的业务入口调用受保护 API 客户端
2. 客户端向 background 发送 `auth:get-access-token`
3. background 返回当前 access token
4. 客户端带上 `Authorization: Bearer <token>` 请求本地 mock API
5. mock API 校验请求头并返回假数据
6. 客户端将结果回传给调用方
失败分支:
- 如果 background 没返回合法 token客户端直接报错不发请求
- 如果 mock API 返回 `401`,客户端将其识别为未授权错误
- 如果请求超时或网络失败,客户端抛出网络错误
## 接口设计
### 扩展内客户端接口
建议客户端提供单一入口,例如:
- `loadProtectedMockData()`
内部行为:
- 先取 token
- 再发 GET 请求
- 返回结构化响应对象或抛出明确错误
后续替换真实后端时,可以保留同样入口,内部再切换到真实 endpoint。
### Mock API 返回
成功时返回固定 JSON例如
```json
{
"ok": true,
"source": "mock-protected-api",
"message": "authorized",
"receivedAuthHeader": "Bearer ..."
}
```
失败时返回:
```json
{
"ok": false,
"error": "unauthorized"
}
```
## 错误处理
### 无 token
- 判定条件background 未返回 `auth:token`,或 token 为空字符串
- 处理方式:立即抛错,提示当前未登录或 token 不可用
### 未授权
- 判定条件:后端返回 `401``403`
- 处理方式:抛出授权失败错误,供上层展示“请重新登录”
### 网络失败
- 判定条件fetch 抛异常、连接失败、超时
- 处理方式:抛出网络错误,不吞掉异常原因
## 实现边界
建议新增或调整如下模块:
### `src/content/market` 下新增受保护 API 客户端
- 不替换现有页面接口客户端
- 独立处理 mock 受保护 API 访问
- 便于后续把“页面抓取模式”和“后端接口模式”并行保留
### `src/shared/auth-messages.ts`
- 复用现有 `auth:get-access-token`
- 若现有消息结构足够,则不新增消息类型
### `scripts/` 或独立目录中的 mock 服务
- 提供本地测试服务启动脚本
- 默认监听本地固定端口
- 返回固定 JSON 结果
## 测试策略
### 单元测试
- 客户端请求前会先获取 access token
- 拿到 token 后,请求头中包含 `Authorization: Bearer <token>`
- token 缺失时不会发起 fetch
- 接口返回 `401` 时抛出未授权错误
- 接口返回成功时正确解析 JSON
### 联调测试
- 启动本地 mock 服务后,带 token 请求能成功
- 不带 token 请求返回 `401`
- 扩展客户端能读到 mock 返回的假数据
## 手动验证
1. 构建并加载扩展
2. 在 popup 中完成 Logto 登录
3. 启动本地 mock API
4. 触发扩展中的受保护接口请求
5. 确认 mock API 收到 `Authorization: Bearer <token>`
6. 确认扩展端收到成功响应
## 迁移到真实后端的路径
当真实后端可用时,仅需要替换以下内容:
- mock API 基地址
- 具体 endpoint 路径
- 返回数据结构映射
- 若真实后端要求额外 scope则补充 `auth-config` 中的 scopes
核心认证链路保持不变:
- 仍由 background 提供 token
- 仍由独立客户端附带 `Bearer` 请求头
- 仍按未授权和网络错误分别处理

View File

@ -0,0 +1,305 @@
# 星图达人批次提交设计
## 背景
当前插件已经具备以下能力:
- 使用 Logto 登录并获取访问 `https://talent-search.intelligrow.cn` 的 access token
- 读取星图市场页中的达人列表数据
- 支持按当前页、前 5 页、前 10 页、全部、自定义页数进行多页采集
- 支持导出 CSV
下一阶段需要把“当前采集范围内的一批达人”提交给其他同学维护的后端接口,而不是逐条发送。用户期望在现有导出范围逻辑基础上,新增一个独立的“提交批次”动作,用来测试整批提交流程。
## 目标
- 在现有工具栏中新增一个独立的 `提交批次` 按钮
- 提交动作复用当前导出范围逻辑,支持多页达人数据采集
- 提交前通过浏览器 `prompt()` 让用户输入批次名称
- 自动生成 `batchId = 批次名称 + 时间戳`
- 将以下信息整合为单个批次 payload 发送给后端:
- Logto 用户 ID
- 星图达人 ID 列表
- 用户输入的批次名称
- 自动生成的批次 ID
## 非目标
- 不改造为逐条提交达人
- 不移除或替代现有 `导出 CSV`
- 不在首版实现复杂弹窗或表单 UI
- 不在首版实现批次列表管理、重试历史、草稿保存
## 方案对比
### 方案 A只提交当前页
- 新按钮只处理当前页达人
- 优点:实现最简单
- 缺点:与现有多页导出/采集使用方式不一致,不符合真实业务需求
### 方案 B提交当前导出范围内的一整批达人
- 复用现有导出范围控制器,提交当前选择范围内的所有达人
- 优点:与用户当前使用习惯一致,便于从测试过渡到真实业务
- 缺点:比只提交当前页多一层批次组装逻辑
### 方案 C先导出 CSV再从 CSV 结果提交
- 先走导出逻辑,再把导出结果转换为提交 payload
- 优点:数据来源统一
- 缺点:流程绕、耦合高,不适合作为长期模式
推荐采用方案 B。
## 交互设计
### 工具栏按钮
在现有工具栏中保留 `导出 CSV`,并新增:
- `提交批次`
两个按钮共享现有导出范围控件:
- 当前页
- 前 5 页
- 前 10 页
- 全部
- 自定义
### 批次名称输入
用户点击 `提交批次` 时:
1. 弹出浏览器 `prompt()`
2. 提示文案建议为:`请输入批次名称`
3. 用户输入如:`618 达人筛选第一批`
处理规则:
- 用户取消:直接终止,不报错
- 输入为空或仅空格:提示 `请输入批次名称`
- 输入有效:继续批次提交流程
### 提交状态
批次提交过程中:
- `提交批次` 按钮禁用
- `导出 CSV` 按钮与范围选择器一并禁用
- 状态文案可复用现有导出状态区域
建议状态文案:
- 当前页:`提交中...`
- 前 N 页:`提交中 3/5 页...`
- 全部:`提交中 第 3 页...`
### 成功与失败反馈
成功:
- 提示 `批次提交成功`
失败:
- 未登录:`请先登录插件`
- 批次名为空:`请输入批次名称`
- 接口失败:`批次提交失败,请稍后重试`
## 批次数据模型
首版建议 payload 结构如下:
```json
{
"logtoUserId": "p7pdhhtde8kj",
"creatorName": "王少卿",
"resource": "https://talent-search.intelligrow.cn",
"batchName": "618达人筛选第一批",
"batchId": "618达人筛选第一批-2026-04-22T12:30:00.000Z",
"createdAt": "2026-04-22T12:30:00.000Z",
"authors": [
{
"authorId": "111",
"authorName": "达人A"
},
{
"authorId": "222",
"authorName": "达人B"
}
]
}
```
### 字段来源
- `logtoUserId`
- 来源:当前登录用户的 Logto `sub`
- `creatorName`
- 来源:当前登录用户 `name``username`
- `resource`
- 来源:当前认证配置中的 API resource
- `batchName`
- 来源:用户在 `prompt()` 中输入
- `createdAt`
- 来源:当前时间的 ISO 字符串
- `batchId`
- 规则:`${batchName}-${createdAt}`
- `authors`
- 来源:当前导出范围内采集出的达人记录
### 达人字段
首版只要求最小字段:
- `authorId`
- `authorName`
后续如有需要,可增加:
- `price21To60s`
- 看后搜率字段
- 城市/地区等补充信息
## 数据流
完整流程如下:
1. 用户在工具栏选择导出范围
2. 用户点击 `提交批次`
3. 插件弹出 `prompt()` 请求输入批次名称
4. 插件检查登录状态与用户信息
5. 插件复用当前多页采集逻辑,获取当前范围内达人记录
6. 插件读取 Logto 用户 ID
7. 插件生成 `createdAt``batchId`
8. 插件组装整批 payload
9. 插件使用带 Bearer token 的客户端提交到后端
10. 插件显示成功或失败状态
## 模块边界
建议按以下边界实现:
### 工具栏层
职责:
- 渲染 `提交批次` 按钮
- 管理按钮禁用态
- 回传点击事件
### 市场控制器层
职责:
- 响应 `提交批次` 点击
- 调用 `prompt()` 获取批次名称
- 复用现有导出范围采集逻辑
- 调用批次 payload 组装器和提交客户端
### 批次 payload 组装层
职责:
- 接收用户信息、批次名、时间戳、达人记录
- 生成标准批次对象
该层不负责网络请求,方便后续根据后端要求调整结构。
### 批次提交客户端
职责:
- 获取 access token
- 附带 `Authorization: Bearer <token>` 请求头
- 向后端提交整批 JSON
后续从 mock 接口切到真实接口时,应主要修改这一层。
## 认证与授权
当前系统已具备:
- Logto 登录能力
- 请求 `https://talent-search.intelligrow.cn` resource 的 access token
- `talent-search:read` scope
首版批次提交如果是写操作,后端后续可能需要新增写权限,例如:
- `talent-search:write`
- 或 `talent-search:batch:submit`
如果后端要求新的写权限,则前端需要同步更新 `auth-config.ts` 中的 `scopes`
## 错误处理
### 用户取消输入
- 直接停止,不提示错误
### 批次名为空
- 不发请求
- 直接提示 `请输入批次名称`
### 未登录或取不到用户信息
- 不发请求
- 提示 `请先登录插件`
### 采集失败
- 复用当前导出多页采集失败逻辑
- 不生成不完整 payload
### 接口失败
- 提示 `批次提交失败,请稍后重试`
## 测试策略
### 单元测试
- 点击 `提交批次` 时会弹出 `prompt()`
- 用户取消输入时不会继续提交
- 空白批次名会报错
- `batchId``批次名 + ISO 时间戳` 生成
- payload 中包含:
- `logtoUserId`
- `batchName`
- `batchId`
- `authors[]`
### 集成测试
- `提交批次` 复用当前导出范围采集逻辑
- 当前页模式只提交当前页达人
- 多页模式会合并多页达人
- 接口调用时带 Bearer token
- 成功时显示成功状态
- 失败时显示失败状态
## 手动验证
1. 登录插件
2. 选择导出范围
3. 点击 `提交批次`
4. 在 `prompt()` 中输入批次名
5. 确认请求 payload 中包含:
- Logto 用户 ID
- 多个达人 ID
- 批次名
- 批次 ID
6. 确认后端返回成功
## 后续扩展
当真实接口稳定后,可以继续扩展:
- 批次提交结果详情展示
- 最近提交批次列表
- 批次重试
- 批次备注、标签
- 写权限 scope 单独拆分

View File

@ -5,9 +5,13 @@
"private": true,
"scripts": {
"build": "node scripts/build.mjs",
"mock:protected-api": "node scripts/mock-protected-api.mjs",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest --passWithNoTests"
},
"dependencies": {
"@logto/chrome-extension": "^0.1.27"
},
"license": "UNLICENSED",
"devDependencies": {
"jsdom": "^29.0.2",

View File

@ -0,0 +1,117 @@
import http from "node:http";
export function createMockProtectedApiServer({ port = 4319 } = {}) {
let server;
return {
get baseUrl() {
const address = server?.address();
const resolvedPort =
typeof address === "object" && address ? address.port : port;
return `http://127.0.0.1:${resolvedPort}`;
},
async start() {
server = http.createServer(async (request, response) => {
if (request.url === "/api/mock/protected") {
const authHeader = readBearerToken(request, response);
if (!authHeader) {
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
source: "mock-protected-api",
message: "authorized",
receivedAuthHeader: authHeader
})
);
return;
}
if (request.url === "/api/mock/batches" && request.method === "POST") {
const authHeader = readBearerToken(request, response);
if (!authHeader) {
return;
}
const payload = await readJsonBody(request);
const authors = Array.isArray(payload?.authors) ? payload.authors : [];
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
source: "mock-batch-submit",
acceptedCount: authors.length,
batchId:
typeof payload?.batchId === "string" ? payload.batchId : null,
receivedAuthHeader: authHeader
})
);
return;
}
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "not-found" }));
});
await new Promise((resolve) => {
server.listen(port, "127.0.0.1", resolve);
});
},
async close() {
if (!server) {
return;
}
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(undefined);
});
});
}
};
}
function readBearerToken(request, response) {
const authHeader = request.headers.authorization ?? "";
const isBearer =
typeof authHeader === "string" &&
authHeader.startsWith("Bearer ") &&
authHeader.length > "Bearer ".length;
if (!isBearer) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "unauthorized" }));
return null;
}
return authHeader;
}
async function readJsonBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
if (chunks.length === 0) {
return null;
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
if (import.meta.url === `file://${process.argv[1]}`) {
const server = createMockProtectedApiServer();
await server.start();
console.log(`mock protected api listening on ${server.baseUrl}`);
}

View File

@ -0,0 +1,53 @@
import type { AuthStateValue } from "../../shared/auth-messages";
import type { MarketRecord } from "./types";
export interface BatchPayload {
authors: Array<{
authorId: string;
authorName: string;
}>;
batchId: string;
batchName: string;
createdAt: string;
creatorName: string;
logtoUserId: string;
resource: string;
}
export function createBatchPayload(options: {
authState: AuthStateValue;
batchName: string;
createdAt: string;
records: MarketRecord[];
}): BatchPayload {
const logtoUserId = options.authState.userInfo?.sub?.trim();
if (!logtoUserId) {
throw new Error("batch submit user id unavailable");
}
const resource = options.authState.resource?.trim();
if (!resource) {
throw new Error("batch submit resource unavailable");
}
const batchName = options.batchName.trim();
if (!batchName) {
throw new Error("batch submit batch name is required");
}
return {
authors: options.records.map((record) => ({
authorId: record.authorId,
authorName: record.authorName
})),
batchId: `${batchName}-${options.createdAt}`,
batchName,
createdAt: options.createdAt,
creatorName:
options.authState.userInfo?.name ??
options.authState.userInfo?.username ??
logtoUserId,
logtoUserId,
resource
};
}

View File

@ -1,4 +1,5 @@
import { buildMarketCsv } from "./csv-exporter";
import { createBatchPayload, type BatchPayload } from "./batch-payload";
import {
applyRowOrder,
applyRowVisibility,
@ -16,6 +17,11 @@ import {
setToolbarExportStatus
} from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store";
import {
isAuthResponseMessage,
type AuthStateValue
} from "../../shared/auth-messages";
import { createBatchSubmitClient } from "../../shared/batch-submit-client";
import type {
MarketApiResult,
MarketFilterState,
@ -33,24 +39,38 @@ interface MutationObserverLike {
export interface CreateMarketControllerOptions {
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
getAuthState?: () => Promise<AuthStateValue>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
mutationObserverFactory?: (
callback: MutationCallback
) => MutationObserverLike;
onCsvReady?: (csv: string) => void;
promptBatchName?: () => string | null;
resultStore?: ReturnType<typeof createMarketResultStore>;
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
window: Window;
}
export function createMarketController(options: CreateMarketControllerOptions) {
const marketApiClient = createMarketApiClient();
const sendRuntimeMessage = createRuntimeMessageSender();
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
const buildCsv = options.buildCsv ?? buildMarketCsv;
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
const mutationObserverFactory =
options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback));
const promptBatchName =
options.promptBatchName ??
(() => options.window.prompt("请输入批次名称"));
const submitBatch =
options.submitBatch ??
createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
sendMessage: sendRuntimeMessage
}).submitBatch;
const exportRangeController = createExportRangeController({
document: options.document,
onProgress: ({ currentPage, totalPages }) => {
@ -117,6 +137,48 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} finally {
setToolbarBusyState(toolbar, false);
}
},
onSubmitBatch: async () => {
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
const batchName = promptBatchName();
if (batchName === null) {
return;
}
if (!batchName.trim()) {
setToolbarExportStatus(toolbar, "请输入批次名称");
return;
}
setToolbarBusyState(toolbar, true);
try {
const records = await exportRecords(exportTarget.target, "提交中");
const authState = await getAuthState();
if (!authState.isAuthenticated) {
throw new Error("请先登录插件");
}
const payload = createBatchPayload({
authState,
batchName,
createdAt: new Date().toISOString(),
records
});
await submitBatch(payload);
setToolbarExportStatus(toolbar, "批次提交成功");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "批次提交失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
}
});
@ -225,9 +287,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
});
}
async function exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
async function exportRecords(
target: MarketExportTarget,
inProgressLabel = "导出中"
): Promise<MarketRecord[]> {
if (target.mode === "count" && target.pageCount <= 1) {
setToolbarExportStatus(toolbar, "导出中...");
setToolbarExportStatus(toolbar, `${inProgressLabel}...`);
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
}
@ -366,56 +431,81 @@ export function createMarketController(options: CreateMarketControllerOptions) {
let previousFingerprint = "";
let stablePassCount = 0;
for (let attempt = 0; attempt < 12; attempt += 1) {
for (let attempt = 0; attempt < 9; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 100);
options.window.setTimeout(
resolve,
previousFingerprint.includes("|missing:0") ? 25 : 50
);
});
await Promise.resolve();
}
collectCurrentPageSnapshots();
const nextFingerprint = readVisibleRowHydrationFingerprint();
if (!nextFingerprint) {
const hydrationSnapshot = readVisibleRowHydrationSnapshot();
if (!hydrationSnapshot.fingerprint) {
stablePassCount = 0;
previousFingerprint = "";
continue;
}
if (nextFingerprint === previousFingerprint) {
if (hydrationSnapshot.fingerprint === previousFingerprint) {
stablePassCount += 1;
} else {
previousFingerprint = nextFingerprint;
previousFingerprint = hydrationSnapshot.fingerprint;
stablePassCount = 1;
}
if (stablePassCount >= 3) {
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
return;
}
}
}
function readVisibleRowHydrationFingerprint(): string {
function readVisibleRowHydrationSnapshot(): {
fingerprint: string;
missingDefaultFieldCount: number;
} {
const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) {
return "";
return {
fingerprint: "",
missingDefaultFieldCount: 0
};
}
return table.rows
.map((rowDom) => {
const parts = table.rows.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0
).length;
const hasRepresentativeVideo = hasTextValue(
rowSnapshot.exportFields?.["代表视频"]
);
const hasPriceField =
hasTextValue(rowSnapshot.price21To60s) ||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
const missingDefaultFieldCount =
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
return [
rowSnapshot.authorId,
populatedFieldCount,
rowSnapshot.price21To60s?.trim() ? "price" : "no-price"
hasRepresentativeVideo ? "video" : "no-video",
hasPriceField ? "price" : "no-price",
`missing:${missingDefaultFieldCount}`
].join(":");
})
.join("|");
});
return {
fingerprint: parts.join("|"),
missingDefaultFieldCount: parts.reduce((count, part) => {
const match = part.match(/missing:(\d+)$/);
return count + Number(match?.[1] ?? 0);
}, 0)
};
}
function scheduleSync(): void {
@ -528,6 +618,32 @@ function mergeFieldMap<T extends Record<string, string | undefined>>(
return merged as T;
}
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
return (message: unknown) =>
Promise.resolve(
(
globalThis as typeof globalThis & {
chrome?: {
runtime?: {
sendMessage?: (payload: unknown) => Promise<unknown>;
};
};
}
).chrome?.runtime?.sendMessage?.(message)
);
}
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
throw new Error("请先登录插件");
}
return response.value;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined

View File

@ -4,9 +4,11 @@ export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void;
}
export interface PluginToolbarDom {
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
@ -70,6 +72,11 @@ export function ensurePluginToolbar(
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select";
appendOption(exportRangeSelect, "current", "当前页");
@ -98,7 +105,8 @@ export function ensurePluginToolbar(
sortApplyButton,
exportRangeSelect,
exportCustomPagesInput,
exportButton
exportButton,
batchSubmitButton
);
root.append(exportStatusText);
document.body.prepend(root);
@ -112,8 +120,12 @@ export function ensurePluginToolbar(
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
batchSubmitButton.addEventListener("click", () => {
void handlers.onSubmitBatch();
});
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
@ -129,6 +141,7 @@ export function ensurePluginToolbar(
});
const toolbarDom = {
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect,
@ -159,6 +172,9 @@ function appendOption(
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = {
batchSubmitButton: root.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement,
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
@ -255,6 +271,7 @@ export function setToolbarBusyState(
isBusy: boolean
): void {
[
toolbar.batchSubmitButton,
toolbar.exportButton,
toolbar.filterApplyButton,
toolbar.sortApplyButton,

155
src/popup/index.ts Normal file
View File

@ -0,0 +1,155 @@
import {
renderDevPanel,
renderLoggedIn,
renderLoggedOut,
setProtectedApiResult
} from "./view";
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
import {
isAuthResponseMessage,
type AuthResponseMessage
} from "../shared/auth-messages";
import { createProtectedApiClient } from "../shared/protected-api-client";
interface BootPopupOptions {
config?: Partial<AuthConfig>;
document?: Document;
fetchProtectedApi?: () => Promise<unknown>;
sendMessage?: (message: unknown) => Promise<unknown>;
}
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
const currentDocument = options.document ?? document;
const popupConfig = readAuthConfig(options.config);
const root = currentDocument.querySelector("#app");
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
if (!root || (HTMLElementCtor && !(root instanceof HTMLElementCtor))) {
throw new Error("popup root #app is required");
}
const sendMessage =
options.sendMessage ??
((message: unknown) =>
Promise.resolve(
(
globalThis as typeof globalThis & {
chrome?: {
runtime?: {
sendMessage?: (payload: unknown) => Promise<unknown>;
};
};
}
).chrome?.runtime?.sendMessage?.(message)
));
const fetchProtectedApi =
options.fetchProtectedApi ??
createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
sendMessage
}).loadProtectedMockData;
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
}
async function renderCurrentAuthState(
root: HTMLElement,
popupConfig: AuthConfig,
sendMessage: (message: unknown) => Promise<unknown>,
fetchProtectedApi: () => Promise<unknown>
): Promise<void> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
renderLoggedOut(root, "认证状态读取失败");
return;
}
if (!response.value.isAuthenticated) {
renderLoggedOut(root, response.value.lastError);
root
.querySelector('[data-popup-sign-in="button"]')
?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-in" },
fetchProtectedApi
});
});
return;
}
renderLoggedIn(root, response.value);
root
.querySelector('[data-popup-sign-out="button"]')
?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, {
actionMessage: { type: "auth:sign-out" },
fetchProtectedApi
});
});
if (popupConfig.enableDevAuthPanel) {
renderDevPanel(root, response.value);
root
.querySelector('[data-popup-test-protected-api="button"]')
?.addEventListener("click", () => {
void runProtectedApiProbe(root, fetchProtectedApi);
});
}
}
async function runAuthAction(
root: HTMLElement,
popupConfig: AuthConfig,
sendMessage: (message: unknown) => Promise<unknown>,
options: {
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
fetchProtectedApi: () => Promise<unknown>;
}
): Promise<void> {
const response = await sendMessage(options.actionMessage);
if (isActionError(response)) {
renderLoggedOut(root, response.error);
root
.querySelector('[data-popup-sign-in="button"]')
?.addEventListener("click", () => {
void runAuthAction(root, popupConfig, sendMessage, options);
});
return;
}
await renderCurrentAuthState(
root,
popupConfig,
sendMessage,
options.fetchProtectedApi
);
}
function isActionError(response: unknown): response is Extract<AuthResponseMessage, { ok: false }> {
return (
isAuthResponseMessage(response) &&
!response.ok &&
response.type === "auth:error"
);
}
async function runProtectedApiProbe(
root: HTMLElement,
fetchProtectedApi: () => Promise<unknown>
): Promise<void> {
setProtectedApiResult(root, "请求中...");
try {
const result = await fetchProtectedApi();
setProtectedApiResult(root, JSON.stringify(result, null, 2));
} catch (error) {
setProtectedApiResult(
root,
error instanceof Error ? error.message : String(error)
);
}
}
if (typeof document !== "undefined") {
void bootPopup();
}

60
src/popup/view.ts Normal file
View File

@ -0,0 +1,60 @@
import type { AuthStateValue } from "../shared/auth-messages";
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>使</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button"> Logto</button>
</section>
`;
}
export function renderLoggedIn(
root: HTMLElement,
authState: AuthStateValue
): void {
const userInfo = authState.userInfo;
root.innerHTML = `
<section data-popup-state="logged-in">
<h1>Star Chart Search Enhancer</h1>
<p></p>
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
<p>${userInfo?.email ?? ""}</p>
<button type="button" data-popup-sign-out="button">退</button>
</section>
`;
}
export function renderDevPanel(
root: HTMLElement,
authState: AuthStateValue
): void {
const panel = root.ownerDocument.createElement("section");
panel.dataset.popupDevPanel = "root";
panel.innerHTML = `
<h2>dev auth panel</h2>
<p>resource: ${authState.resource ?? ""}</p>
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
<p>error: ${authState.lastError ?? ""}</p>
<button type="button" data-popup-test-protected-api="button"></button>
<pre data-popup-protected-api-result="output"></pre>
`;
root.appendChild(panel);
}
export function setProtectedApiResult(root: HTMLElement, value: string): void {
const output = root.querySelector(
'[data-popup-protected-api-result="output"]'
);
if (!output) {
return;
}
output.textContent = value;
}

View File

@ -0,0 +1,65 @@
import type { BatchPayload } from "../content/market/batch-payload";
import { isAuthResponseMessage } from "./auth-messages";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status: number;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createBatchSubmitClient(options: {
baseUrl: string;
fetchImpl?: FetchLike;
sendMessage: SendMessageLike;
}) {
const fetchImpl = options.fetchImpl ?? fetch;
return {
async submitBatch(payload: BatchPayload) {
const token = await readAccessToken(options.sendMessage);
const response = await fetchImpl(
new URL("/api/mock/batches", options.baseUrl).toString(),
{
body: JSON.stringify(payload),
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
method: "POST"
}
);
if (response.status === 401 || response.status === 403) {
throw new Error("batch submit unauthorized");
}
if (!response.ok) {
throw new Error(`batch submit failed: ${response.status}`);
}
return response.json();
}
};
}
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
const response = await sendMessage({ type: "auth:get-access-token" });
if (
!isAuthResponseMessage(response) ||
!response.ok ||
response.type !== "auth:token" ||
!response.value.accessToken.trim()
) {
throw new Error("batch submit token unavailable");
}
return response.value.accessToken;
}

View File

@ -0,0 +1,62 @@
import { isAuthResponseMessage } from "./auth-messages";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status: number;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createProtectedApiClient(options: {
baseUrl: string;
fetchImpl?: FetchLike;
sendMessage: SendMessageLike;
}) {
const fetchImpl = options.fetchImpl ?? fetch;
return {
async loadProtectedMockData() {
const token = await readAccessToken(options.sendMessage);
const response = await fetchImpl(
new URL("/api/mock/protected", options.baseUrl).toString(),
{
headers: {
Authorization: `Bearer ${token}`
},
method: "GET"
}
);
if (response.status === 401 || response.status === 403) {
throw new Error("protected api unauthorized");
}
if (!response.ok) {
throw new Error(`protected api request failed: ${response.status}`);
}
return response.json();
}
};
}
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
const response = await sendMessage({ type: "auth:get-access-token" });
if (
!isAuthResponseMessage(response) ||
!response.ok ||
response.type !== "auth:token" ||
!response.value.accessToken.trim()
) {
throw new Error("protected api token unavailable");
}
return response.value.accessToken;
}

View File

@ -47,4 +47,84 @@ describe("background-index", () => {
);
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
});
test("responds to auth:get-state with auth status", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
> = [];
const sendResponse = vi.fn();
registerBackgroundMessageHandler(
{
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
},
{
authController: {
getAccessToken: vi.fn(),
getAuthState: vi.fn(async () => ({ isAuthenticated: false })),
signIn: vi.fn(),
signOut: vi.fn()
}
}
);
const result = listeners[0]({ type: "auth:get-state" }, {}, sendResponse);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sendResponse).toHaveBeenCalledWith({
ok: true,
type: "auth:state",
value: { isAuthenticated: false }
});
});
test("responds to auth:get-access-token with the current token", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
> = [];
const sendResponse = vi.fn();
registerBackgroundMessageHandler(
{
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
},
{
authController: {
getAccessToken: vi.fn(async () => "test-access-token"),
getAuthState: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn()
}
}
);
const result = listeners[0](
{ type: "auth:get-access-token" },
{},
sendResponse
);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(sendResponse).toHaveBeenCalledWith({
ok: true,
type: "auth:token",
value: { accessToken: "test-access-token" }
});
});
});

View File

@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";
import { createBatchPayload } from "../src/content/market/batch-payload";
describe("batch-payload", () => {
test("builds a batch id from the batch name and timestamp", () => {
const payload = createBatchPayload({
authState: {
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: {
name: "王少卿",
sub: "p7pdhhtde8kj"
}
},
batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z",
records: [
{ authorId: "111", authorName: "达人A", status: "success" },
{ authorId: "222", authorName: "达人B", status: "success" }
]
});
expect(payload).toEqual({
authors: [
{ authorId: "111", authorName: "达人A" },
{ authorId: "222", authorName: "达人B" }
],
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
});
});
test("throws when the user id is unavailable", () => {
expect(() =>
createBatchPayload({
authState: {
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: {
name: "王少卿"
}
},
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
records: [{ authorId: "111", authorName: "达人A", status: "success" }]
})
).toThrow(/user/i);
});
});

View File

@ -0,0 +1,82 @@
import { describe, expect, test, vi } from "vitest";
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
describe("batch-submit-client", () => {
test("posts the batch payload with a Bearer token", async () => {
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}));
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ acceptedCount: 2, ok: true })
}));
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
});
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: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
});
expect(fetchImpl).toHaveBeenCalledWith(
"http://127.0.0.1:4319/api/mock/batches",
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: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
}),
headers: expect.objectContaining({
Authorization: "Bearer abc123",
"Content-Type": "application/json"
}),
method: "POST"
})
);
});
test("throws on unauthorized responses", async () => {
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl: vi.fn(async () => ({
ok: false,
status: 401,
json: async () => ({ error: "unauthorized", ok: false })
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}))
});
await expect(
client.submitBatch({
authors: [],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
})
).rejects.toThrow(/unauthorized/i);
});
});

View File

@ -44,14 +44,21 @@ describe("market-content-entry", () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}));
window.history.replaceState({}, "", "/ad/creator/market");
(
globalThis as typeof globalThis & {
chrome?: { runtime?: object };
chrome?: { runtime?: { sendMessage?: (message: unknown) => Promise<unknown> } };
}
).chrome = {
runtime: {}
runtime: {
sendMessage
}
};
vi.doMock("../src/content/market/index", () => ({
@ -72,7 +79,12 @@ describe("market-content-entry", () => {
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
expect(createMarketController).toHaveBeenCalledTimes(1);
@ -90,6 +102,11 @@ describe("market-content-entry", () => {
await bootContentScript({
createMarketController,
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
})),
window: {
location: {
href: "https://www.xingtu.cn/ad/creator/market"
@ -128,7 +145,12 @@ describe("market-content-entry", () => {
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
@ -163,8 +185,14 @@ describe("market-content-entry", () => {
};
const { bootContentScript } = await import("../src/content/index");
sendMessage.mockClear();
await bootContentScript({
createMarketController
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
@ -213,6 +241,28 @@ describe("market-content-entry", () => {
).toBe("0.03% - 0.2%");
});
test("boots the controller only after auth succeeds", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
window.history.replaceState({}, "", "/ad/creator/market");
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
})),
window
});
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("hydrates the real div-grid market rows on start", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
@ -481,6 +531,9 @@ describe("market-content-entry", () => {
expect(exportRangeSelect?.value).toBe("first-5");
expect(customPagesInput?.hidden).toBe(true);
expect(
document.querySelector('[data-plugin-batch-submit="button"]')
).not.toBeNull();
setSelectValue('[data-plugin-export-range="select"]', "custom");
dispatchChange('[data-plugin-export-range="select"]');
@ -731,6 +784,7 @@ describe("market-content-entry", () => {
click('[data-plugin-export="button"]');
expectButtonDisabled('[data-plugin-batch-submit="button"]', true);
expectButtonDisabled('[data-plugin-export="button"]', true);
expectButtonDisabled('[data-plugin-filter-apply="button"]', true);
expectButtonDisabled('[data-plugin-sort-apply="button"]', true);
@ -742,6 +796,7 @@ describe("market-content-entry", () => {
await waitForMockCall(buildCsv, 80, 100);
expect(pagination.getClicks()).toBe(2);
expectButtonDisabled('[data-plugin-batch-submit="button"]', false);
expectButtonDisabled('[data-plugin-export="button"]', false);
expectSelectDisabled('[data-plugin-export-range="select"]', false);
expect(buildCsv).toHaveBeenCalledTimes(1);
@ -782,6 +837,107 @@ describe("market-content-entry", () => {
).toContain("有效页数");
});
test("prompts for a batch name before submitting the current range", async () => {
document.body.innerHTML = buildMarketFixture();
const promptBatchName = vi.fn(() => "618达人筛选第一批");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50);
expect(promptBatchName).toHaveBeenCalledTimes(1);
expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({
batchId: expect.stringContaining("618达人筛选第一批-"),
batchName: "618达人筛选第一批",
logtoUserId: "p7pdhhtde8kj"
})
);
});
test("shows an error when the batch name is blank", async () => {
document.body.innerHTML = buildMarketFixture();
const promptBatchName = vi.fn(() => " ");
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
await flush();
expect(submitBatch).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("请输入批次名称");
});
test("does nothing when the prompt is cancelled", async () => {
document.body.innerHTML = buildMarketFixture();
const promptBatchName = vi.fn(() => null);
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName,
submitBatch,
window
}));
await controller.ready;
click('[data-plugin-batch-submit="button"]');
await flush();
expect(promptBatchName).toHaveBeenCalledTimes(1);
expect(submitBatch).not.toHaveBeenCalled();
});
test("export only includes records that are present on the current page", async () => {
document.body.innerHTML = buildMarketFixture();
const resultStore = createMarketResultStore();

View File

@ -0,0 +1,80 @@
import { afterEach, describe, expect, test } from "vitest";
import { createMockProtectedApiServer } from "../scripts/mock-protected-api.mjs";
const servers: Array<{ close: () => Promise<void> }> = [];
afterEach(async () => {
while (servers.length > 0) {
await servers.pop()?.close();
}
});
describe("mock-protected-api", () => {
test("returns mock data when a Bearer token is present", async () => {
const server = createMockProtectedApiServer({ port: 0 });
await server.start();
servers.push(server);
const response = await fetch(`${server.baseUrl}/api/mock/protected`, {
headers: {
Authorization: "Bearer abc123"
}
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
ok: true,
source: "mock-protected-api"
})
);
});
test("returns 401 when the Authorization header is missing", async () => {
const server = createMockProtectedApiServer({ port: 0 });
await server.start();
servers.push(server);
const response = await fetch(`${server.baseUrl}/api/mock/protected`);
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({
ok: false,
error: "unauthorized"
});
});
test("accepts a batch payload when a Bearer token is present", async () => {
const server = createMockProtectedApiServer({ port: 0 });
await server.start();
servers.push(server);
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: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
}),
headers: {
Authorization: "Bearer abc123",
"Content-Type": "application/json"
},
method: "POST"
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
acceptedCount: 1,
batchId: "批次A-2026-04-22T12:30:00.000Z",
ok: true,
source: "mock-batch-submit"
})
);
});
});

188
tests/popup-entry.test.ts Normal file
View File

@ -0,0 +1,188 @@
import { JSDOM } from "jsdom";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { bootPopup } from "../src/popup/index";
describe("popup-entry", () => {
let dom: JSDOM;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>");
});
test("renders a sign-in button when unauthenticated", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
document: dom.window.document,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: false }
}))
});
expect(dom.window.document.querySelector("button")?.textContent).toContain(
"登录"
);
});
test("renders the dev auth panel when enabled", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
config: { enableDevAuthPanel: true },
document: dom.window.document,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
accessTokenExpiresAt: 1700000000000,
isAuthenticated: true,
resource: "https://api.example.test",
scopes: ["openid", "profile"],
tokenAvailable: true,
userInfo: { email: "dev@example.com", name: "Dev" }
}
}))
});
expect(dom.window.document.body.textContent).toContain("resource");
expect(dom.window.document.body.textContent).toContain("token");
});
test("renders a protected api test button in the dev panel", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
config: { enableDevAuthPanel: true },
document: dom.window.document,
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
tokenAvailable: true
}
}))
});
expect(
dom.window.document.querySelector('[data-popup-test-protected-api="button"]')
).not.toBeNull();
});
test("clicking the dev button runs the protected api client and prints the result", async () => {
const fetchProtectedApi = vi.fn(async () => ({
message: "authorized",
ok: true,
source: "mock-protected-api"
}));
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
tokenAvailable: true
}
}));
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
config: { enableDevAuthPanel: true },
document: dom.window.document,
fetchProtectedApi,
sendMessage
});
(
dom.window.document.querySelector(
'[data-popup-test-protected-api="button"]'
) as HTMLButtonElement | null
)?.click();
await Promise.resolve();
expect(fetchProtectedApi).toHaveBeenCalledTimes(1);
expect(dom.window.document.body.textContent).toContain("authorized");
expect(dom.window.document.body.textContent).toContain("mock-protected-api");
});
test("clicking sign-out sends the auth:sign-out message", async () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { email: "dev@example.com", name: "Dev" }
}
})
.mockResolvedValueOnce({
ok: true,
type: "auth:ack"
})
.mockResolvedValueOnce({
ok: true,
type: "auth:state",
value: {
isAuthenticated: false
}
});
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
document: dom.window.document,
sendMessage
});
(
dom.window.document.querySelector('[data-popup-sign-out="button"]') as
| HTMLButtonElement
| null
)?.click();
await Promise.resolve();
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
});
test("shows the auth error when sign-in fails", async () => {
const sendMessage = vi
.fn()
.mockResolvedValueOnce({
ok: true,
type: "auth:state",
value: {
isAuthenticated: false
}
})
.mockResolvedValueOnce({
error: "redirect_uri_mismatch",
ok: false,
type: "auth:error"
});
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
document: dom.window.document,
sendMessage
});
(
dom.window.document.querySelector('[data-popup-sign-in="button"]') as
| HTMLButtonElement
| null
)?.click();
await Promise.resolve();
expect(dom.window.document.body.textContent).toContain(
"redirect_uri_mismatch"
);
});
});

View File

@ -0,0 +1,73 @@
import { describe, expect, test, vi } from "vitest";
import { createProtectedApiClient } from "../src/shared/protected-api-client";
describe("protected-api-client", () => {
test("requests a token before calling the protected endpoint", async () => {
const sendMessage = vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}));
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true })
}));
const client = createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
});
await client.loadProtectedMockData();
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" });
expect(fetchImpl).toHaveBeenCalledWith(
"http://127.0.0.1:4319/api/mock/protected",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer abc123"
}),
method: "GET"
})
);
});
test("throws before fetch when the token is unavailable", async () => {
const sendMessage = vi.fn(async () => ({
ok: false,
type: "auth:error",
error: "token missing"
}));
const fetchImpl = vi.fn();
const client = createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl,
sendMessage
});
await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
test("throws an authorization error on 401", async () => {
const client = createProtectedApiClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl: vi.fn(async () => ({
ok: false,
status: 401,
json: async () => ({ ok: false, error: "unauthorized" })
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}))
});
await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i);
});
});