Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/app/repo/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Dexie from "dexie";
import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type migrateType from "../migrate";
import type { Logger, LoggerDAO as LoggerDAOType } from "./logger";

let dao: LoggerDAOType;

beforeAll(async () => {
// dao.ts 在模块求值时创建 Dexie 单例并取走当时的 IndexedDB 来源:先换成独立的 fake-indexeddb 再重新加载
// 模块(本文件在 isolated 项目中运行,替换不会泄漏给其它文件),并用迁移声明的真实表结构打开数据库。
// default 导出的类型断言沿用 migrate.test.ts 的做法。
Dexie.dependencies.indexedDB = new IDBFactory();
Dexie.dependencies.IDBKeyRange = IDBKeyRange;
vi.resetModules();
const migrateModule = await import("../migrate.js");
await (migrateModule.default as unknown as typeof migrateType)();
const { LoggerDAO } = await import("./logger.js");
dao = new LoggerDAO();
});

beforeEach(async () => {
await dao.clear();
});

// 与 DBWriter 写日志的方式一致:id 为 0 时由自增主键分配,写入先后决定 id 大小。
async function seed(rows: Array<Pick<Logger, "message" | "createtime">>) {
for (const row of rows) {
await dao.save({ id: 0, level: "info", label: {}, ...row });
}
}

describe("LoggerDAO.walkNewestFirst", () => {
it("只访问 createtime ≥ since 的记录:从新到旧,同一毫秒内后写入的在前", async () => {
await seed([
{ message: "before since", createtime: 99 },
{ message: "at since", createtime: 100 },
{ message: "same ms first", createtime: 200 },
{ message: "same ms second", createtime: 200 },
{ message: "newest", createtime: 300 },
]);
const visited: string[] = [];

await dao.walkNewestFirst(100, (row) => {
visited.push(row.message);
return true;
});

expect(visited).toEqual(["newest", "same ms second", "same ms first", "at since"]);
});

it("visit 返回 false 后不再访问更早的记录", async () => {
await seed([1, 2, 3, 4].map((n) => ({ message: `row ${n}`, createtime: n })));
const visited: string[] = [];

await dao.walkNewestFirst(0, (row) => {
visited.push(row.message);
return visited.length < 2;
});

expect(visited).toEqual(["row 4", "row 3"]);
});
});
16 changes: 16 additions & 0 deletions src/app/repo/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ export class LoggerDAO extends DAO<Logger> {
return ret.sort((a, b) => b.createtime - a.createtime);
}

/**
* 沿 createtime 索引从最新往回逐条读取 createtime ≥ since 的日志,同一毫秒内 id 大(后写入)的在前;
* visit 返回 false 时停止读取。按标签筛选的调用方据此只读到凑够结果为止,不把整段日志载入内存。
*/
async walkNewestFirst(since: number, visit: (row: Logger) => boolean): Promise<void> {
let done = false;
await this.table
.where("createtime")
.aboveOrEqual(since)
.reverse()
.until(() => done)
.each((row) => {
done = !visit(row);
});
}

deleteBefore(time: number) {
return this.table.where("createtime").below(time).delete();
}
Expand Down
97 changes: 96 additions & 1 deletion src/app/service/sandbox/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ vi.mock("../content/exec_warp", () => ({
BgExecScriptWarp: function (...args: any[]) {
return BgExecScriptWarpCtor(...args);
},
CATRetryError: class CATRetryError {},
CATRetryError: class CATRetryError {
constructor(
public msg: string,
public time: Date
) {}
},
}));

