fix(system): retired heartbeat rows no longer alarm on status page
- GET /system/health splits processes into current instances (hosts with
a beat in the last 10 min, newest per host+kind, not stopped) and
retiredProcesses[]; 45s stale and level only consider current ones
- heartbeat writer marks stopped_at on graceful exit (migration 0043),
upsert clears it; pure api role now stops its writer on SIGTERM
- scheduled job system.heartbeat_cleanup (maintain, 1h) deletes rows
without a beat for 24h and samples older than 7 days
- status page: per-role alarms ("io worker 45 秒没有心跳"), collapsible
retired instances; glossary/runbook updated
- bump all members to 0.2.2, CHANGELOG [0.2.2]
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fa261eb6cd
commit
b4c9b1ead2
32 files changed
+747
-91
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/adapters",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* camofox-browser 适配器(BrowserProvider 首个实现)。
|
||||
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
|
||||
* 对照:jo-inc/camofox-browser v1.17.0(commit 389c996)openapi.json、README 与 server.js;2026-09-28 对真实容器核验,
|
||||
* 结果见 docs/runbook/browser.md「真机核验」。
|
||||
*
|
||||
* 上游模型与本项目合同的映射:
|
||||
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
|
||||
@@ -9,10 +10,10 @@
|
||||
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
|
||||
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
|
||||
*
|
||||
* 已实现(仅对 mock 与上游文档验证):health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
|
||||
* 已实现:health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
|
||||
* 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、
|
||||
* 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。
|
||||
* 未核验的上游行为(坐标点击、无目标输入、downloads 字段形状)见 docs/runbook/browser.md。
|
||||
* 与文档不同的实测行为(截图直接返回 image/png、click 不接受坐标、焦点输入需 mode=keyboard、downloads 字段名)已按实测适配。
|
||||
*/
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
@@ -718,6 +719,13 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
await this.withProfile(s, async () => {
|
||||
this.assertActor(s, ref.as);
|
||||
const tab = this.tabOf(s, ref);
|
||||
// 上游 click/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作,
|
||||
// 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click
|
||||
if (typeof payload.x === "number" && typeof payload.y === "number") {
|
||||
payload.selector = await this.pointSelector(s, tab, payload.x, payload.y);
|
||||
delete payload.x;
|
||||
delete payload.y;
|
||||
}
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action}`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, ...payload },
|
||||
@@ -732,6 +740,22 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */
|
||||
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
||||
const nonce = this.newId().replace(/[^a-z0-9]/gi, "").slice(0, 16);
|
||||
const expression = `(() => { const e = document.elementFromPoint(${x}, ${y}); if (!e) return false;
|
||||
for (const o of document.querySelectorAll("[data-umt-pt]")) o.removeAttribute("data-umt-pt");
|
||||
e.setAttribute("data-umt-pt", "${nonce}"); return true; })()`;
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, expression },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const out = await this.json(res, path);
|
||||
if (out.result !== true) throw new BrowserError("bad_request", "该坐标处没有可操作的元素");
|
||||
return `[data-umt-pt="${nonce}"]`;
|
||||
}
|
||||
|
||||
/** 点击可能跳转或改动页面:之后旧引用作废 */
|
||||
async click(ref: TabRef, target: ElementTarget): Promise<void> {
|
||||
await this.act(
|
||||
@@ -765,7 +789,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
ref,
|
||||
"type",
|
||||
(s) => ({
|
||||
...(target ? this.assertTarget(s, target) : {}),
|
||||
// 无目标 = 输入到当前焦点:上游 mode=fill 必须带 ref/selector,焦点输入要 mode=keyboard(1.17.0 实测)
|
||||
...(target ? this.assertTarget(s, target) : { mode: "keyboard" }),
|
||||
text,
|
||||
...(input.clear !== undefined ? { clear: input.clear } : {}),
|
||||
...(input.submit !== undefined ? { submit: input.submit } : {}),
|
||||
@@ -875,7 +900,13 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
if (s.seenDownloads.has(key)) continue;
|
||||
const status = str(d.status) ?? str(d.state);
|
||||
if (status && !["completed", "complete", "done", "finished"].includes(status)) continue;
|
||||
const b64 = str(d.data) ?? str(d.base64) ?? str(d.content);
|
||||
// 1.17.0 实测字段:{ id, url, suggestedFilename, mimeType, bytes, createdAt, failure, dataBase64 | dataSkipped | readError }
|
||||
if (d.failure) {
|
||||
s.seenDownloads.add(key);
|
||||
throw new BrowserError("upstream", "浏览器下载失败");
|
||||
}
|
||||
if (d.dataSkipped) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
||||
const b64 = str(d.dataBase64) ?? str(d.data) ?? str(d.base64) ?? str(d.content);
|
||||
const size = int(d.size) ?? int(d.bytes) ?? null;
|
||||
if (size !== null && size > opts.maxBytes)
|
||||
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/agent",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/contracts",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -2167,6 +2167,31 @@ export const Api = {
|
||||
query: browser.BrowserProvidersQuery,
|
||||
response: z.object({ items: z.array(browser.BrowserProviderView) }),
|
||||
}),
|
||||
createProvider: ep({
|
||||
method: "post",
|
||||
path: "/browser/providers",
|
||||
scope: "admin",
|
||||
summary: "登记浏览器服务(camofox 地址、访问凭据存凭证库、启用)",
|
||||
body: browser.BrowserProviderCreate,
|
||||
response: browser.BrowserProviderView,
|
||||
}),
|
||||
updateProvider: ep({
|
||||
method: "patch",
|
||||
path: "/browser/providers/{id}",
|
||||
scope: "admin",
|
||||
summary: "修改浏览器服务地址、访问凭据或启用(环境变量登记的只能改启用)",
|
||||
params: browser.BrowserProviderParams,
|
||||
body: browser.BrowserProviderUpdate,
|
||||
response: browser.BrowserProviderView,
|
||||
}),
|
||||
probeProvider: ep({
|
||||
method: "post",
|
||||
path: "/browser/providers/{id}/probe",
|
||||
scope: "admin",
|
||||
summary: "测试连通:探测该浏览器服务的 /health 与 /openapi.json 并写回能力",
|
||||
params: browser.BrowserProviderParams,
|
||||
response: browser.BrowserProviderView,
|
||||
}),
|
||||
sessions: ep({
|
||||
method: "get",
|
||||
path: "/browser/sessions",
|
||||
|
||||
@@ -240,8 +240,12 @@ export const BrowserProviderView = z.object({
|
||||
baseUrl: z.string().nullable(),
|
||||
/** 访问凭据的 secret 引用(明文永不出 API) */
|
||||
secretRef: Id.nullable(),
|
||||
/** env = 由 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 登记(地址与凭据只能改部署配置);null = 设置页登记 */
|
||||
managedBy: z.enum(["env"]).nullable(),
|
||||
/** 是否配置了访问凭据(secret 或环境变量) */
|
||||
hasAccessKey: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
/** 最近一次声明/探测到的能力;verified 只由人工核验写入 config.verified */
|
||||
/** 最近一次声明/探测到的能力;verified 只由人工核验写入(config.verified 或适配器内置的已核验版本表) */
|
||||
capabilities: BrowserCapabilities.nullable(),
|
||||
verifiedAt: Timestamp.nullable(),
|
||||
healthStatus: InstanceHealth,
|
||||
@@ -251,6 +255,30 @@ export const BrowserProviderView = z.object({
|
||||
});
|
||||
export type BrowserProviderView = z.infer<typeof BrowserProviderView>;
|
||||
|
||||
const HttpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((u) => /^https?:\/\//i.test(u), "只支持 http(s) 地址");
|
||||
|
||||
/** 设置页登记浏览器服务(camofox);accessKey 明文只在请求里出现一次,落库为 secret */
|
||||
export const BrowserProviderCreate = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
baseUrl: HttpUrl,
|
||||
accessKey: z.string().min(1).max(512).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type BrowserProviderCreate = z.infer<typeof BrowserProviderCreate>;
|
||||
|
||||
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据 */
|
||||
export const BrowserProviderUpdate = z.object({
|
||||
baseUrl: HttpUrl.optional(),
|
||||
accessKey: z.string().min(1).max(512).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type BrowserProviderUpdate = z.infer<typeof BrowserProviderUpdate>;
|
||||
|
||||
export const BrowserProviderParams = z.object({ id: Id });
|
||||
|
||||
export const BrowserProvidersQuery = z.object({
|
||||
/** true = 现场探测 /health 与 /openapi.json 并写回 */
|
||||
probe: QueryBool,
|
||||
|
||||
@@ -381,8 +381,10 @@ export const SystemProcess = z.object({
|
||||
lastBeatAt: Timestamp,
|
||||
/** 距上次心跳秒数 */
|
||||
beatAgeSec: z.number().nonnegative(),
|
||||
/** 超过 45s 没心跳 */
|
||||
/** 超过 45s 没心跳(只对当前实例告警) */
|
||||
stale: z.boolean(),
|
||||
/** 进程正常退出时写入;非 null 即已下线 */
|
||||
stoppedAt: Timestamp.nullable(),
|
||||
/** 最近一次采样的事件循环延迟 */
|
||||
eventLoopLagMs: NonNegInt,
|
||||
/** 最近一个心跳周期(15s)内的最大延迟 */
|
||||
@@ -434,7 +436,10 @@ export const SystemHealth = z.object({
|
||||
checkedAt: Timestamp,
|
||||
/** 处理本请求的 api 进程 */
|
||||
api: z.object({ version: z.string(), startedAt: Timestamp, uptimeSec: z.number().nonnegative() }),
|
||||
/** 当前实例:最近 10 分钟内有心跳的 host 上、同 host 同 kind 最新启动且未退出的进程;stale 与告警只看这里 */
|
||||
processes: z.array(SystemProcess),
|
||||
/** 已下线实例:已正常退出、所在 host 10 分钟没有心跳(容器已重建)、或被同 host 新进程替换;不计入 level */
|
||||
retiredProcesses: z.array(SystemProcess),
|
||||
sse: z.object({ connections: NonNegInt }),
|
||||
db: z.object({
|
||||
level: HealthLevel,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/db",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// 心跳残留修复:进程正常退出(SIGTERM / SIGINT)时把自己的行标 stopped_at,读取方立即把它当已下线实例,
|
||||
// 不再等 45s 判 stale。重启后同一 instance_id 的 upsert 把 stopped_at 清回 null。
|
||||
// 超过 24 小时没心跳的行由定时任务 system.heartbeat_cleanup 删除。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `alter table process_heartbeat add column stopped_at timestamptz;`);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, `alter table process_heartbeat drop column if exists stopped_at;`);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// 统一从本包取 Kysely 与 sql,避免各包各装一份 kysely
|
||||
export { type Kysely, sql, type Transaction } from "kysely";
|
||||
export { type Kysely, type Selectable, sql, type Transaction } from "kysely";
|
||||
export {
|
||||
type CachedOptions,
|
||||
cachedCall,
|
||||
|
||||
@@ -1152,6 +1152,7 @@ export interface ProcessHeartbeatTable {
|
||||
concurrency: Generated<Json>;
|
||||
running_runs: number | null;
|
||||
extra: Generated<Json>;
|
||||
stopped_at: Timestamptz | null;
|
||||
}
|
||||
|
||||
export interface ProcessHeartbeatSampleTable {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/domain",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/mapping",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/tasks",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umt/testkit",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
|
||||
@@ -4,14 +4,15 @@ import { deflateSync } from "node:zlib";
|
||||
import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts";
|
||||
|
||||
/**
|
||||
* mock camofox-browser(形状按 jo-inc/camofox-browser openapi.json 1.17.0,取值虚构,未对真实实例核验)。
|
||||
* mock camofox-browser(形状按 jo-inc/camofox-browser v1.17.0,2026-09-28 与真实容器对照过请求/响应字段;取值虚构)。
|
||||
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、DELETE /tabs/{id}、POST /tabs/{id}/navigate、
|
||||
* POST /tabs/{id}/{back,forward,refresh}、GET /tabs/{id}/snapshot、GET /tabs/{id}/screenshot(base64 PNG)、
|
||||
* POST /tabs/{id}/{click,type,select,scroll,press,extract}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
|
||||
* POST /tabs/{id}/{click,type,select,scroll,press,extract,evaluate}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
|
||||
* 不给 site:快照为固定文本(含密码值/Cookie/passkey 供脱敏测试)。
|
||||
* 给 site:模拟一个 NexusPHP 风格站点——未登录访问受保护页重定向登录页,登录页可带验证码(需人工),
|
||||
* 点击/输入改变页面,点"下载"把 .torrent 放进该标签页的下载列表;登录状态按 userId(profile)保存。
|
||||
* 坐标点击:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H)。
|
||||
* 坐标:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H);与真实上游一样
|
||||
* click 不接受 {x, y},适配器先 evaluate(elementFromPoint)打 data-umt-pt 标记再按选择器点击。
|
||||
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 401。
|
||||
* 故障注入(401/超时/断连)用 MockHttp.fault。
|
||||
*/
|
||||
@@ -58,6 +59,8 @@ export interface MockCamofoxTab {
|
||||
/** 元素引用 → 已输入的值 */
|
||||
fields: Map<string, string>;
|
||||
focus: string | null;
|
||||
/** evaluate(elementFromPoint)打的 data-umt-pt 标记 → 元素引用 */
|
||||
marks: Map<string, string>;
|
||||
downloads: MockCamofoxDownload[];
|
||||
error: string | null;
|
||||
}
|
||||
@@ -121,6 +124,7 @@ const OPENAPI_PATHS: Record<string, string[]> = {
|
||||
"/tabs/{tabId}/click": ["post"],
|
||||
"/tabs/{tabId}/type": ["post"],
|
||||
"/tabs/{tabId}/select": ["post"],
|
||||
"/tabs/{tabId}/evaluate": ["post"],
|
||||
"/tabs/{tabId}/extract": ["post"],
|
||||
"/tabs/{tabId}/downloads": ["get"],
|
||||
"/sessions/{userId}": ["delete"],
|
||||
@@ -403,8 +407,11 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
const findLine = (t: MockCamofoxTab, b: Body): Line | undefined => {
|
||||
const lines = pageLines(t);
|
||||
if (typeof b.ref === "string") return lines.find((l) => l.ref === b.ref);
|
||||
if (typeof b.y === "number") return lines[Math.floor((b.y - MOCK_ROW_Y0) / MOCK_ROW_H)];
|
||||
if (typeof b.selector === "string") return lines.find((l) => l.role.startsWith("button"));
|
||||
if (typeof b.selector === "string") {
|
||||
const mark = /^\[data-umt-pt="([^"]+)"\]$/.exec(b.selector)?.[1];
|
||||
if (mark) return lines.find((l) => l.ref === t.marks.get(mark));
|
||||
return lines.find((l) => l.role.startsWith("button"));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -473,6 +480,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
cursor: -1,
|
||||
fields: new Map(),
|
||||
focus: null,
|
||||
marks: new Map(),
|
||||
downloads: [],
|
||||
error: null,
|
||||
};
|
||||
@@ -565,6 +573,9 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
[
|
||||
/^\/tabs\/([^/]+)\/click$/,
|
||||
action("click", (t, b) => {
|
||||
// 1.17.0 实测:只认 ref / selector,坐标点击由适配器先 evaluate 打标记
|
||||
if (typeof b.ref !== "string" && typeof b.selector !== "string")
|
||||
return err(400, "ref or selector required");
|
||||
const line = findLine(t, b);
|
||||
if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined;
|
||||
if (line.role.startsWith("textbox")) t.focus = line.ref;
|
||||
@@ -576,6 +587,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
/^\/tabs\/([^/]+)\/type$/,
|
||||
action("type", (t, b) => {
|
||||
if (typeof b.text !== "string") return err(400, "text required");
|
||||
if (typeof b.ref !== "string" && typeof b.selector !== "string" && b.mode !== "keyboard")
|
||||
return err(400, "ref or selector required for mode=fill");
|
||||
const ref = typeof b.ref === "string" ? b.ref : (findLine(t, b)?.ref ?? t.focus);
|
||||
if (!ref) return err(422, "No focused element");
|
||||
t.fields.set(ref, b.clear === false ? (t.fields.get(ref) ?? "") + b.text : b.text);
|
||||
@@ -584,6 +597,21 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return undefined;
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^\/tabs\/([^/]+)\/evaluate$/,
|
||||
action("evaluate", (t, b) => {
|
||||
// 只模拟适配器的坐标定位表达式:elementFromPoint(x, y) + data-umt-pt 标记
|
||||
const expr = typeof b.expression === "string" ? b.expression : "";
|
||||
const pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr);
|
||||
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
|
||||
if (!pt || !nonce) return { json: { ok: true, result: null } };
|
||||
const line = pageLines(t)[Math.floor((Number(pt[2]) - MOCK_ROW_Y0) / MOCK_ROW_H)];
|
||||
if (!line) return { json: { ok: true, result: false } };
|
||||
t.marks.clear();
|
||||
t.marks.set(nonce, line.ref);
|
||||
return { json: { ok: true, result: true } };
|
||||
}),
|
||||
],
|
||||
[/^\/tabs\/([^/]+)\/select$/, action("select", () => undefined)],
|
||||
[/^\/tabs\/([^/]+)\/scroll$/, action("scroll", () => undefined)],
|
||||
[
|
||||
@@ -617,16 +645,20 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
const t = tabFor(m[1]!, q.userId);
|
||||
if (!t) return err(404, "Tab not found");
|
||||
const max = q.maxBytes ? Number(q.maxBytes) : Number.POSITIVE_INFINITY;
|
||||
// 字段形状按 1.17.0 实测(lib/downloads.js getDownloadsList)
|
||||
const list = t.downloads.map((d) => ({
|
||||
id: d.id,
|
||||
filename: d.filename,
|
||||
url: d.url,
|
||||
suggestedFilename: d.filename,
|
||||
mimeType: d.mimeType,
|
||||
size: d.bytes.byteLength,
|
||||
status: "completed",
|
||||
...(q.includeData === "true" && d.bytes.byteLength <= max
|
||||
? { data: Buffer.from(d.bytes).toString("base64") }
|
||||
: { truncated: d.bytes.byteLength > max }),
|
||||
bytes: d.bytes.byteLength,
|
||||
createdAt: Date.now(),
|
||||
failure: null,
|
||||
...(q.includeData === "true"
|
||||
? d.bytes.byteLength <= max
|
||||
? { dataBase64: Buffer.from(d.bytes).toString("base64") }
|
||||
: { dataSkipped: "max_bytes_exceeded" }
|
||||
: {}),
|
||||
}));
|
||||
if (q.consume === "true") t.downloads = [];
|
||||
return { json: { downloads: list } };
|
||||
|
||||
Reference in new issue
Block a user