vi.mock("../offscreen/client", () => ({
Expand All @@ -31,6 +36,9 @@ vi.mock("../offscreen/client", () => ({

import { Runtime } from "./runtime";
import { initLanguage } from "@App/locales/locales";
import { proxyUpdateRunStatus } from "../offscreen/client";
import { CATRetryError } from "../content/exec_warp";
import { SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts";

initTestEnv();
// 生产中 sandbox init() 会先 initLanguage(),setSandboxLanguage 依赖已初始化的 i18n
Expand Down Expand Up @@ -163,3 +171,90 @@ describe("Runtime.executeSkillScript 执行环境", () => {
expect(BgExecScriptWarpCtor.mock.calls[0][3]).toBe("pt-BR");
});
});

describe("Runtime.execScript 被停止的旧运行迟到结束", () => {
// 每次 exec 返回一个由测试控制结束时机的运行;started[n] 在第 n 次 exec 被调用时兑现。
let runs: Array<{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }>;
let execCalled: Array<() => void>;
const execStarted = (n: number) => new Promise<void>((resolve) => (execCalled[n] = resolve));

beforeEach(() => {
BgExecScriptWarpCtor.mockClear();
mockStop.mockClear();
vi.mocked(proxyUpdateRunStatus).mockClear();
runs = [];
execCalled = [];
mockExec.mockImplementation(
() =>
new Promise((resolve, reject) => {
runs.push({ resolve, reject });
execCalled[runs.length]?.();
})
);
vi.useFakeTimers();
});

afterEach(() => {
mockExec.mockReset().mockResolvedValue(undefined);
vi.useRealTimers();
});

// 返回本次运行使用的 logger,info/error 被调用时兑现对应 Promise,作为旧运行收尾处理已执行完的信号。
const spyRunLogger = (runtime: Runtime) => {
const runLogger = runtime.logger.with();
vi.spyOn(runtime.logger, "with").mockReturnValue(runLogger);
let infoLogged!: () => void;
let errorLogged!: () => void;
const info = new Promise<void>((resolve) => (infoLogged = resolve));
const error = new Promise<void>((resolve) => (errorLogged = resolve));
vi.spyOn(runLogger, "info").mockImplementation(() => infoLogged());
vi.spyOn(runLogger, "error").mockImplementation(() => errorLogged());
return { info, error };
};

// execScript 的返回值就是脚本运行的 Promise(交给消息层答复调用方),返回旧运行的那一个供断言其结局。
const startTwoRuns = async (runtime: Runtime, script: ScriptLoadInfo) => {
const firstStarted = execStarted(1);
const oldRun = runtime.execScript(script);
await firstStarted;
const secondStarted = execStarted(2);
void runtime.execScript(script);
await secondStarted;
vi.mocked(proxyUpdateRunStatus).mockClear();
return { oldRun };
};

it("旧运行在新运行开始后才完成:不发布 COMPLETE,新运行结束时照常发布", async () => {
const runtime = setup({ inIncognitoContext: false });
const logged = spyRunLogger(runtime);
const script = buildScript();
const { oldRun } = await startTwoRuns(runtime, script);

runs[0].resolve("old");
await expect(oldRun).resolves.toBe("old");
await logged.info;
expect(proxyUpdateRunStatus).not.toHaveBeenCalled();

runs[1].resolve("new");
await vi.waitFor(() =>
expect(proxyUpdateRunStatus).toHaveBeenCalledWith(expect.anything(), {
uuid: script.uuid,
runStatus: SCRIPT_RUN_STATUS_COMPLETE,
})
);
});

it("旧运行在新运行开始后才以 CATRetryError 失败:不发布 ERROR、不加入重试队列", async () => {
const runtime = setup({ inIncognitoContext: false });
const logged = spyRunLogger(runtime);
const script = buildScript(undefined, SCRIPT_TYPE_CRONTAB);
const { oldRun } = await startTwoRuns(runtime, script);

runs[0].reject(new CATRetryError("retry later", new Date(Date.now() + 60_000)));
await expect(oldRun).rejects.toBeInstanceOf(CATRetryError);
await logged.error;

expect(proxyUpdateRunStatus).not.toHaveBeenCalled();
expect(runtime.retryList).toEqual([]);
});
});
64 changes: 15 additions & 49 deletions src/app/service/sandbox/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,15 @@ import { BgExecScriptWarp } from "../content/exec_warp";
import type ExecScript from "../content/exec_script";
import { compileScriptCodeByResource } from "../content/utils";
import type { ValueUpdateDataEncoded } from "../content/types";
import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils";
import { getStorageName, getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils";
import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types";
import { CATRetryError } from "../content/exec_warp";
import { parseUserConfig } from "@App/pkg/utils/yaml";
import { decodeRValue } from "@App/pkg/utils/message_value";
import { extractCronExpr } from "@App/pkg/utils/cron";
import { changeLanguage, initLanguage, t } from "@App/locales/locales";
import { type TExtensionEnv } from "../extension/extension_env";

const utime_1min = 60 * 1000;
const utime_1hr = 60 * 60 * 1000;
const utime_1day = 24 * 60 * 60 * 1000;
import { backgroundRunInAllowed, isOnceRunDone } from "./utils";

export class Runtime {
cronJob: Map<string, Array<CronJob>> = new Map();
Expand Down Expand Up @@ -143,33 +140,7 @@ export class Runtime {
const execOnce = opts?.execOnce ?? false;
const cronJobOncePos = opts?.cronJobOncePos ?? 0;

if (cronJobOncePos >= 1) {
// 没有最后一次执行时间表示之前都没执行过,直接执行
if (script.lastruntime) {
const now = new Date();
const last = new Date(script.lastruntime);
// 根据once所在的位置去判断执行
const timeDiff = now.getTime() - last.getTime();
switch (cronJobOncePos) {
case 1: // 每分钟
if (timeDiff < 2 * utime_1min && last.getMinutes() === now.getMinutes()) return;
break;
case 2: // 每小时
if (timeDiff < 2 * utime_1hr && last.getHours() === now.getHours()) return;
break;
case 3: // 每天
if (timeDiff < 2 * utime_1day && last.getDay() === now.getDay()) return;
break;
case 4: // 每月
if (timeDiff < 62 * utime_1day && last.getMonth() === now.getMonth()) return;
break;
case 5: // 每周
if (timeDiff < 14 * utime_1day && getISOWeek(last) === getISOWeek(now)) return;
break;
default:
}
}
}
if (isOnceRunDone(cronJobOncePos, script.lastruntime, new Date())) return;

const logger = this.logger.with({ uuid: script.uuid, name: script.name });
if (this.execScriptMap.has(script.uuid)) {
Expand All @@ -178,19 +149,8 @@ export class Runtime {
await this.stopScript(script.uuid);
}
const extensionEnv = await this.extensionEnvAsync;

// 判断 run-in
const runIn = script.metadata?.["run-in"]?.[0];
const inIncognitoContext = extensionEnv?.inIncognitoContext;
// Firefox spanning 只有一个共享 event page / sandbox,没有按普通窗口与隐身窗口拆分的后台环境。
// 因此后台/定时脚本只能运行一次;tab 专属的 run-in 值在该共享环境中不做伪隔离。
const hasPerContextBackground = extensionEnv?.incognitoMode !== "spanning";
if (hasPerContextBackground && runIn && runIn !== "all" && typeof inIncognitoContext === "boolean") {
// 判断插件运行环境
const contextType = inIncognitoContext ? "incognito-tabs" : "normal-tabs";
if (runIn !== contextType) {
return;
}
if (!backgroundRunInAllowed(script.metadata, extensionEnv)) {
return;
}

const exec = new BgExecScriptWarp(script, this.windowMessage, extensionEnv, this.userLanguage);
Expand All @@ -199,23 +159,27 @@ export class Runtime {
// 修改掉脚本掉最后运行时间, 数据库也需要修改
script.lastruntime = Date.now();
const ret = exec.exec();
// stopScript 只清掉旧运行的计时器,它的 Promise 仍可能在重跑或停用之后才结束。运行状态不带运行 id,
// 只有仍登记为该脚本当前运行的 exec 才能发布终态和安排重试,否则会覆盖新运行(或已停用)的状态。
const isCurrent = () => this.execScriptMap.get(script.uuid) === exec;
if (ret instanceof Promise) {
ret
.then((resp) => {
// 发送执行完成消息
proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE });
if (isCurrent()) {
proxyUpdateRunStatus(this.windowMessage, { uuid: script.uuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE });
}
logger.info("exec script complete", {
value: resp,
});
})
.catch((err) => {
// 发送执行完成+错误消息
const current = isCurrent();
let errMsg;
let nextruntime = 0;
if (err instanceof CATRetryError) {
// @ts-ignore
errMsg = { error: err.msg };
if (!execOnce) {
if (!execOnce && current) {
// 下一次执行时间
// @ts-ignore
nextruntime = err.time.getTime();
Expand All @@ -226,6 +190,8 @@ export class Runtime {
errMsg = Logger.E(err);
}
logger.error("exec script error", errMsg);
// 被停止的运行已不代表该脚本:它的错误只记入上面的日志,不再当作当前运行的失败抛出。
if (!current) return;
proxyUpdateRunStatus(this.windowMessage, {
uuid: script.uuid,
runStatus: SCRIPT_RUN_STATUS_ERROR,
Expand Down
49 changes: 49 additions & 0 deletions src/app/service/sandbox/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { TExtensionEnv } from "../extension/extension_env";
import { backgroundRunInAllowed, isOnceRunDone } from "./utils";

const at = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s);
const env = (inIncognitoContext: boolean | undefined, incognitoMode = "split") =>
({ inIncognitoContext, incognitoMode }) as TExtensionEnv;

describe("isOnceRunDone:cron 的 once 字段所在周期内已运行过则跳过", () => {
it("没有 once(位置 < 1)或从未运行过时不跳过", () => {
expect(isOnceRunDone(0, at(2026, 11, 5, 10).getTime(), at(2026, 11, 5, 10, 30))).toBe(false);
expect(isOnceRunDone(3, undefined, at(2026, 11, 5, 10))).toBe(false);
});

it.each([
["分钟", 1, at(2026, 11, 5, 10, 30, 1), at(2026, 11, 5, 10, 30, 59), at(2026, 11, 5, 10, 31, 0)],
["小时", 2, at(2026, 11, 5, 10, 1), at(2026, 11, 5, 10, 59), at(2026, 11, 5, 11, 0)],
["天", 3, at(2026, 11, 5, 0, 1), at(2026, 11, 5, 23, 59), at(2026, 11, 6, 0, 0)],
["月", 4, at(2026, 11, 1), at(2026, 11, 30, 23), at(2026, 12, 1)],
["周(ISO 周一起算)", 5, at(2026, 11, 2), at(2026, 11, 8, 23), at(2026, 11, 9)],
])("%s周期:同一周期内跳过,进入下一周期即可运行", (_name, pos, last, samePeriod, nextPeriod) => {
expect(isOnceRunDone(pos, last.getTime(), samePeriod)).toBe(true);
expect(isOnceRunDone(pos, last.getTime(), nextPeriod)).toBe(false);
});

it("相隔整整一个更大的周期、同名时间单位相同时不跳过(如隔了一周的同一星期几)", () => {
expect(isOnceRunDone(3, at(2026, 11, 5, 9).getTime(), at(2026, 11, 12, 9))).toBe(false);
expect(isOnceRunDone(2, at(2026, 11, 5, 9).getTime(), at(2026, 11, 6, 9, 30))).toBe(false);
});
});

describe("backgroundRunInAllowed:@run-in 与扩展环境(普通 / 隐身)是否匹配", () => {
const meta = (runIn?: string) => (runIn ? { "run-in": [runIn] } : {});

it("普通环境只运行 normal-tabs,隐身环境只运行 incognito-tabs", () => {
expect(backgroundRunInAllowed(meta("normal-tabs"), env(false))).toBe(true);
expect(backgroundRunInAllowed(meta("incognito-tabs"), env(false))).toBe(false);
expect(backgroundRunInAllowed(meta("incognito-tabs"), env(true))).toBe(true);
expect(backgroundRunInAllowed(meta("normal-tabs"), env(true))).toBe(false);
});

it("未写 run-in、run-in 为 all、拿不到隐身状态或 Firefox spanning 共享后台时都允许", () => {
expect(backgroundRunInAllowed(meta(), env(true))).toBe(true);
expect(backgroundRunInAllowed(meta("all"), env(true))).toBe(true);
expect(backgroundRunInAllowed(meta("incognito-tabs"), env(undefined))).toBe(true);
expect(backgroundRunInAllowed(meta("incognito-tabs"), undefined)).toBe(true);
expect(backgroundRunInAllowed(meta("incognito-tabs"), env(false, "spanning"))).toBe(true);
});
});
Loading
Loading