From 6537fdd405ddf82521e853dd0cce302b8b002ce9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:13:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E9=87=8D=E6=9E=84=20G?= =?UTF-8?q?M=20value=20=E6=9B=B4=E6=96=B0=E6=B5=81=E7=A8=8B=EF=BC=9A?= =?UTF-8?q?=E4=BB=A5=20storageName=20=E5=88=86=E7=BB=84=E6=89=B9=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=B9=B6=E4=BF=9D=E8=AF=81=E9=A1=BA=E5=BA=8F=E4=B8=8E?= =?UTF-8?q?=20updatetime=20=E4=B8=A5=E6=A0=BC=E9=80=92=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../service/content/create_context.test.ts | 40 +-- src/app/service/content/exec_script.ts | 17 +- src/app/service/content/gm_api/gm_api.test.ts | 52 ++-- src/app/service/content/gm_api/gm_api.ts | 79 +++--- src/app/service/content/script_executor.ts | 13 +- src/app/service/content/script_runtime.ts | 4 +- src/app/service/content/scripting.ts | 4 +- src/app/service/content/types.ts | 16 +- src/app/service/queue.ts | 4 +- src/app/service/sandbox/runtime.ts | 35 ++- src/app/service/service_worker/runtime.ts | 14 +- src/app/service/service_worker/value.test.ts | 243 ++++++++++++------ src/app/service/service_worker/value.ts | 199 ++++++++------ 13 files changed, 443 insertions(+), 277 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..5f5bc880c 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -141,7 +141,7 @@ describe.concurrent("createContext", () => { expect((context as any).loadScriptResolve).toBeUndefined(); }); - it.concurrent("setInvalidContext 会释放监听器且后续 valueUpdate 不再触发", () => { + it.concurrent("setInvalidContext 会释放监听器且后续 valueStoreUpdate 不再触发", async () => { const script = createScriptInfo(); const context = createContext( script, @@ -154,28 +154,34 @@ describe.concurrent("createContext", () => { const listener = vi.fn(); context.GM_addValueChangeListener("foo", listener); - context.valueUpdate({ - id: "remote-1", - uuid: script.uuid, - storageName: "", - sender: { runFlag: "other-run-flag", tabId: 7 }, - entries: [["foo", encodeRValue("next"), encodeRValue("bar")]], - valueUpdated: true, - }); + context.valueStoreUpdate(script.value, [ + { + id: "remote-1", + uuid: script.uuid, + storageName: "", + sender: { runFlag: "other-run-flag", tabId: 7 }, + valueChanges: [["foo", encodeRValue("next"), encodeRValue("bar")]], + }, + ]); + // 监听器回调延后到下一个 microTask 执行 + expect(listener).not.toHaveBeenCalled(); + await Promise.resolve(); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); context.setInvalidContext(); context.setInvalidContext(); expect(context.isInvalidContext()).toBe(true); - context.valueUpdate({ - id: "remote-2", - uuid: script.uuid, - storageName: "", - sender: { runFlag: "other-run-flag", tabId: 8 }, - entries: [["foo", encodeRValue("again"), encodeRValue("next")]], - valueUpdated: true, - }); + context.valueStoreUpdate(script.value, [ + { + id: "remote-2", + uuid: script.uuid, + storageName: "", + sender: { runFlag: "other-run-flag", tabId: 8 }, + valueChanges: [["foo", encodeRValue("again"), encodeRValue("next")]], + }, + ]); + await Promise.resolve(); expect(listener).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..e63773bcc 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -7,7 +7,8 @@ import type { Message } from "@Packages/message/types"; import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; import type { IGM_Base } from "./gm_api/gm_api"; -import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { ScriptRunResource, TScriptInfo } from "@App/app/repo/scripts"; +import { getStorageName } from "@App/pkg/utils/utils"; // 执行脚本,控制脚本执行与停止 export default class ExecScript { @@ -74,8 +75,18 @@ export default class ExecScript { this.sandboxContext?.emitEvent(event, eventId, data); } - valueUpdate(data: ValueUpdateDataEncoded) { - this.sandboxContext?.valueUpdate(data); + valueUpdate(storageName: string, uuid: string, responses: ValueUpdateDataEncoded[]) { + const scriptRes = this.scriptRes; + if (scriptRes.uuid !== uuid && getStorageName(scriptRes) !== storageName) return; + const context = this.sandboxContext; + if (!context) return; + const contextScriptRes = context.scriptRes as ScriptRunResource | null | undefined; + if (!contextScriptRes) return; + // 以 extValueStoreCopy(SW 确认顺序的快照)为基准应用更新,使各页面 valueStore 的 key 顺序一致 + contextScriptRes.value = context.extValueStoreCopy || contextScriptRes.value; + const valueStore = contextScriptRes.value; + context.valueStoreUpdate(valueStore, responses); + context.extValueStoreCopy = { ...valueStore }; } execContext: any; diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index 8a94e1b66..cbdef52e3 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -6,6 +6,7 @@ import { compileScript, compileScriptCode } from "../utils"; import type { Message } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { getStorageName } from "@App/pkg/utils/utils"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -1115,14 +1116,15 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 - exec.valueUpdate({ - id: "id-1", - entries: [["param1", encodeRValue(123), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: "id-1", + valueChanges: [["param1", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + }, + ]); const ret = await retPromise; expect(ret).toEqual({ name: "param1", oldValue: undefined, newValue: 123, remote: false }); }); @@ -1155,14 +1157,15 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const retPromise = exec.exec(); expect(mockSendMessage).toHaveBeenCalledTimes(1); // 模拟值变化 - exec.valueUpdate({ - id: "id-2", - entries: [["param2", encodeRValue(456), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: "testStorage", - sender: { runFlag: "user", tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: "id-2", + valueChanges: [["param2", encodeRValue(456), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: "testStorage", + sender: { runFlag: "user", tabId: -2 }, + }, + ]); const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); @@ -1195,14 +1198,15 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(id).toBeTypeOf("string"); expect(id.length).greaterThan(0); // 触发valueUpdate - exec.valueUpdate({ - id: id, - entries: [["a", encodeRValue(123), encodeRValue(undefined)]], - uuid: script.uuid, - storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, - valueUpdated: true, - }); + exec.valueUpdate(getStorageName(script), script.uuid, [ + { + id: id, + valueChanges: [["a", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + }, + ]); const ret = await retPromise; expect(ret).toEqual(123); diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..4a3ccb051 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -15,11 +15,10 @@ import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/ import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; -import { type ScriptRunResource } from "@App/app/repo/scripts"; +import type { ScriptRunResource, ValueStore } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { ScriptEnvTag } from "@Packages/message/consts"; -import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; @@ -44,7 +43,7 @@ void CATAgentOPFSApi; export interface IGM_Base { sendMessage(api: string, params: any[]): Promise; connect(api: string, params: any[]): Promise; - valueUpdate(data: ValueUpdateDataEncoded): void; + valueStoreUpdate(valueStore: ValueStore, responses: ValueUpdateDataEncoded[]): void; emitEvent(event: string, eventId: string, data: any): void; } @@ -61,6 +60,15 @@ let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; const valueChangePromiseMap = new Map(); +const generateValChangeId = () => { + if (valChangeCounterId > 1e8) { + // 防止 valChangeCounterId 过大导致无法正常工作 + valChangeCounterId = 0; + valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; + } + return `${valChangeRandomId}::${++valChangeCounterId}`; +}; + const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 @@ -168,12 +176,13 @@ class GM_Base implements IGM_Base { } @GMContext.protected() - public valueUpdate(data: ValueUpdateDataEncoded) { - if (!this.scriptRes || !this.valueChangeListener) return; - const scriptRes = this.scriptRes; - const { id, uuid, entries, storageName, sender, valueUpdated } = data; - if (uuid === scriptRes.uuid || storageName === getStorageName(scriptRes)) { - const valueStore = scriptRes.value; + public extValueStoreCopy?: ValueStore; // 以 SW 确认顺序为准的 store 快照,使各页面 valueStore 的 key 顺序一致 + + @GMContext.protected() + public valueStoreUpdate(valueStore: ValueStore, responses: ValueUpdateDataEncoded[]) { + // 同步更新 valueStore;监听器回调延后到下一个 microTask,避免阻塞更新流程 + for (const response of responses) { + const { id, valueChanges, sender } = response; const remote = sender.runFlag !== this.runFlag; if (!remote && id) { const fn = valueChangePromiseMap.get(id); @@ -182,21 +191,18 @@ class GM_Base implements IGM_Base { fn(); } } - if (valueUpdated) { - const valueChanges = entries; - for (const [key, rTyped1, rTyped2] of valueChanges) { - const value = decodeRValue(rTyped1); - const oldValue = decodeRValue(rTyped2); - // 触发,并更新值 - if (value === undefined) { - if (valueStore[key] !== undefined) { - delete valueStore[key]; - } - } else { - valueStore[key] = value; - } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + for (const [key, rTyped1, rTyped2] of valueChanges) { + const value = decodeRValue(rTyped1); + const oldValue = decodeRValue(rTyped2); + // 触发,并更新值 + if (value !== undefined) { + valueStore[key] = value; + } else if (valueStore[key] !== undefined) { + delete valueStore[key]; } + Promise.resolve().then(() => { + this.valueChangeListener?.execute(key, oldValue, value, remote, sender.tabId); + }); } } } @@ -283,17 +289,17 @@ export default class GMApi extends GM_Base { static _GM_setValue(a: GMApi, promise: any, key: string, value: any) { key = `${key}`; if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; + const id = generateValChangeId(); if (promise) { valueChangePromiseMap.set(id, promise); } + const valueStore = a.scriptRes.value; + if (!a.extValueStoreCopy) { + // 本地乐观写入前记下快照;快照只随 SW 确认的更新演进,key 顺序以 SW 为准 + a.extValueStoreCopy = { ...valueStore }; + } if (value === undefined) { - delete a.scriptRes.value[key]; + delete valueStore[key]; a.sendMessage("GM_setValue", [id, key]); } else { // 对object的value进行一次转化 @@ -301,7 +307,7 @@ export default class GMApi extends GM_Base { value = customClone(value); } // customClone 可能返回 undefined - a.scriptRes.value[key] = value; + valueStore[key] = value; if (value === undefined) { a.sendMessage("GM_setValue", [id, key]); } else { @@ -313,16 +319,15 @@ export default class GMApi extends GM_Base { static _GM_setValues(a: GMApi, promise: any, values: TGMKeyValue) { if (!a.scriptRes) return; - if (valChangeCounterId > 1e8) { - // 防止 valChangeCounterId 过大导致无法正常工作 - valChangeCounterId = 0; - valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; - } - const id = `${valChangeRandomId}::${++valChangeCounterId}`; + const id = generateValChangeId(); if (promise) { valueChangePromiseMap.set(id, promise); } const valueStore = a.scriptRes.value; + if (!a.extValueStoreCopy) { + // 本地乐观写入前记下快照;快照只随 SW 确认的更新演进,key 顺序以 SW 为准 + a.extValueStoreCopy = { ...valueStore }; + } const keyValuePairs = [] as [string, REncoded][]; for (const [key, value] of Object.entries(values)) { let value_ = value; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 75eb90bec..b8c5e4888 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -1,8 +1,7 @@ import type { Message } from "@Packages/message/types"; -import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; -import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ScriptFunc, ValueUpdateSendData } from "./types"; import { addStyleSheet, definePropertyListener, waitBody } from "./utils"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; @@ -46,12 +45,12 @@ export class ScriptExecutor { } } - valueUpdate(data: ValueUpdateDataEncoded) { + valueUpdate(sendData: ValueUpdateSendData) { // runtime/valueUpdate - const { uuid, storageName } = data; - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + const { storageName, storageChanges } = sendData; + for (const [uuid, responses] of Object.entries(storageChanges)) { + for (const execScript of this.execScriptMap.values()) { + execScript.valueUpdate(storageName, uuid, responses); } } } diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..da8ba9fdd 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -3,7 +3,7 @@ import type { Message } from "@Packages/message/types"; import { initEnvInfo, type ScriptExecutor } from "./script_executor"; import type { TScriptInfo } from "@App/app/repo/scripts"; import type { EmitEventRequest } from "../service_worker/types"; -import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ValueUpdateSendData } from "./types"; import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; @@ -59,7 +59,7 @@ export class ScriptRuntime { // 转发给脚本 this.scriptExecutor.emitEvent(data); }); - this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { + this.server.on("runtime/valueUpdate", (data: ValueUpdateSendData) => { this.scriptExecutor.valueUpdate(data); }); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 4a08a0265..3d200a69d 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -6,7 +6,7 @@ import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; -import type { ValueUpdateDataEncoded } from "./types"; +import type { ValueUpdateSendData } from "./types"; const PageOrContent = { PAGE: 1, @@ -73,7 +73,7 @@ export default class ScriptingRuntime { deliveryStorage.onChanged.addListener((changes) => { const record = changes["valueUpdateDelivery"]; if (record?.newValue) { - const sendData = (record.newValue as { sendData: ValueUpdateDataEncoded }).sendData; + const sendData = (record.newValue as { sendData: ValueUpdateSendData }).sendData; const activeOn = this.activeStorageNames === null ? PageOrContent.PAGE_AND_CONTENT diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..c1d4e6a6a 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -12,24 +12,20 @@ export type ValueUpdateSender = { /** * key, value, oldValue */ -export type ValueUpdateDataEntry = [string, any, any]; export type ValueUpdateDataREntry = [string, REncoded, REncoded]; -export type ValueUpdateData = { +export type ValueUpdateDataEncoded = { id?: string; - entries: ValueUpdateDataEntry[]; + valueChanges: ValueUpdateDataREntry[]; uuid: string; storageName: string; // 储存name sender: ValueUpdateSender; }; -export type ValueUpdateDataEncoded = { - id?: string; - entries: ValueUpdateDataREntry[]; - uuid: string; - storageName: string; // 储存name - sender: ValueUpdateSender; - valueUpdated: boolean; +// 以 storageName 为单位的推送数据;storageChanges 以 uuid 分组,同组内按处理顺序排列 +export type ValueUpdateSendData = { + storageName: string; + storageChanges: Record; }; // gm_api.ts diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index cb3070714..e5fb206c8 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -1,4 +1,4 @@ -import type { Script, SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; +import type { SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; import type { InstallSource, SWScriptMenuItemOption, @@ -30,8 +30,6 @@ export type TEnableScript = { uuid: string; enable: boolean }; export type TScriptRunStatus = { uuid: string; runStatus: SCRIPT_RUN_STATUS }; -export type TScriptValueUpdate = { script: Script; valueUpdated: boolean }; - export type TScriptMenuRegister = { uuid: string; key: TScriptMenuItemKey; diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index f5df396d0..b1d28d22b 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -14,7 +14,7 @@ import { proxyUpdateRunStatus } from "../offscreen/client"; 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 type { ValueUpdateSendData } from "../content/types"; import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; import { CATRetryError } from "../content/exec_warp"; @@ -340,21 +340,28 @@ export class Runtime { return this.execScript(loadScript, { execOnce: true }); } - valueUpdate(data: ValueUpdateDataEncoded) { + valueUpdate(sendData: ValueUpdateSendData) { // runtime/valueUpdate - const dataEntries = data.entries; - // 转发给脚本 - this.execScriptMap.forEach((val) => { - if (val.scriptRes.uuid === data.uuid || getStorageName(val.scriptRes) === data.storageName) { - val.valueUpdate(data); + const { storageName, storageChanges } = sendData; + for (const [uuid, responses] of Object.entries(storageChanges)) { + // 转发给脚本 + for (const execScript of this.execScriptMap.values()) { + execScript.valueUpdate(storageName, uuid, responses); } - }); - // 更新crontabScripts中的脚本值 - for (const script of this.crontabSripts) { - if (script.uuid === data.uuid || getStorageName(script) === data.storageName) { - for (const [key, rTyped1, _rTyped2] of dataEntries) { - const value = decodeRValue(rTyped1); - script.value[key] = value; + // 更新crontabScripts中的脚本值 + for (const script of this.crontabSripts) { + if (script.uuid === uuid || getStorageName(script) === storageName) { + const valueStore = script.value; + for (const { valueChanges } of responses) { + for (const [key, rTyped1, _rTyped2] of valueChanges) { + const value = decodeRValue(rTyped1); + if (value !== undefined) { + valueStore[key] = value; + } else if (valueStore[key] !== undefined) { + delete valueStore[key]; + } + } + } } } } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 55b7e0be6..bb73b2bd4 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -49,7 +49,7 @@ import { type SystemConfig } from "@App/pkg/config/config"; import { type ResourceService } from "./resource"; import { type LocalStorageDAO } from "@App/app/repo/localStorage"; import Logger from "@App/app/logger/logger"; -import type { GMInfoEnv, ValueUpdateDataEncoded } from "../content/types"; +import type { GMInfoEnv, ValueUpdateSendData } from "../content/types"; import { initLocalesPromise, localePath } from "@App/locales/locales"; import { DocumentationSite } from "@App/app/const"; import { extractUrlPatterns, RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; @@ -458,7 +458,7 @@ export class RuntimeService { await this.loadPageScript(script, apiScript!); } - public async pushValueUpdate(script: Script, sendData: ValueUpdateDataEncoded) { + public async pushValueUpdate(updatedScripts: Script[], sendData: ValueUpdateSendData) { try { // 前台腳本 (推送值到tab) await deliveryStorage!.set({ @@ -474,8 +474,8 @@ export class RuntimeService { await sendMessage(this.msgSender, "offscreen/runtime/valueUpdate", sendData); } - // valueUpdate 消息用于 early script 的处理 - if (sendData.valueUpdated) { + // updatedScripts 为本批次有实际值变更的脚本,用于 early script 的处理 + for (const script of updatedScripts) { if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) { // 如果是预加载脚本,需要更新脚本代码重新注册 // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变 @@ -483,11 +483,7 @@ export class RuntimeService { } } } catch (e) { - this.logger.error( - "push value update failed", - { uuid: script.uuid, storageName: sendData.storageName }, - Logger.E(e) - ); + this.logger.error("push value update failed", { storageName: sendData.storageName }, Logger.E(e)); } } diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..bd8e22b42 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -13,7 +13,9 @@ import { Server } from "@Packages/message/server"; import EventEmitter from "eventemitter3"; import { MessageQueue } from "@Packages/message/message_queue"; import type { ValueUpdateSender } from "../content/types"; -import { getStorageName } from "@App/pkg/utils/utils"; +import { deferred, getStorageName } from "@App/pkg/utils/utils"; +import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import type { TKeyValuePair } from "@App/pkg/utils/message_value"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { TrashScriptDAO, type TrashScript } from "@App/app/repo/trash_script"; @@ -26,8 +28,10 @@ initTestEnv(); beforeEach(() => createMockOPFS()); +const nextMacroTask = () => new Promise((r) => setTimeout(r, 0)); + /** - * ValueService.setValue 方法的单元测试 + * ValueService.setValues 方法的单元测试 * * 测试覆盖的场景: * 1. 设置新脚本的值(首次设置) @@ -35,8 +39,11 @@ beforeEach(() => createMockOPFS()); * 3. 值未改变时的处理(不进行保存) * 4. 删除值(设置为undefined) * 5. 脚本不存在时的错误处理 + * 6. 同一 storageName 的并发操作会被合并为一次读写与一次推送 + * 7. setValues 的处理顺序与调用顺序一致 + * 8. updatetime 严格递增 */ -describe("ValueService - setValue 方法测试", () => { +describe("ValueService - setValues 方法测试", () => { let valueService: ValueService; let mockGroup: Group; let mockMessageQueue: IMessageQueue; @@ -65,6 +72,19 @@ describe("ValueService - setValue 方法测试", () => { tabId: -2, }); + // pushValueUpdate 中单个 ValueUpdateDataEncoded 的期望结构 + const expectResponse = (id: string, mockScript: Script, changeCount: number) => + expect.objectContaining({ + id, + valueChanges: Array(changeCount).fill(expect.anything()), + uuid: mockScript.uuid, + storageName: getStorageName(mockScript), + sender: expect.objectContaining({ + runFlag: expect.any(String), + tabId: expect.any(Number), + }), + }); + beforeEach(() => { // 创建消息系统 const eventEmitter = new EventEmitter(); @@ -128,17 +148,12 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4021", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4021", mockScript, 1)], + }, }) ); @@ -180,17 +195,12 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4022", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4022", mockScript, 1)], + }, }) ); @@ -241,17 +251,12 @@ describe("ValueService - setValue 方法测试", () => { expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4023", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4023", mockScript, 1)], + }, }) ); @@ -291,24 +296,19 @@ describe("ValueService - setValue 方法测试", () => { isReplace: false, }); - // 验证结果 - 不应该保存或发送更新 + // 验证结果 - 不应该保存,但仍然推送(客户端依赖 id 回执解除等待) expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid); expect(mockValueDAO.get).toHaveBeenCalled(); expect(mockValueDAO.save).not.toHaveBeenCalled(); // 值未改变,不应该保存 expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [], // 没有实际变更的脚本 expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4024", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: false, + storageChanges: { + [mockScript.uuid]: [expectResponse("testId-4024", mockScript, 0)], + }, }) ); // 值未改变 }); @@ -352,7 +352,7 @@ describe("ValueService - setValue 方法测试", () => { expect(savedValue.data.otherKey).toBe("otherValue"); // 其他键保持不变 }); - it("当脚本不存在时应该抛出错误", async () => { + it("当脚本不存在时应该抛出错误,且不影响后续 setValues", async () => { // 准备测试数据 const nonExistentUuid = randomUUID(); const mockSender = createMockValueSender(); @@ -376,11 +376,25 @@ describe("ValueService - setValue 方法测试", () => { expect(mockValueDAO.get).not.toHaveBeenCalled(); expect(mockValueDAO.save).not.toHaveBeenCalled(); expect(valueService.pushValueUpdate).not.toHaveBeenCalled(); - expect(mockMessageQueue.emit).toHaveBeenCalledTimes(0); + + // 顺序队列不应因单次错误而卡死,后续 setValues 正常处理 + const mockScript = createMockScript(); + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + await valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4027", + keyValuePairs: keyValuePairs1, + valueSender: mockSender, + isReplace: false, + }); + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); }); - it("应该正确处理并发访问的缓存键", async () => { - // 这个测试验证 stackAsyncTask 的使用,确保相同 storageName 的操作不会冲突 + it("同一 storageName 的并发 setValues 应合并为一次读写与一次推送", async () => { + // 这个测试验证以 storageName 分组的批处理: + // 排队期间累积的多个 setValues 由一次 setValuesByStorageName 集中处理 const mockScript = createMockScript(); const mockSender = createMockValueSender(); const key1 = "key1"; @@ -396,10 +410,14 @@ describe("ValueService - setValue 方法测试", () => { expect(mockValueDAO.save).toHaveBeenCalledTimes(0); expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(0); + // 先占住该 storageName 的处理队列,让两个 setValues 的任务都先入队 + const d = deferred(); + stackAsyncTask(`${CACHE_KEY_SET_VALUE}${getStorageName(mockScript)}`, () => d.promise); + // 并发执行两个setValue操作 const keyValuePairs1 = [[key1, encodeRValue(value1)]] satisfies TKeyValuePair[]; const keyValuePairs2 = [[key2, encodeRValue(value2)]] satisfies TKeyValuePair[]; - await Promise.all([ + const ret = Promise.all([ valueService.setValues({ uuid: mockScript.uuid, id: "testId-4041", @@ -415,41 +433,122 @@ describe("ValueService - setValue 方法测试", () => { isReplace: false, }), ]); - - // 验证两个操作都被调用 + await nextMacroTask(); + // 队列被占住期间,两个任务都已入队但未处理 expect(mockScriptDAO.get).toHaveBeenCalledTimes(2); - expect(mockValueDAO.save).toHaveBeenCalledTimes(2); - expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(2); + expect(mockValueDAO.save).toHaveBeenCalledTimes(0); + d.resolve(); + await ret; + + // 验证两个操作被合并为一次批处理 + expect(mockValueDAO.get).toHaveBeenCalledTimes(1); + expect(mockValueDAO.save).toHaveBeenCalledTimes(1); + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( 1, - mockScript, + [mockScript], expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4041", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), storageName: getStorageName(mockScript), - uuid: mockScript.uuid, - valueUpdated: true, + storageChanges: { + [mockScript.uuid]: [ + expectResponse("testId-4041", mockScript, 1), + expectResponse("testId-4042", mockScript, 1), + ], + }, }) ); - expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith( - 2, - mockScript, - expect.objectContaining({ - entries: expect.any(Object), - id: "testId-4042", - sender: expect.objectContaining({ - runFlag: expect.any(String), - tabId: expect.any(Number), - }), - storageName: getStorageName(mockScript), + + // 验证两个键都已写入同一份数据 + const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; + const savedValue = saveCall[1]; + expect(savedValue.data[key1]).toBe(value1); + expect(savedValue.data[key2]).toBe(value2); + }); + + it("setValues 的处理顺序应与调用顺序一致", async () => { + // scriptDAO.get 的耗时差异不应打乱 setValues 的处理顺序 + const mockScript = createMockScript(); + const mockSender = createMockValueSender(); + const key = "orderKey"; + + // 第一次调用的 scriptDAO.get 较慢,第二次立即返回 + vi.mocked(mockScriptDAO.get) + .mockImplementationOnce(() => new Promise((r) => setTimeout(() => r(mockScript), 20))) + .mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + // 占住处理队列,保证两个任务进入同一个批次以便观察顺序 + const d = deferred(); + stackAsyncTask(`${CACHE_KEY_SET_VALUE}${getStorageName(mockScript)}`, () => d.promise); + + const ret = Promise.all([ + valueService.setValues({ uuid: mockScript.uuid, - valueUpdated: true, - }) - ); + id: "testId-4051", + keyValuePairs: [[key, encodeRValue("first")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4052", + keyValuePairs: [[key, encodeRValue("second")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + ]); + await new Promise((r) => setTimeout(r, 50)); + d.resolve(); + await ret; + + // 后写的胜出:最终值必须是第二次调用的 "second" + const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0]; + expect(saveCall[1].data[key]).toBe("second"); + // 推送的响应顺序也与调用顺序一致 + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); + const sendData = vi.mocked(valueService.pushValueUpdate).mock.calls[0][1]; + expect(sendData.storageChanges[mockScript.uuid].map((r) => r.id)).toEqual(["testId-4051", "testId-4052"]); + }); + + it("值变更时 updatetime 应严格递增", async () => { + const mockScript = createMockScript(); + const mockSender = createMockValueSender(); + const existingValueModel: Value = { + uuid: mockScript.uuid, + storageName: getStorageName(mockScript), + data: { k: "v0" }, + createtime: Date.now() - 1000, + updatetime: Date.now() - 1000, + }; + + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(existingValueModel); + const savedUpdatetimes: number[] = []; + vi.mocked(mockValueDAO.save).mockImplementation((_storageName, model) => { + savedUpdatetimes.push(model.updatetime); + return Promise.resolve({} as any); + }); + + // 连续两次变更(同一时间片内也必须保证 updatetime 变化) + await valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4061", + keyValuePairs: [["k", encodeRValue("v1")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + await valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4062", + keyValuePairs: [["k", encodeRValue("v2")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + + expect(savedUpdatetimes).toHaveLength(2); + expect(savedUpdatetimes[0]).toBeGreaterThan(existingValueModel.createtime); + expect(savedUpdatetimes[1]).toBeGreaterThan(savedUpdatetimes[0]); }); }); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 6f5af7ba5..b69c5503f 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -6,8 +6,13 @@ import { TrashScriptDAO } from "@App/app/repo/trash_script"; import type { IGetSender, Group } from "@Packages/message/server"; import { type RuntimeService } from "./runtime"; import { type PopupService } from "./popup"; -import { getStorageName } from "@App/pkg/utils/utils"; -import type { ValueUpdateDataEncoded, ValueUpdateDataREntry, ValueUpdateSender } from "../content/types"; +import { aNow, getStorageName } from "@App/pkg/utils/utils"; +import type { + ValueUpdateDataEncoded, + ValueUpdateDataREntry, + ValueUpdateSendData, + ValueUpdateSender, +} from "../content/types"; import { type TDeleteScript } from "../queue"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key"; @@ -24,6 +29,15 @@ export type TSetValuesParams = { valueSender?: ValueUpdateSender; }; +type ValueUpdateTask = { + script: Script; + id: string; + keyValuePairs: TKeyValuePair[]; + valueSender: ValueUpdateSender; + isReplace: boolean; + ts: number; +}; + export class ValueService { logger: Logger; scriptDAO: ScriptDAO = new ScriptDAO(); @@ -76,43 +90,38 @@ export class ValueService { return this.getScriptValueDetails(script).then((res) => res[0]); } - async pushValueUpdate(script: Script, sendData: T) { - return this.runtime!.pushValueUpdate(script, sendData); + async pushValueUpdate(updatedScripts: Script[], sendData: ValueUpdateSendData) { + return this.runtime!.pushValueUpdate(updatedScripts, sendData); } - // 批量设置 - async setValues(params: TSetValuesParams) { - const { uuid, keyValuePairs, isReplace } = params; - const id = params.id || ""; - const ts = params.ts || 0; - const valueSender = params.valueSender || { - runFlag: "user", - tabId: -2, - }; - // 查询出脚本 - const script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - // 查询老的值 - const storageName = getStorageName(script); - let oldValueRecord: ValueStore = {}; - const cacheKey = `${CACHE_KEY_SET_VALUE}${storageName}`; - const entries = [] as ValueUpdateDataREntry[]; - const _flag = await stackAsyncTask(cacheKey, async () => { - let valueModel: Value | undefined = await this.valueDAO.get(storageName); + // 排队中的 setValues 任务,以 storageName 分组,由 setValuesByStorageName 集中处理 + private valueUpdateTasks = new Map(); + + // 集中处理同一 storageName 下累积的 setValues 任务:一次读取、按序应用、一次保存、一次推送 + async setValuesByStorageName(storageName: string) { + const taskList = this.valueUpdateTasks.get(storageName); + if (!taskList?.length) return; + // 同步取走整批任务;之后入队的任务会建立新列表,由其对应的队列调用处理 + this.valueUpdateTasks.delete(storageName); + let valueModel: Value | undefined = await this.valueDAO.get(storageName); + const storageChanges: Record = {}; + // 有实际值变更的脚本(uuid 去重),供 early-start 脚本重新注册使用 + const updatedScripts = new Map(); + let valueModelUpdated = false; + for (const task of taskList) { + const { script, id, keyValuePairs, valueSender, isReplace, ts } = task; + const uuid = script.uuid; + const entries = [] as ValueUpdateDataREntry[]; + const now = aNow(); // 保证 updatetime 严格递增 + let changed = false; + let isNewModel = false; + let oldValueRecord: ValueStore = {}; + let dataModel: ValueStore; if (!valueModel) { - const now = Date.now(); - const dataModel: ValueStore = {}; - for (const [key, rTyped1] of keyValuePairs) { - const value = decodeRValue(rTyped1); - if (value !== undefined) { - dataModel[key] = value; - entries.push([key, rTyped1, R_UNDEFINED]); - } - } - // 即使是空 dataModel 也进行更新 - // 由于没entries, valueUpdated 是 false, 但 valueDAO 会有一个空的 valueModel 记录 updatetime + isNewModel = true; + // 即使无实际变更也保存空 dataModel,让 valueDAO 记录 updatetime + changed = true; + dataModel = {}; valueModel = { uuid: uuid, storageName: storageName, @@ -121,53 +130,89 @@ export class ValueService { updatetime: ts ? Math.min(ts, now) : now, }; } else { - let changed = false; - let dataModel = (oldValueRecord = valueModel.data); - dataModel = { ...dataModel }; // 每次储存使用新参考 - const containedKeys = new Set(); - for (const [key, rTyped1] of keyValuePairs) { - containedKeys.add(key); - const value = decodeRValue(rTyped1); - const oldValue = dataModel[key]; - if (oldValue === value) continue; - changed = true; - if (value === undefined) { - delete dataModel[key]; - } else { - dataModel[key] = value; - } - const rTyped2 = encodeRValue(oldValue); - entries.push([key, rTyped1, rTyped2]); + oldValueRecord = valueModel.data; + dataModel = { ...oldValueRecord }; // 每次储存使用新参考 + } + const containedKeys = new Set(); + for (const [key, rTyped1] of keyValuePairs) { + containedKeys.add(key); + const value = decodeRValue(rTyped1); + const oldValue = dataModel[key]; + if (oldValue === value) continue; + changed = true; + if (value === undefined) { + delete dataModel[key]; + } else { + dataModel[key] = value; } - if (isReplace) { - // 处理oldValue有但是没有在data.values中的情况 - for (const key of Object.keys(oldValueRecord)) { - if (!containedKeys.has(key)) { - changed = true; - const oldValue = oldValueRecord[key]; - delete dataModel[key]; // 这里使用delete是因为保存不需要这个字段了 - const rTyped2 = encodeRValue(oldValue); - entries.push([key, R_UNDEFINED, rTyped2]); - } + const rTyped2 = encodeRValue(oldValue); + entries.push([key, rTyped1, rTyped2]); + } + if (isReplace) { + // 处理oldValue有但是没有在data.values中的情况 + for (const key of Object.keys(oldValueRecord)) { + if (!containedKeys.has(key)) { + changed = true; + const oldValue = oldValueRecord[key]; + delete dataModel[key]; // 这里使用delete是因为保存不需要这个字段了 + const rTyped2 = encodeRValue(oldValue); + entries.push([key, R_UNDEFINED, rTyped2]); } } - if (!changed) return false; + } + if (changed) { valueModel.data = dataModel; // 每次储存使用新参考 + if (!isNewModel) { + valueModel.updatetime = now; + } + valueModelUpdated = true; + } + if (entries.length > 0 && !updatedScripts.has(uuid)) { + updatedScripts.set(uuid, script); + } + const list = storageChanges[uuid] || (storageChanges[uuid] = []); + list.push({ + id, + valueChanges: entries, + uuid, + storageName, + sender: valueSender, + }); + } + if (valueModelUpdated) { + await this.valueDAO.save(storageName, valueModel!); + } + // 推送到所有加载了本脚本的tab中;即使无实际变更也要推送,客户端依赖 id 回执解除等待 + this.pushValueUpdate([...updatedScripts.values()], { storageName, storageChanges }); + } + + // 批量设置 + async setValues(params: TSetValuesParams): Promise { + const { uuid, keyValuePairs, isReplace } = params; + const id = params.id || ""; + const ts = params.ts || 0; + const valueSender = params.valueSender || { + runFlag: "user", + tabId: -2, + }; + let storageName!: string; + // 顺序队列:入队顺序与 setValues 调用顺序一致, + // 不因 scriptDAO.get 的异步耗时差异而打乱 + await stackAsyncTask("valueChangeOnSequence", async () => { + // 查询出脚本 + const script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + storageName = getStorageName(script); + let taskList = this.valueUpdateTasks.get(storageName); + if (!taskList) { + this.valueUpdateTasks.set(storageName, (taskList = [])); } - await this.valueDAO.save(storageName, valueModel); - return true; + taskList.push({ script, id, keyValuePairs, valueSender, isReplace, ts }); }); - // 推送到所有加载了本脚本的tab中 - const valueUpdated = entries.length > 0; - const sendData = { - id, - entries: entries, - uuid, - storageName, - sender: valueSender, - valueUpdated, - } as ValueUpdateDataEncoded; - this.pushValueUpdate(script, sendData); + // valueDAO 读写以 storageName 为单位串行 + await stackAsyncTask(`${CACHE_KEY_SET_VALUE}${storageName}`, () => this.setValuesByStorageName(storageName)); } setScriptValues(params: Pick, _sender: IGetSender) { From 0a5b081d601c29b95a39fb1212313f1684ccab52 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:54:29 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E6=94=B6=E6=95=9B=20G?= =?UTF-8?q?M=20value=20=E9=87=8D=E6=9E=84=E7=9A=84=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/01_bug_report.yaml | 158 +------- .../02_userscript_compatibility.yaml | 81 +--- .../ISSUE_TEMPLATE/03_feature_request.yaml | 65 +--- .../ISSUE_TEMPLATE/04_technical_proposal.yaml | 43 +-- .github/ISSUE_TEMPLATE/05_documentation.yaml | 78 +--- .github/ISSUE_TEMPLATE/06_translation.yaml | 75 +--- .github/ISSUE_TEMPLATE/11_bug_report_en.yaml | 172 +-------- .../12_userscript_compatibility_en.yaml | 86 +---- .../ISSUE_TEMPLATE/13_feature_request_en.yaml | 70 +--- .../14_technical_proposal_en.yaml | 42 +-- .../ISSUE_TEMPLATE/15_documentation_en.yaml | 77 ---- .github/ISSUE_TEMPLATE/16_translation_en.yaml | 76 +--- .husky/pre-commit | 18 + docs/develop.md | 14 +- package.json | 7 +- scripts/check-issue-templates.mjs | 350 ++++++++++++++++++ scripts/check-issue-templates.test.mjs | 226 +++++++++++ 17 files changed, 716 insertions(+), 922 deletions(-) create mode 100644 scripts/check-issue-templates.mjs create mode 100644 scripts/check-issue-templates.test.mjs diff --git a/.github/ISSUE_TEMPLATE/01_bug_report.yaml b/.github/ISSUE_TEMPLATE/01_bug_report.yaml index f5fadcab1..9f9020f2d 100644 --- a/.github/ISSUE_TEMPLATE/01_bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/01_bug_report.yaml @@ -8,42 +8,17 @@ body: - type: markdown attributes: value: | - 感谢反馈。请尽量填写完整,尤其是 **ScriptCat 版本、浏览器版本、复现步骤、日志、相关脚本链接/导出文件**。 - 如果是某个用户脚本在 ScriptCat 不兼容,但在 Tampermonkey/Violentmonkey 正常,请改用 “Userscript Compatibility” 模板。 + 感谢反馈。请尽量写清复现步骤和版本信息。 + + 如果是某个用户脚本在 ScriptCat 不兼容、但在 Tampermonkey/Violentmonkey 正常,请改用 “Userscript 兼容性问题” 模板。 - type: checkboxes id: precheck attributes: label: 提交前检查 options: - - label: 我已经搜索过现有 issues,确认不是重复问题 - required: true - - label: 我已经尽量使用最新 stable 或 beta 版本测试 + - label: 我已经搜索过现有 issues,并尽量用最新 stable 或 beta 版本确认问题仍然存在 required: true - - label: 这不是安全漏洞;安全漏洞会通过 Security Advisory 私密提交 - required: true - - - type: dropdown - id: bug-area - attributes: - label: 问题类型 - multiple: true - options: - - 扩展安装 / 更新 / 卸载 - - 脚本安装 / 更新 / 导入 / 导出 - - 脚本执行 / 注入 / 匹配规则 - - GM API / CAT API - - 后台脚本 / 定时脚本 / offscreen - - Cloud Sync / 备份 / 恢复 / WebDAV / Google Drive / OneDrive - - Popup / Options 管理页 / UI - - 内置编辑器 / Monaco / ESLint - - 日志 / 调试 / 错误提示 - - 移动端 / 小屏适配 - - 隐身模式 / 多窗口 / 多标签页 - - 性能 / 内存 / 存储 - - 其他 - validations: - required: true - type: textarea id: summary @@ -52,14 +27,17 @@ body: description: 实际发生了什么?你原本期望发生什么? placeholder: | 例:在 Edge Android 上打开 ScriptCat popup 后点击“导入文件”,文件选择器出现但选择后没有任何反应。 + + 期望: + 实际: validations: required: true - type: textarea id: steps attributes: - label: 最小复现步骤 - description: 请写成别人可以照着操作的步骤。不要只写“1”“见图”“如题”。 + label: 复现步骤 + description: 请写成别人可以照着操作的步骤。 placeholder: | 1. 安装 ScriptCat v... 2. 打开 ... @@ -68,16 +46,6 @@ body: validations: required: true - - type: textarea - id: expected-actual - attributes: - label: 期望行为 vs 实际行为 - placeholder: | - 期望: - 实际: - validations: - required: true - - type: input id: scriptcat-version attributes: @@ -86,114 +54,22 @@ body: validations: required: true - - type: dropdown - id: release-channel - attributes: - label: ScriptCat 渠道 - options: - - Chrome Stable - - Chrome Beta - - Edge Stable - - Edge Beta - - Firefox Stable / MV2 - - Firefox Beta / MV2 - - GitHub Release 手动安装 - - 自行构建 / dev - - VSCode 插件 - - 不确定 - validations: - required: true - - - type: dropdown - id: manifest-mode - attributes: - label: Manifest / 扩展模式 - options: - - MV3 - - MV2 / Firefox - - 不确定 - validations: - required: true - - - type: input - id: os - attributes: - label: 操作系统 - placeholder: "例如:Windows 11 24H2 / macOS 15 / Android 16 / HarmonyOS 4.2 / iOS" - validations: - required: true - - type: input id: browser attributes: - label: 浏览器及版本 - placeholder: "例如:Chrome 143.0.7499.41 / Edge 146.0.3802.0 / Firefox 151.0b7 / Brave 1.92.120 / Cromite 142..." - validations: - required: true - - - type: dropdown - id: device-type - attributes: - label: 设备类型 - options: - - 桌面端 - - Android 手机/平板 - - iOS/iPadOS - - 其他 + label: 系统 / 浏览器及版本 + placeholder: "例如:Windows 11 + Chrome 143 / Android 16 + Edge 146 / macOS + Firefox 151" validations: required: true - - type: checkboxes - id: browser-flags - attributes: - label: 浏览器/扩展状态 - options: - - label: 开启了隐身模式 / InPrivate / Private Window - - label: 使用移动端浏览器扩展支持 - - label: 使用第三方 Chromium 浏览器 - - label: 使用第三方 Firefox/Gecko 浏览器 - - label: 已授予 ScriptCat 对目标网站的站点访问权限 - - label: 开启了开发者模式 - - label: 安装了其他可能影响页面/请求/脚本的扩展 - - - type: textarea - id: related-script - attributes: - label: 相关用户脚本 / 配置 - description: 如果问题和某个脚本有关,请提供安装链接、脚本头部 metadata、或 ScriptCat 导出的 zip。敏感信息请先脱敏。 - placeholder: | - 脚本链接: - 目标网站: - 关键 metadata: - ```js - // ==UserScript== - // @name ... - // @match ... - // @grant ... - // @connect ... - // ==/UserScript== - ``` - validations: - required: false - - - type: textarea - id: logs - attributes: - label: 日志 / 错误信息 / 截图 - description: | - 请说明日志来自哪里:页面 DevTools、扩展 Service Worker、offscreen.html、popup、options 页面、定时脚本日志等。 - placeholder: | - 日志来源: - 错误信息: - 截图/视频: - validations: - required: false - - type: textarea - id: workaround + id: details attributes: - label: 是否有临时解决办法 + label: 相关脚本 / 日志 / 截图 + description: 相关脚本的安装链接或 metadata、控制台或 Service Worker 日志、截图。敏感信息请先脱敏。 placeholder: | - 例:刷新页面后恢复;换成 Tampermonkey 正常;关闭隐身模式后正常;重新安装脚本后正常。 + 脚本链接 / 目标网站: + 日志(来源:页面 DevTools / Service Worker / offscreen / popup): + 截图: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/02_userscript_compatibility.yaml b/.github/ISSUE_TEMPLATE/02_userscript_compatibility.yaml index 2d5c15db5..45bce4e08 100644 --- a/.github/ISSUE_TEMPLATE/02_userscript_compatibility.yaml +++ b/.github/ISSUE_TEMPLATE/02_userscript_compatibility.yaml @@ -9,19 +9,15 @@ body: attributes: value: | 这个模板专门用于 **用户脚本兼容性**。 - 请尽量提供脚本链接、目标网站、metadata、对比结果和最小复现脚本。 - 如果只是 ScriptCat 界面或同步功能异常,请使用 Bug 反馈模板。 + + 如果只是 ScriptCat 界面、同步、安装等本体功能异常,请改用 Bug 反馈模板。 - type: checkboxes id: precheck attributes: label: 提交前检查 options: - - label: 我已经搜索过现有 issues - required: true - - label: 我确认该问题与某个用户脚本或 userscript API 兼容性有关 - required: true - - label: 我已经尽量提供脚本链接或最小复现脚本 + - label: 我已经搜索过现有 issues,并确认问题与某个用户脚本或 userscript API 有关 required: true - type: input @@ -36,29 +32,17 @@ body: id: target-url attributes: label: 触发问题的目标网站 - placeholder: "例如: " validations: required: true - type: textarea id: problem attributes: - label: 在 ScriptCat 中的异常表现 + label: 问题表现与对比 + description: 在 ScriptCat 中是什么表现?在其他脚本管理器中又是什么表现?请注明对比用的管理器和版本。 placeholder: | - 例:按钮不出现 / 脚本不执行 / GM_xmlhttpRequest 报错 / 菜单项无效 / 页面无限刷新 - validations: - required: true - - - type: textarea - id: comparison - attributes: - label: 与其他用户脚本管理器的对比 - description: 请写清楚 Tampermonkey / Violentmonkey / FireMonkey / Greasemonkey 的结果。 - placeholder: | - Tampermonkey 版本: - Violentmonkey 版本: - FireMonkey/Greasemonkey 版本: - 在这些管理器中的表现: + 在 ScriptCat 中:按钮不出现 / 脚本不执行 / GM_xmlhttpRequest 报错 / 页面无限刷新 + 在其他管理器中(如 Tampermonkey 5.x):正常 validations: required: true @@ -78,41 +62,6 @@ body: validations: required: true - - type: textarea - id: minimal-reproduction - attributes: - label: 最小复现脚本 - description: 如果原脚本太大,请尽量删减成最小可复现版本。 - render: javascript - placeholder: | - // 尽量提供能单独复现问题的最小脚本 - validations: - required: false - - - type: dropdown - id: api-area - attributes: - label: 可能相关的 API / 机制 - multiple: true - options: - - GM_xmlhttpRequest / GM.xmlHttpRequest - - GM_getValue / GM.setValue / GM storage - - GM_registerMenuCommand - - GM_download - - GM_cookie / Cookie API - - "@match / @include / @exclude" - - "@connect" - - "@require / @resource" - - "@run-at" - - "@inject-into" - - sandbox / unsafeWindow / page context - - iframe / allFrames / noframes - - CSP / Trusted Types - - service worker / offscreen / background - - 不确定 - validations: - required: true - - type: input id: scriptcat-version attributes: @@ -124,19 +73,19 @@ body: - type: input id: browser attributes: - label: 操作系统和浏览器 - placeholder: "例如:Windows 11 Chrome 143 / macOS Firefox 151 / Android WebLibre 0.20" + label: 系统 / 浏览器及版本 + placeholder: "例如:Windows 11 + Chrome 143 / macOS + Firefox 151 / Android + WebLibre 0.20" validations: required: true - type: textarea - id: logs + id: details attributes: - label: 控制台日志 / 网络请求 / 截图 - description: 请说明日志来源:页面 console、content script、service worker、offscreen、网络面板等。 + label: 最小复现脚本 / 日志 / 截图 + description: 如果原脚本太大,请尽量删减成最小可复现版本。日志请注明来源:页面 console、content script、Service Worker、offscreen、网络面板等。 placeholder: | - 日志来源: - 错误: - 截图/视频: + 最小复现脚本: + 日志来源与错误信息: + 截图 / 视频: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/03_feature_request.yaml b/.github/ISSUE_TEMPLATE/03_feature_request.yaml index 738e12d6d..62f7d4893 100644 --- a/.github/ISSUE_TEMPLATE/03_feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/03_feature_request.yaml @@ -9,45 +9,22 @@ body: attributes: value: | 请描述你真正想解决的问题,而不只是描述一个按钮或 API 名称。 - 如果是 GM/CAT API 设计,建议给出 API 草案、兼容对象和安全影响。 + + 如果某个功能失效、或者以前能用现在不行,请改用 Bug 反馈模板。 - type: checkboxes id: precheck attributes: label: 提交前检查 options: - - label: 我已经搜索过现有 issues,确认不是重复功能请求 - required: true - - label: 我已经确认这不是 bug;如果是异常行为,应使用 Bug 模板 + - label: 我已经搜索过现有 issues,确认不是重复的功能请求 required: true - - type: dropdown - id: feature-area - attributes: - label: 功能领域 - multiple: true - options: - - 用户脚本管理 / 列表 / 分组 / 搜索 - - 脚本安装 / 更新 / 订阅 - - 脚本匹配 / 排除 / 权限管理 - - GM API / CAT API - - 后台脚本 / 定时脚本 - - Cloud Sync / 备份 / 恢复 / 迁移 - - Popup / Options / UI - - 内置编辑器 / Monaco / ESLint / VSCode 插件 - - 日志 / 调试 / 开发者体验 - - 移动端适配 - - 国际化 / 翻译 - - 性能 / 存储 / 安全 - - 其他 - validations: - required: true - - type: textarea id: problem attributes: - label: 你遇到的实际问题 - description: 没有这个功能时,用户或脚本作者会遇到什么痛点? + label: 你想解决的问题 + description: 没有这个功能时,你或脚本作者会遇到什么麻烦? placeholder: | 例:我有很多定时签到脚本,目前必须逐个打开日志才能确认运行结果。 validations: @@ -63,35 +40,11 @@ body: required: true - type: textarea - id: api-design - attributes: - label: API / UI 草案(如适用) - description: 如果涉及 GM/CAT API、配置结构、同步格式、UI 流程,请给出草案。 - placeholder: | - ```ts - // API 草案 - ``` - validations: - required: false - - - type: textarea - id: alternatives - attributes: - label: 备选方案 - description: 是否已有 workaround?其他扩展是如何处理的? - placeholder: | - Tampermonkey / Violentmonkey / FireMonkey / Soulsign / Automa 等参考: - validations: - required: false - - - type: textarea - id: compatibility + id: additional-info attributes: - label: 兼容性和安全影响 - description: 是否影响 MV2/MV3、Firefox、移动端、隐身模式、站点权限、用户脚本权限? + label: 补充信息 + description: 可以补充 API 草案、其他脚本管理器的做法、截图或参考链接。 placeholder: | - 可能影响: - 需要新增权限: - 是否可能被恶意脚本滥用: + API 草案 / 参考实现 / 截图: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/04_technical_proposal.yaml b/.github/ISSUE_TEMPLATE/04_technical_proposal.yaml index 0e128e53a..d0ab30bb8 100644 --- a/.github/ISSUE_TEMPLATE/04_technical_proposal.yaml +++ b/.github/ISSUE_TEMPLATE/04_technical_proposal.yaml @@ -9,29 +9,8 @@ body: attributes: value: | 这个模板用于技术设计、重构、性能优化、存储结构、运行时架构等提案。 - 普通用户 bug 或功能请求请使用其他模板。 - - type: dropdown - id: proposal-area - attributes: - label: 涉及模块 - multiple: true - options: - - Service Worker - - offscreen - - sandbox / inject / content script - - script matching / registry - - GM/CAT API runtime - - storage / Dexie / IndexedDB / OPFS - - sync / backup / import/export - - resource loader / @require / @resource - - editor / Monaco / ESLint - - popup / options UI - - build / packaging / manifest - - tests / e2e / CI - - docs / i18n - validations: - required: true + 普通用户 bug 或功能请求请使用其他模板。 - type: textarea id: background @@ -50,25 +29,13 @@ body: required: true - type: textarea - id: compatibility + id: impact attributes: - label: 兼容性 / 迁移影响 + label: 兼容性 / 迁移 / 测试 placeholder: | 对旧版本数据的影响: - 对 MV2/MV3 的影响: - 对 Firefox/Chrome/Edge/移动端的影响: + 对 MV2/MV3、Firefox/Chrome/Edge/移动端的影响: 是否需要 migration: - validations: - required: true - - - type: textarea - id: test-plan - attributes: - label: 测试计划 - placeholder: | - 单元测试: - e2e: - 手动测试浏览器: - 回归风险: + 测试与回归风险: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/05_documentation.yaml b/.github/ISSUE_TEMPLATE/05_documentation.yaml index b5b712395..60a660e78 100644 --- a/.github/ISSUE_TEMPLATE/05_documentation.yaml +++ b/.github/ISSUE_TEMPLATE/05_documentation.yaml @@ -9,9 +9,8 @@ body: attributes: value: | 感谢帮助改进 ScriptCat 文档。 - 请尽量提供具体页面、章节、当前问题和建议修改内容。 - 如果是界面翻译、语言包、本地化措辞问题,请使用 “Translation / i18n” 模板。 + 如果是界面翻译、语言包、本地化措辞问题,请改用 “翻译 / 本地化” 模板。 - type: checkboxes id: precheck @@ -20,73 +19,12 @@ body: options: - label: 我已经搜索过现有 issues,确认不是重复问题 required: true - - label: 我确认这是文档问题,而不是 ScriptCat 本体 bug - required: true - type: input id: doc-url attributes: label: 相关文档页面 description: 请提供 docs.scriptcat.org、learn.scriptcat.org、README、CONTRIBUTING 或其他相关页面。 - placeholder: "例如:" - validations: - required: true - - - type: dropdown - id: doc-language - attributes: - label: 文档语言 - options: - - 简体中文 / zh-CN - - 繁體中文 / zh-TW - - English / en - - 日本語 / ja - - Русский / ru - - Deutsch / de - - Tiếng Việt / vi - - 其他 / Other - - 不确定 - validations: - required: true - - - type: dropdown - id: doc-area - attributes: - label: 文档领域 - multiple: true - options: - - 安装 / 更新 / 浏览器支持 - - 用户脚本安装 / 更新 / 订阅 - - 用户脚本开发指南 - - GM API / CAT API - - 后台脚本 / 定时脚本 - - Cloud Sync / 备份 / 恢复 / 迁移 - - 权限 / 安全 / 隐身模式 - - Popup / Options / UI - - 内置编辑器 / VSCode 插件 - - 调试 / 日志 / 开发者工具 - - 构建 / 贡献指南 - - README / Release Notes - - 其他 - validations: - required: true - - - type: dropdown - id: doc-issue-type - attributes: - label: 文档问题类型 - multiple: true - options: - - 内容错误 - - 内容过期 - - 缺少说明 - - 表达不清楚 - - 示例代码无法运行 - - 链接失效 - - 图片 / 截图过期 - - 页面结构或导航问题 - - 与实际 UI 或行为不一致 - - 其他 validations: required: true @@ -107,27 +45,13 @@ body: description: 请说明你希望如何修改文档。可以直接贴出建议文案。 placeholder: | 建议改为: - ... validations: required: true - - type: textarea - id: affected-version - attributes: - label: 相关版本 / 环境(如适用) - description: 如果文档问题和特定 ScriptCat 版本、浏览器或平台有关,请填写。 - placeholder: | - ScriptCat 版本: - 浏览器 / 平台: - validations: - required: false - - type: textarea id: additional-info attributes: label: 补充信息 description: 可以补充截图、相关 issue、PR、代码位置或参考资料。 - placeholder: | - 截图 / 参考资料 / 相关链接: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/06_translation.yaml b/.github/ISSUE_TEMPLATE/06_translation.yaml index d879f0388..c13ceb51d 100644 --- a/.github/ISSUE_TEMPLATE/06_translation.yaml +++ b/.github/ISSUE_TEMPLATE/06_translation.yaml @@ -9,9 +9,8 @@ body: attributes: value: | 感谢帮助改进 ScriptCat 的本地化。 - 请尽量提供原文、当前译文、建议译文和出现位置。 - 如果是文档内容本身错误、过期或缺失,请使用 “Documentation” 模板。 + 如果是文档内容本身错误、过期或缺失,请改用 “文档问题 / 改进” 模板。 - type: checkboxes id: precheck @@ -20,27 +19,6 @@ body: options: - label: 我已经搜索过现有 issues,确认不是重复问题 required: true - - label: 我已经确认这是翻译 / 本地化问题,而不是功能 bug - required: true - - - type: dropdown - id: i18n-area - attributes: - label: 问题范围 - multiple: true - options: - - 扩展界面 / Popup - - Options 管理页 - - 安装 / 更新页面 - - 内置编辑器 / Monaco / ESLint 提示 - - 日志 / 错误提示 / 通知 - - 文档 - - README / CONTRIBUTING / SECURITY - - VSCode 插件 - - ScriptCat 网站 / 脚本市场 - - 其他 - validations: - required: true - type: dropdown id: language @@ -59,28 +37,11 @@ body: validations: required: true - - type: dropdown - id: issue-type - attributes: - label: 翻译问题类型 - multiple: true - options: - - 翻译错误 - - 术语不一致 - - 机器翻译不自然 - - 缺少翻译 / fallback 到其他语言 - - 文案太长导致 UI 溢出 - - 标点 / 大小写 / 格式问题 - - 新增语言支持 - - 其他 - validations: - required: true - - type: input id: location attributes: label: 出现位置 - description: 请提供页面、菜单路径、截图位置、文件路径或文档页面。 + description: 请提供页面、菜单路径、文件路径或文档页面。 placeholder: "例如:Options > Settings > Script Sync / src/locales//translation.json" validations: required: true @@ -88,10 +49,8 @@ body: - type: textarea id: source-text attributes: - label: 原文 / 当前文案 - description: 请贴出看到的原文或当前翻译。 - placeholder: | - 当前文案: + label: 当前文案 + description: 请贴出你看到的原文或当前翻译。 validations: required: true @@ -100,35 +59,15 @@ body: attributes: label: 建议译文 description: 请提供你建议使用的翻译;如果是新增语言请求,请说明目标语言和是否愿意贡献。 - placeholder: | - 建议改为: validations: required: true - type: textarea - id: reason + id: additional-info attributes: - label: 修改理由 - description: 为什么当前翻译不合适?是否有术语、上下文或地区用语考虑? + label: 修改理由 / 截图 + description: 为什么当前翻译不合适?是否涉及术语、上下文或地区用语?如果涉及 UI 溢出或截断,请上传截图。 placeholder: | 例:这里的 “sync” 在产品中统一译为 “同步”,不应译为 “备份”。 validations: required: false - - - type: input - id: scriptcat-version - attributes: - label: ScriptCat 版本(如适用) - placeholder: "例如:v1.4.0 / v1.4.0-beta / Firefox MV2 v0.16.15" - validations: - required: false - - - type: textarea - id: screenshots - attributes: - label: 截图 / 补充信息 - description: 如果涉及 UI 溢出、截断、换行或上下文,请上传截图。 - placeholder: | - 截图 / 相关链接: - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/11_bug_report_en.yaml b/.github/ISSUE_TEMPLATE/11_bug_report_en.yaml index 909e9e504..de9a9274a 100644 --- a/.github/ISSUE_TEMPLATE/11_bug_report_en.yaml +++ b/.github/ISSUE_TEMPLATE/11_bug_report_en.yaml @@ -8,66 +8,36 @@ body: - type: markdown attributes: value: | - Thanks for reporting a bug. + Thanks for reporting a bug. Please describe the reproduction steps and version information as clearly as you can. - Please provide as much detail as possible, especially: - - ScriptCat version - - Browser and OS version - - Reproduction steps - - Logs or screenshots - - Related userscript link or exported script package, if applicable - - If the issue is that a specific userscript works in Tampermonkey / Violentmonkey but not in ScriptCat, please use the **Userscript Compatibility** template instead. + If a specific userscript works in Tampermonkey / Violentmonkey but not in ScriptCat, please use the **Userscript Compatibility** template instead. - type: checkboxes id: precheck attributes: label: Pre-check options: - - label: I have searched existing issues and confirmed this is not a duplicate. - required: true - - label: I have tested with the latest stable or beta version where possible. + - label: I have searched existing issues, and confirmed the problem still happens on the latest stable or beta version where possible. required: true - - label: This is not a security vulnerability. Security issues should be reported privately through Security Advisory. - required: true - - - type: dropdown - id: bug-area - attributes: - label: Issue Area - multiple: true - options: - - Extension installation / update / uninstall - - Userscript installation / update / import / export - - Script execution / injection / matching rules - - GM API / CAT API - - Background scripts / scheduled scripts / offscreen - - Cloud Sync / backup / restore / WebDAV / Google Drive / OneDrive - - Popup / Options page / UI - - Built-in editor / Monaco / ESLint - - Logs / debugging / error messages - - Mobile / small-screen support - - Incognito mode / multiple windows / multiple tabs - - Performance / memory / storage - - Other - validations: - required: true - type: textarea id: summary attributes: - label: Problem Description - description: What happened? What did you expect to happen? + label: Description + description: What actually happened, and what did you expect to happen? placeholder: | - Example: On Edge for Android, after opening the ScriptCat popup and selecting "Import File", the file picker opens, but nothing happens after choosing a file. + Example: On Edge for Android, opening the ScriptCat popup and tapping "Import file" shows the file picker, but nothing happens after selecting a file. + + Expected: + Actual: validations: required: true - type: textarea id: steps attributes: - label: Minimal Reproduction Steps - description: Please provide steps that others can follow. Do not write only "1", "see screenshot", or "as title". + label: Steps to Reproduce + description: Please write steps someone else can follow. placeholder: | 1. Install ScriptCat v... 2. Open ... @@ -76,17 +46,6 @@ body: validations: required: true - - type: textarea - id: expected-actual - attributes: - label: Expected Behavior vs Actual Behavior - placeholder: | - Expected: - - Actual: - validations: - required: true - - type: input id: scriptcat-version attributes: @@ -95,115 +54,22 @@ body: validations: required: true - - type: dropdown - id: release-channel - attributes: - label: ScriptCat Channel - options: - - Chrome Stable - - Chrome Beta - - Edge Stable - - Edge Beta - - Firefox Stable / MV2 - - Firefox Beta / MV2 - - GitHub Release manual installation - - Self-built / dev - - VSCode extension - - Not sure - validations: - required: true - - - type: dropdown - id: manifest-mode - attributes: - label: Manifest / Extension Mode - options: - - MV3 - - MV2 / Firefox - - Not sure - validations: - required: true - - - type: input - id: os - attributes: - label: Operating System - placeholder: "Example: Windows 11 24H2 / macOS 15 / Android 16 / HarmonyOS 4.2 / iOS" - validations: - required: true - - type: input id: browser attributes: - label: Browser and Version - placeholder: "Example: Chrome 143.0.7499.41 / Edge 146.0.3802.0 / Firefox 151.0b7 / Brave 1.92.120 / Cromite 142..." + label: OS / Browser and Version + placeholder: "Example: Windows 11 + Chrome 143 / Android 16 + Edge 146 / macOS + Firefox 151" validations: required: true - - type: dropdown - id: device-type - attributes: - label: Device Type - options: - - Desktop - - Android phone / tablet - - iOS / iPadOS - - Other - validations: - required: true - - - type: checkboxes - id: browser-flags - attributes: - label: Browser / Extension State - options: - - label: Incognito / InPrivate / Private Window is enabled - - label: Mobile browser extension support is being used - - label: Third-party Chromium-based browser is being used - - label: Third-party Firefox / Gecko-based browser is being used - - label: ScriptCat has been granted site access permission for the target website - - label: Developer mode is enabled - - label: Other extensions may affect pages, requests, or userscripts - - - type: textarea - id: related-script - attributes: - label: Related Userscript / Configuration - description: If the issue is related to a specific userscript, please provide its install URL, metadata block, or an exported ScriptCat zip package. Please remove sensitive information first. - placeholder: | - Userscript URL: - Target website: - Relevant metadata: - ```js - // ==UserScript== - // @name ... - // @match ... - // @grant ... - // @connect ... - // ==/UserScript== - ``` - validations: - required: false - - - type: textarea - id: logs - attributes: - label: Logs / Error Messages / Screenshots - description: | - Please also mention where the logs come from: - page DevTools, extension Service Worker, offscreen.html, popup, options page, scheduled script logs, etc. - placeholder: | - Log source: - Error message: - Screenshots / video: - validations: - required: false - - type: textarea - id: workaround + id: details attributes: - label: Temporary Workaround + label: Related Script / Logs / Screenshots + description: Installation URL or metadata of the related script, console or Service Worker logs, and screenshots. Please redact sensitive information first. placeholder: | - Example: Refreshing the page fixes it; it works in Tampermonkey; it works after disabling incognito mode; reinstalling the userscript fixes it. + Script URL / target website: + Logs (source: page DevTools / Service Worker / offscreen / popup): + Screenshots: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/12_userscript_compatibility_en.yaml b/.github/ISSUE_TEMPLATE/12_userscript_compatibility_en.yaml index 37ed9be35..fecfc2373 100644 --- a/.github/ISSUE_TEMPLATE/12_userscript_compatibility_en.yaml +++ b/.github/ISSUE_TEMPLATE/12_userscript_compatibility_en.yaml @@ -10,13 +10,6 @@ body: value: | This template is specifically for **userscript compatibility issues**. - Please provide: - - Userscript URL - - Target website - - Metadata block - - Comparison with Tampermonkey / Violentmonkey / other managers - - Minimal reproduction script if possible - If the issue is about ScriptCat UI, sync, backup, installation, or general extension behavior, please use the Bug Report template instead. - type: checkboxes @@ -24,11 +17,7 @@ body: attributes: label: Pre-check options: - - label: I have searched existing issues. - required: true - - label: I confirm this issue is related to a userscript or userscript API compatibility. - required: true - - label: I have provided a userscript link or a minimal reproduction script where possible. + - label: I have searched existing issues, and confirmed this is related to a userscript or userscript API. required: true - type: input @@ -42,30 +31,18 @@ body: - type: input id: target-url attributes: - label: Target Website - placeholder: "Example: " + label: Target Website Where the Issue Occurs validations: required: true - type: textarea id: problem attributes: - label: What Fails in ScriptCat? + label: Behavior and Comparison + description: What happens in ScriptCat, and what happens in other userscript managers? Please name the manager and version you compared against. placeholder: | - Example: Button does not appear / script does not run / GM_xmlhttpRequest fails / menu command does not work / page refreshes infinitely. - validations: - required: true - - - type: textarea - id: comparison - attributes: - label: Comparison with Other Userscript Managers - description: Please describe the behavior in Tampermonkey, Violentmonkey, FireMonkey, Greasemonkey, etc. - placeholder: | - Tampermonkey version: - Violentmonkey version: - FireMonkey / Greasemonkey version: - Behavior in those managers: + In ScriptCat: button never appears / script does not run / GM_xmlhttpRequest throws / page reloads endlessly + In another manager (e.g. Tampermonkey 5.x): works fine validations: required: true @@ -73,7 +50,7 @@ body: id: metadata attributes: label: Userscript Metadata - description: Please include relevant fields such as `@match`, `@grant`, `@connect`, `@require`, `@resource`, `@run-at`, `@inject-into`, etc. + description: Please include at least the relevant `@match`, `@grant`, `@connect`, `@require`, `@resource`, `@run-at`, and `@inject-into` entries. render: javascript placeholder: | // ==UserScript== @@ -85,41 +62,6 @@ body: validations: required: true - - type: textarea - id: minimal-reproduction - attributes: - label: Minimal Reproduction Script - description: If the original userscript is large, please try to reduce it to the smallest script that can reproduce the problem. - render: javascript - placeholder: | - // Please provide the smallest standalone script that reproduces the issue. - validations: - required: false - - - type: dropdown - id: api-area - attributes: - label: Related API / Mechanism - multiple: true - options: - - GM_xmlhttpRequest / GM.xmlHttpRequest - - GM_getValue / GM.setValue / GM storage - - GM_registerMenuCommand - - GM_download - - GM_cookie / Cookie API - - "@match / @include / @exclude" - - "@connect" - - "@require / @resource" - - "@run-at" - - "@inject-into" - - sandbox / unsafeWindow / page context - - iframe / allFrames / noframes - - CSP / Trusted Types - - service worker / offscreen / background - - Not sure - validations: - required: true - - type: input id: scriptcat-version attributes: @@ -131,19 +73,19 @@ body: - type: input id: browser attributes: - label: Operating System and Browser - placeholder: "Example: Windows 11 Chrome 143 / macOS Firefox 151 / Android WebLibre 0.20" + label: OS / Browser and Version + placeholder: "Example: Windows 11 + Chrome 143 / macOS + Firefox 151 / Android + WebLibre 0.20" validations: required: true - type: textarea - id: logs + id: details attributes: - label: Console Logs / Network Requests / Screenshots - description: Please mention the log source, such as page console, content script, service worker, offscreen, network panel, etc. + label: Minimal Reproduction Script / Logs / Screenshots + description: If the original script is large, please reduce it to a minimal reproduction. For logs, note where they came from — page console, content script, Service Worker, offscreen, network panel, etc. placeholder: | - Log source: - Error: + Minimal reproduction script: + Log source and error message: Screenshots / video: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/13_feature_request_en.yaml b/.github/ISSUE_TEMPLATE/13_feature_request_en.yaml index b3fe5bf5f..7393ad3b2 100644 --- a/.github/ISSUE_TEMPLATE/13_feature_request_en.yaml +++ b/.github/ISSUE_TEMPLATE/13_feature_request_en.yaml @@ -10,11 +10,7 @@ body: value: | Please describe the real problem you want to solve, not just a button name or an API name. - If this is a GM/CAT API design request, please provide: - - Proposed API shape - - Compatibility target - - Security impact - - Possible abuse cases + If something is broken, or used to work and no longer does, please use the Bug Report template instead. - type: checkboxes id: precheck @@ -23,38 +19,14 @@ body: options: - label: I have searched existing issues and confirmed this is not a duplicate feature request. required: true - - label: I confirm this is not a bug. If this is unexpected behavior, I should use the Bug Report template. - required: true - - - type: dropdown - id: feature-area - attributes: - label: Feature Area - multiple: true - options: - - Userscript management / list / group / search - - Script installation / update / subscription - - Script matching / exclusion / permission management - - GM API / CAT API - - Background scripts / scheduled scripts - - Cloud Sync / backup / restore / migration - - Popup / Options / UI - - Built-in editor / Monaco / ESLint / VSCode extension - - Logs / debugging / developer experience - - Mobile support - - Internationalization / translation - - Performance / storage / security - - Other - validations: - required: true - type: textarea id: problem attributes: - label: Problem to Solve - description: What pain point do users or userscript authors experience without this feature? + label: The Problem You Want to Solve + description: What is painful for you or for script authors without this feature? placeholder: | - Example: I have many scheduled check-in scripts. Currently I must open logs one by one to confirm whether they ran successfully. + Example: I have many scheduled check-in scripts, and I currently have to open each script's log to find out whether it ran successfully. validations: required: true @@ -63,40 +35,16 @@ body: attributes: label: Proposed Solution placeholder: | - Example: Add "Last Run Time" and "Last Result" columns to the script list. If a scheduled script fails, show the failure count on the extension icon. + Example: Add "last run time" and "last run result" columns to the script list, and show a failure count badge on the extension icon. validations: required: true - type: textarea - id: api-design - attributes: - label: API / UI Draft, if Applicable - description: If this involves GM/CAT APIs, configuration structure, sync format, or UI flow, please provide a draft. - placeholder: | - ```ts - // Proposed API - ``` - validations: - required: false - - - type: textarea - id: alternatives - attributes: - label: Alternatives - description: Is there any workaround? How do other extensions handle this? - placeholder: | - References from Tampermonkey / Violentmonkey / FireMonkey / Soulsign / Automa / others: - validations: - required: false - - - type: textarea - id: compatibility + id: additional-info attributes: - label: Compatibility and Security Impact - description: Does this affect MV2/MV3, Firefox, mobile browsers, incognito mode, site permissions, or userscript permissions? + label: Additional Information + description: Optionally add a proposed API shape, how other userscript managers handle this, screenshots, or reference links. placeholder: | - Possible impact: - New permissions required: - Possible abuse cases: + API draft / reference implementation / screenshots: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/14_technical_proposal_en.yaml b/.github/ISSUE_TEMPLATE/14_technical_proposal_en.yaml index 0c97b3954..e3bbced37 100644 --- a/.github/ISSUE_TEMPLATE/14_technical_proposal_en.yaml +++ b/.github/ISSUE_TEMPLATE/14_technical_proposal_en.yaml @@ -12,28 +12,6 @@ body: For regular user bugs or feature requests, please use the other templates. - - type: dropdown - id: proposal-area - attributes: - label: Affected Module - multiple: true - options: - - Service Worker - - offscreen - - sandbox / inject / content script - - script matching / registry - - GM/CAT API runtime - - storage / Dexie / IndexedDB / OPFS - - sync / backup / import / export - - resource loader / @require / @resource - - editor / Monaco / ESLint - - popup / options UI - - build / packaging / manifest - - tests / e2e / CI - - docs / i18n - validations: - required: true - - type: textarea id: background attributes: @@ -51,25 +29,13 @@ body: required: true - type: textarea - id: compatibility + id: impact attributes: - label: Compatibility / Migration Impact + label: Compatibility / Migration / Testing placeholder: | Impact on existing data: - Impact on MV2/MV3: - Impact on Firefox / Chrome / Edge / mobile: + Impact on MV2/MV3 and Firefox / Chrome / Edge / mobile: Migration required: - validations: - required: true - - - type: textarea - id: test-plan - attributes: - label: Test Plan - placeholder: | - Unit tests: - E2E tests: - Manual browsers to test: - Regression risks: + Test plan and regression risks: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/15_documentation_en.yaml b/.github/ISSUE_TEMPLATE/15_documentation_en.yaml index 0d9124465..f719aa0a6 100644 --- a/.github/ISSUE_TEMPLATE/15_documentation_en.yaml +++ b/.github/ISSUE_TEMPLATE/15_documentation_en.yaml @@ -10,8 +10,6 @@ body: value: | Thanks for helping improve ScriptCat documentation. - Please provide the exact page, section, current problem, and suggested change where possible. - If this is about UI translation, language packs, or localization wording, please use the **Translation / i18n** template instead. - type: checkboxes @@ -21,73 +19,12 @@ body: options: - label: I have searched existing issues and confirmed this is not a duplicate. required: true - - label: I confirm this is a documentation issue, not a ScriptCat product bug. - required: true - type: input id: doc-url attributes: label: Related Documentation Page description: Please provide the related docs.scriptcat.org, learn.scriptcat.org, README, CONTRIBUTING, or other page. - placeholder: "Example: " - validations: - required: true - - - type: dropdown - id: doc-language - attributes: - label: Documentation Language - options: - - Simplified Chinese / zh-CN - - Traditional Chinese / zh-TW - - English / en - - Japanese / ja - - Russian / ru - - German / de - - Vietnamese / vi - - Other - - Not sure - validations: - required: true - - - type: dropdown - id: doc-area - attributes: - label: Documentation Area - multiple: true - options: - - Installation / update / browser support - - Userscript installation / update / subscription - - Userscript development guide - - GM API / CAT API - - Background scripts / scheduled scripts - - Cloud Sync / backup / restore / migration - - Permissions / security / incognito mode - - Popup / Options / UI - - Built-in editor / VSCode extension - - Debugging / logs / developer tools - - Build / contributing guide - - README / Release Notes - - Other - validations: - required: true - - - type: dropdown - id: doc-issue-type - attributes: - label: Documentation Issue Type - multiple: true - options: - - Incorrect content - - Outdated content - - Missing explanation - - Unclear wording - - Code example does not work - - Broken link - - Outdated image / screenshot - - Page structure or navigation issue - - Inconsistent with actual UI or behavior - - Other validations: required: true @@ -108,27 +45,13 @@ body: description: Please describe how the documentation should be changed. You may paste suggested wording directly. placeholder: | Suggested wording: - ... validations: required: true - - type: textarea - id: affected-version - attributes: - label: Related Version / Environment, if Applicable - description: If the documentation issue is related to a specific ScriptCat version, browser, or platform, please provide it. - placeholder: | - ScriptCat version: - Browser / platform: - validations: - required: false - - type: textarea id: additional-info attributes: label: Additional Information description: Screenshots, related issues, PRs, code locations, or references can be added here. - placeholder: | - Screenshots / references / related links: validations: required: false diff --git a/.github/ISSUE_TEMPLATE/16_translation_en.yaml b/.github/ISSUE_TEMPLATE/16_translation_en.yaml index 510b93324..385ad18ba 100644 --- a/.github/ISSUE_TEMPLATE/16_translation_en.yaml +++ b/.github/ISSUE_TEMPLATE/16_translation_en.yaml @@ -10,8 +10,6 @@ body: value: | Thanks for helping improve ScriptCat localization. - Please provide the source text, current translation, suggested translation, and where it appears. - If the documentation content itself is incorrect, outdated, or missing, please use the **Documentation** template instead. - type: checkboxes @@ -21,27 +19,6 @@ body: options: - label: I have searched existing issues and confirmed this is not a duplicate. required: true - - label: I confirm this is a translation / localization issue, not a feature bug. - required: true - - - type: dropdown - id: i18n-area - attributes: - label: Affected Area - multiple: true - options: - - Extension UI / Popup - - Options page - - Installation / update page - - Built-in editor / Monaco / ESLint messages - - Logs / error messages / notifications - - Documentation - - README / CONTRIBUTING / SECURITY - - VSCode extension - - ScriptCat website / script marketplace - - Other - validations: - required: true - type: dropdown id: language @@ -60,28 +37,11 @@ body: validations: required: true - - type: dropdown - id: issue-type - attributes: - label: Translation Issue Type - multiple: true - options: - - Incorrect translation - - Inconsistent terminology - - Unnatural machine translation - - Missing translation / fallback to another language - - Text too long and breaks UI - - Punctuation / capitalization / formatting issue - - New language support - - Other - validations: - required: true - - type: input id: location attributes: label: Location - description: Please provide the page, menu path, screenshot location, file path, or documentation page. + description: Please provide the page, menu path, file path, or documentation page. placeholder: "Example: Options > Settings > Script Sync / src/locales//translation.json" validations: required: true @@ -89,10 +49,8 @@ body: - type: textarea id: source-text attributes: - label: Source Text / Current Text - description: Please paste the current source text or translation you see. - placeholder: | - Current text: + label: Current Text + description: Please paste the source text or current translation you see. validations: required: true @@ -101,35 +59,15 @@ body: attributes: label: Suggested Translation description: Please provide the wording you suggest. For new language requests, mention the target language and whether you are willing to contribute. - placeholder: | - Suggested wording: validations: required: true - type: textarea - id: reason - attributes: - label: Reason - description: Why is the current translation unsuitable? Are there terminology, context, or regional-language considerations? - placeholder: | - Example: In this product, "sync" is consistently translated as "...", so this string should not use "...". - validations: - required: false - - - type: input - id: scriptcat-version - attributes: - label: ScriptCat Version, if Applicable - placeholder: "Example: v1.4.0 / v1.4.0-beta / Firefox MV2 v0.16.15" - validations: - required: false - - - type: textarea - id: screenshots + id: additional-info attributes: - label: Screenshots / Additional Information - description: If this involves UI overflow, truncation, line wrapping, or context, please upload screenshots. + label: Reason / Screenshots + description: Why is the current translation unsuitable? Does it involve terminology, context, or regional wording? If it involves UI overflow or truncation, please attach screenshots. placeholder: | - Screenshots / related links: + Example: In this product, "sync" is consistently translated one way, so this string should not use a different term. validations: required: false diff --git a/.husky/pre-commit b/.husky/pre-commit index ba726b54f..6a1d34393 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -49,6 +49,24 @@ else rm -f "$i18n_files" fi +# issue 模板或构造 issues/new 预填链接的源码有改动时,跑模板机械检查(YAML 合法性、 +# issue-form schema、中英文结构对齐、预填参数 id 是否仍然存在)。同样检查暂存快照而非工作区。 +issue_template_files=$(mktemp) +git diff --cached --name-only -z --diff-filter=ACMRD -- \ + ".github/ISSUE_TEMPLATE/*" "src/*.ts" "src/*.tsx" > "$issue_template_files" + +if [ -s "$issue_template_files" ]; then + rm -f "$issue_template_files" + snapshot_dir=$(mktemp -d) + node scripts/git-staged-snapshot.mjs "$(pwd)" "$snapshot_dir" \ + && node scripts/check-issue-templates.mjs "--root=$snapshot_dir" + status=$? + rm -rf "$snapshot_dir" + [ $status -eq 0 ] || exit 1 +else + rm -f "$issue_template_files" +fi + # 在 main 或 release/* 分支上提交时额外跑测试 branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ] || echo "$branch" | grep -q "^release/"; then diff --git a/docs/develop.md b/docs/develop.md index f4eb30c2d..691760815 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -22,13 +22,21 @@ pnpm run typecheck # tsc --noEmit pnpm run test:e2e:install # install Playwright Chromium (first run only) pnpm run test:e2e # Playwright (e2e/*.spec.ts, 1 worker) -pnpm run lint # tsc --noEmit + eslint +pnpm run lint # prettier --check + tsc --noEmit + check:i18n + check:issue-templates, then eslint pnpm run lint-fix # prettier --write + tsc --noEmit + eslint --fix + +pnpm run check:i18n # translation key parity (see docs/translation.md) +pnpm run check:issue-templates # .github/ISSUE_TEMPLATE schema, zh/en parity, issues/new prefill ids ``` No standalone `format` script — formatting is part of `lint-fix` and runs through `prettier --write`. Husky -pre-commit runs `prettier --check` and `pnpm run typecheck` plus ESLint for staged JS/TS files, and also runs -`pnpm run test:ci` when committing on `main` or `release/*`. +pre-commit runs `prettier --check` and `pnpm run typecheck` plus ESLint for staged JS/TS files, runs +`check:i18n` when locale files are staged and `check:issue-templates` when issue templates or `src/` TypeScript +are staged, and also runs `pnpm run test:ci` when committing on `main` or `release/*`. + +`check:issue-templates` guards a contract that is invisible in review: GitHub prefills an issue form from +`issues/new?...` query params keyed by **field id**, so renaming or deleting an id silently breaks every link +using it — including links already shipped in installed builds, which keep sending the old param name. After `pnpm run dev`, load `dist/ext` as an unpacked extension. The browser hot-reloads page changes, but edits to `manifest.json`, `service_worker`, `offscreen`, or `sandbox` require reloading the extension. diff --git a/package.json b/package.json index 16f331519..371daac99 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build": "cross-env NODE_ENV=production rspack build", "pack": "node ./scripts/pack.js", "typecheck": "tsc --noEmit", - "lint": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" && eslint . --cache --cache-location .eslintcache", - "lint:ci": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" && eslint . --cache --cache-location .eslintcache", + "lint": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" \"pnpm run check:issue-templates\" && eslint . --cache --cache-location .eslintcache", + "lint:ci": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" \"pnpm run check:issue-templates\" && eslint . --cache --cache-location .eslintcache", "lint-fix": "concurrently -g \"prettier --write --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint --fix . --cache --cache-location .eslintcache", "test": "vitest --no-coverage --reporter=verbose", "test:ci": "vitest run --no-coverage --reporter=default --reporter.summary=false", @@ -26,7 +26,8 @@ "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", - "check:i18n": "node ./scripts/check-i18n.mjs" + "check:i18n": "node ./scripts/check-i18n.mjs", + "check:issue-templates": "node ./scripts/check-issue-templates.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/check-issue-templates.mjs b/scripts/check-issue-templates.mjs new file mode 100644 index 000000000..94833ad7b --- /dev/null +++ b/scripts/check-issue-templates.mjs @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +// Mechanical check for .github/ISSUE_TEMPLATE/*.yaml. +// +// Fail-closed by design: anything the checker cannot statically resolve is reported as an error +// asking a human to extend the check, never passed through. A silently-broken issue form is +// invisible in review — GitHub simply refuses to render it, or drops a prefilled value, and +// nobody notices until a user hits it. +// +// Checks: +// 1. Every template parses as YAML and satisfies GitHub's issue-form schema: known top-level +// keys, `name`/`description`/`body` present, known element types, unique non-empty ids, +// `markdown` blocks carry neither id nor validations, dropdown/checkboxes have options, +// and checkboxes use per-option `required` rather than `validations.required`. +// 2. zh/en parity — templates pair by numeric prefix (0N ↔ 1N). The pair must expose the same +// element sequence (type, id, required, render, multiple) and the same title/type/labels, +// so a field added to one language cannot silently go missing in the other. +// 3. The `issues/new?...` prefill contract. GitHub prefills an issue form from query params +// keyed by *field id*, so renaming or dropping an id silently breaks every link that used +// it — including links baked into already-installed extension builds, which keep sending +// the old param forever. Every such URL built in src/ is parsed from the TypeScript AST +// (not string-matched) and each of its params must resolve to a real id in every template +// the URL can select. +// +// Run with `--root=` to check a tree other than this file's repo checkout. + +import process from "node:process"; +import { readdirSync, readFileSync, existsSync, statSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; +import ts from "typescript"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const TEMPLATE_DIR = ".github/ISSUE_TEMPLATE"; +const TOP_LEVEL_KEYS = new Set(["name", "description", "title", "labels", "assignees", "body", "type", "projects"]); +const ELEMENT_TYPES = new Set(["markdown", "textarea", "input", "dropdown", "checkboxes"]); + +function walkFiles(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + walkFiles(full, out); + } else if (/\.(ts|tsx)$/.test(entry.name)) { + out.push(full); + } + } + return out; +} + +// Collapse a `a + b + c` chain of string/template literals into one static skeleton, replacing +// each `${...}` with "\0" so the result stays parseable as a URL shape while marking the spots +// whose value is only known at runtime. +function staticText(node) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isTemplateExpression(node)) { + return node.head.text + node.templateSpans.map((span) => "\0" + span.literal.text).join(""); + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = staticText(node.left); + const right = staticText(node.right); + return left === null || right === null ? null : left + right; + } + if (ts.isParenthesizedExpression(node)) return staticText(node.expression); + return null; +} + +// ts.forEachChild aborts as soon as its callback returns a truthy value, so the visitor must +// return undefined — returning the accumulator here silently stops after the first child. +function collectStringLiterals(node, out = []) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) out.push(node.text); + node.forEachChild((child) => { + collectStringLiterals(child, out); + }); + return out; +} + +// The whole expression a URL literal belongs to, so `"...?" + \`template=${a ? "x" : "y"}\`` is +// reconstructed as one unit rather than three unrelated fragments. +function outermostConcat(node) { + let current = node; + while ( + current.parent && + ((ts.isBinaryExpression(current.parent) && current.parent.operatorToken.kind === ts.SyntaxKind.PlusToken) || + ts.isParenthesizedExpression(current.parent)) + ) { + current = current.parent; + } + return current; +} + +function readTemplates(root, problems) { + const dir = path.join(root, TEMPLATE_DIR); + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + problems.push(`${TEMPLATE_DIR}/ not found — issue templates must live there.`); + return {}; + } + + const templates = {}; + for (const file of readdirSync(dir) + .filter((f) => /\.ya?ml$/.test(f) && f !== "config.yml") + .sort()) { + const source = readFileSync(path.join(dir, file), "utf8"); + const doc = YAML.parseDocument(source, { prettyErrors: true, strict: true }); + if (doc.errors.length > 0) { + for (const error of doc.errors) problems.push(`${TEMPLATE_DIR}/${file}: invalid YAML — ${error.message}`); + continue; + } + templates[file] = doc.toJS(); + } + return templates; +} + +function checkSchema(file, doc, problems) { + const where = `${TEMPLATE_DIR}/${file}`; + + if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { + problems.push(`${where}: top level must be a mapping.`); + return; + } + for (const key of Object.keys(doc)) { + if (!TOP_LEVEL_KEYS.has(key)) problems.push(`${where}: unknown top-level key "${key}".`); + } + for (const key of ["name", "description", "body"]) { + if (!doc[key]) problems.push(`${where}: missing required top-level "${key}".`); + } + if (!Array.isArray(doc.body)) { + if (doc.body) problems.push(`${where}: "body" must be a list.`); + return; + } + + const seen = new Set(); + doc.body.forEach((element, index) => { + const at = `${where} body[${index}]`; + if (element === null || typeof element !== "object") { + problems.push(`${at}: element must be a mapping.`); + return; + } + const attributes = element.attributes ?? {}; + + if (!ELEMENT_TYPES.has(element.type)) { + problems.push(`${at}: unknown element type "${element.type}".`); + return; + } + + if (element.type === "markdown") { + if (element.id) problems.push(`${at}: markdown blocks must not declare an id.`); + if (element.validations) problems.push(`${at}: markdown blocks must not declare validations.`); + if (!attributes.value) problems.push(`${at}: markdown block missing attributes.value.`); + return; + } + + if (!element.id) { + problems.push(`${at}: missing id — prefill links address fields by id.`); + } else if (seen.has(element.id)) { + problems.push(`${at}: duplicate id "${element.id}".`); + } + seen.add(element.id); + + if (!attributes.label) problems.push(`${at}: missing attributes.label.`); + + if (element.type === "dropdown" || element.type === "checkboxes") { + if (!Array.isArray(attributes.options) || attributes.options.length === 0) { + problems.push(`${at}: ${element.type} must declare a non-empty options list.`); + } + } + if (element.type === "checkboxes" && element.validations) { + problems.push(`${at}: checkboxes mark required per option, not via validations.`); + } + }); +} + +function elementShape(doc) { + return (doc.body ?? []) + .filter((element) => element && typeof element === "object") + .map((element) => { + const attributes = element.attributes ?? {}; + return [ + element.type, + element.id ?? "-", + element.validations?.required ? "required" : "optional", + attributes.render ? `render=${attributes.render}` : "", + attributes.multiple ? "multiple" : "", + ] + .filter(Boolean) + .join(":"); + }); +} + +// zh templates are 0N_*, their en mirrors 1N_*_en. +function checkLanguageParity(templates, problems) { + for (const file of Object.keys(templates)) { + const match = /^0(\d)_(.+)\.yaml$/.exec(file); + if (!match) continue; + const [, digit, slug] = match; + const mirror = `1${digit}_${slug}_en.yaml`; + + if (!templates[mirror]) { + problems.push(`${TEMPLATE_DIR}/${file}: missing English mirror ${mirror}.`); + continue; + } + + const zh = templates[file]; + const en = templates[mirror]; + + const zhShape = elementShape(zh); + const enShape = elementShape(en); + if (JSON.stringify(zhShape) !== JSON.stringify(enShape)) { + problems.push( + `${file} / ${mirror}: element structure differs — the two languages must expose the same fields.\n` + + ` zh: ${JSON.stringify(zhShape)}\n` + + ` en: ${JSON.stringify(enShape)}` + ); + } + for (const key of ["title", "type"]) { + if (zh[key] !== en[key]) problems.push(`${file} / ${mirror}: top-level "${key}" differs.`); + } + if (JSON.stringify(zh.labels) !== JSON.stringify(en.labels)) { + problems.push(`${file} / ${mirror}: "labels" differ.`); + } + + for (const type of ["dropdown", "checkboxes"]) { + const zhElements = (zh.body ?? []).filter((element) => element?.type === type); + const enElements = (en.body ?? []).filter((element) => element?.type === type); + zhElements.forEach((element, index) => { + const zhCount = element.attributes?.options?.length ?? 0; + const enCount = enElements[index]?.attributes?.options?.length ?? 0; + if (zhCount !== enCount) { + problems.push( + `${file} / ${mirror}: ${type} "${element.id}" has ${zhCount} options in zh but ${enCount} in en.` + ); + } + }); + } + } +} + +function checkPrefillContract(root, templates, problems) { + const srcDir = path.join(root, "src"); + if (!existsSync(srcDir)) return; + + const ids = Object.fromEntries( + Object.entries(templates).map(([file, doc]) => [ + file, + new Set((doc.body ?? []).map((element) => element?.id).filter(Boolean)), + ]) + ); + + for (const file of walkFiles(srcDir)) { + const source = readFileSync(file, "utf8"); + if (!source.includes("issues/new")) continue; + + const relative = path.relative(root, file); + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const expressions = new Set(); + + const visit = (node) => { + if ( + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node)) && + (staticText(node) ?? "").includes("issues/new") + ) { + expressions.add(outermostConcat(node)); + } + node.forEachChild(visit); + }; + visit(sourceFile); + + for (const expression of expressions) { + const url = staticText(expression); + if (url === null) { + problems.push( + `${relative}: found an issues/new URL this check cannot statically resolve. ` + + `Extend scripts/check-issue-templates.mjs rather than leaving the prefill contract unverified.` + ); + continue; + } + + const params = [...url.matchAll(/[?&\0]([a-zA-Z][\w-]*)=/g)].map((m) => m[1]).filter((p) => p !== "template"); + if (params.length === 0) continue; + + // `template=` is either spelled out inline (`template=01_bug_report.yaml&...`, so the name + // sits in the surrounding literal text) or chosen at runtime between locale variants + // (`template=${cond ? "01_bug_report" : "11_bug_report_en"}.yaml`, so the candidates are + // string literals in the expression). Collect both, and treat every candidate as reachable. + const targets = new Set(); + const addCandidate = (value) => { + const name = /^(\d\d_\w+?)(\.yaml)?$/.exec(value)?.[1]; + if (name && ids[`${name}.yaml`]) targets.add(`${name}.yaml`); + }; + addCandidate(/[?&\0]template=([^&\0]+)/.exec(url)?.[1] ?? ""); + for (const literal of collectStringLiterals(expression)) addCandidate(literal); + + if (targets.size === 0) { + problems.push( + `${relative}: issues/new URL prefills ${params.map((p) => `"${p}"`).join(", ")} ` + + `but no known template file could be resolved from it — the prefill target is unverifiable.` + ); + continue; + } + + for (const target of targets) { + for (const param of params) { + if (!ids[target].has(param)) { + problems.push( + `${relative}: prefills "${param}=", but ${TEMPLATE_DIR}/${target} has no field with that id. ` + + `GitHub silently drops unknown prefill params, and installed builds keep sending the old name.` + ); + } + } + } + } + } +} + +function runCheck(root) { + const problems = []; + const templates = readTemplates(root, problems); + + for (const [file, doc] of Object.entries(templates)) checkSchema(file, doc, problems); + checkLanguageParity(templates, problems); + checkPrefillContract(root, templates, problems); + + return { problems, templateCount: Object.keys(templates).length }; +} + +function main() { + const rootArg = process.argv.find((arg) => arg.startsWith("--root=")); + const root = rootArg ? path.resolve(rootArg.slice("--root=".length)) : path.resolve(__dirname, ".."); + + const { problems, templateCount } = runCheck(root); + + if (problems.length === 0) { + console.log(`✅ issue template check passed: ${templateCount} templates valid, mirrored, prefill ids intact.`); + process.exit(0); + } + + for (const problem of problems) console.error(`\n❌ ${problem}`); + console.error("\nIssue template check failed. Fix the problems above before submitting."); + process.exit(1); +} + +// Same argv/import.meta.url normalization rationale as scripts/check-i18n.mjs: compare real paths +// on both sides, or the check silently no-ops under symlinked or non-ASCII repo paths. +if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) { + main(); +} + +export { runCheck }; diff --git a/scripts/check-issue-templates.test.mjs b/scripts/check-issue-templates.test.mjs new file mode 100644 index 000000000..b2b1f5145 --- /dev/null +++ b/scripts/check-issue-templates.test.mjs @@ -0,0 +1,226 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { runCheck } from "./check-issue-templates.mjs"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// 用最小 fixture 仓库树(而非真实仓库)锁定两类真实回归: +// - 英文模板里未加引号的 `: ` 让 YAML 解析失败,中文模板因为用全角「:」而侥幸通过, +// 单看 diff 完全看不出来,GitHub 则直接拒绝渲染该表单; +// - 字段 id 被改名后,popup「报告问题」链接的 &browser= 预填静默失效, +// 且已安装的旧版本会一直沿用旧参数名。 + +const tmpDirs = []; + +const TEMPLATE_DIR = ".github/ISSUE_TEMPLATE"; + +function template({ name, title = "[BUG] ", labels = ["bug"], fields }) { + const body = fields + .map((field) => { + if (field.type === "markdown") { + return ` - type: markdown\n attributes:\n value: |\n ${field.value ?? "hello"}\n`; + } + const lines = [` - type: ${field.type}`]; + if (field.id !== undefined) lines.push(` id: ${field.id}`); + lines.push(` attributes:`); + lines.push(` label: ${field.label ?? field.id}`); + if (field.options) { + lines.push(` options:`); + for (const option of field.options) { + lines.push(typeof option === "string" ? ` - ${option}` : ` - label: ${option.label}`); + } + } + if (field.validations !== false) lines.push(` validations:\n required: ${field.required ?? true}`); + return lines.join("\n") + "\n"; + }) + .join("\n"); + return `name: ${name}\ndescription: ${name} description\ntitle: "${title}"\nlabels: ${JSON.stringify(labels)}\n\nbody:\n${body}`; +} + +const DEFAULT_FIELDS = [ + { type: "markdown" }, + { type: "textarea", id: "summary" }, + { type: "input", id: "scriptcat-version" }, + { type: "input", id: "browser" }, +]; + +function makeFixtureRoot({ zh, en, source } = {}) { + const dir = path.join(os.tmpdir(), `check-issue-templates-${Math.random().toString(36).slice(2)}`); + tmpDirs.push(dir); + mkdirSync(path.join(dir, TEMPLATE_DIR), { recursive: true }); + + writeFileSync( + path.join(dir, TEMPLATE_DIR, "01_bug_report.yaml"), + zh ?? template({ name: "Bug 反馈", fields: DEFAULT_FIELDS }) + ); + writeFileSync( + path.join(dir, TEMPLATE_DIR, "11_bug_report_en.yaml"), + en ?? template({ name: "Bug Report", fields: DEFAULT_FIELDS }) + ); + + if (source !== undefined) { + mkdirSync(path.join(dir, "src/pages/popup"), { recursive: true }); + writeFileSync(path.join(dir, "src/pages/popup/App.tsx"), source); + } + return dir; +} + +function problemsOf(root) { + return runCheck(root).problems; +} + +afterEach(() => { + while (tmpDirs.length) rmSync(tmpDirs.pop(), { recursive: true, force: true }); +}); + +describe("issue 模板机械检查", () => { + it("结构合法且中英文对齐的模板集合应通过", () => { + expect(problemsOf(makeFixtureRoot())).toEqual([]); + }); + + it("仓库现有的 issue 模板应全部通过检查", () => { + expect(problemsOf(REPO_ROOT)).toEqual([]); + }); + + describe("YAML 与 issue-form schema", () => { + it("未加引号导致的 YAML 解析失败应报错,而不是被跳过", () => { + // description 里裸露的 `: ` 会让 YAML 认为这里又开了一个映射键。 + const broken = + `name: Bug Report\ndescription: d\ntitle: "[BUG] "\nlabels: ["bug"]\n\nbody:\n` + + ` - type: input\n id: summary\n attributes:\n label: L\n` + + ` description: For logs, note the source: page console, service worker\n`; + const problems = problemsOf(makeFixtureRoot({ en: broken })); + expect(problems.join("\n")).toMatch(/invalid YAML/); + }); + + it("markdown 块声明 id 应报错", () => { + const zh = template({ + name: "Bug 反馈", + fields: [{ type: "markdown" }, { type: "textarea", id: "summary" }], + }).replace(" - type: markdown\n", " - type: markdown\n id: intro\n"); + expect(problemsOf(makeFixtureRoot({ zh })).join("\n")).toMatch(/markdown blocks must not declare an id/); + }); + + it("重复的字段 id 应报错", () => { + const fields = [ + { type: "textarea", id: "summary" }, + { type: "input", id: "summary" }, + ]; + const zh = template({ name: "Bug 反馈", fields }); + const en = template({ name: "Bug Report", fields }); + expect(problemsOf(makeFixtureRoot({ zh, en })).join("\n")).toMatch(/duplicate id "summary"/); + }); + + it("非 markdown 字段缺少 id 应报错", () => { + const fields = [{ type: "textarea", label: "无 id" }]; + const zh = template({ name: "Bug 反馈", fields }); + const en = template({ name: "Bug Report", fields }); + expect(problemsOf(makeFixtureRoot({ zh, en })).join("\n")).toMatch(/missing id/); + }); + + it("dropdown 没有 options 应报错", () => { + const fields = [{ type: "dropdown", id: "area" }]; + const zh = template({ name: "Bug 反馈", fields }); + const en = template({ name: "Bug Report", fields }); + expect(problemsOf(makeFixtureRoot({ zh, en })).join("\n")).toMatch(/must declare a non-empty options list/); + }); + + it("checkboxes 用 validations 而非逐项 required 应报错", () => { + const fields = [{ type: "checkboxes", id: "precheck", options: [{ label: "已搜索" }] }]; + const zh = template({ name: "Bug 反馈", fields }); + const en = template({ name: "Bug Report", fields }); + expect(problemsOf(makeFixtureRoot({ zh, en })).join("\n")).toMatch(/mark required per option/); + }); + + it("未知的顶层键应报错", () => { + const zh = template({ name: "Bug 反馈", fields: DEFAULT_FIELDS }) + "\nunknown_key: x\n"; + expect(problemsOf(makeFixtureRoot({ zh })).join("\n")).toMatch(/unknown top-level key "unknown_key"/); + }); + }); + + describe("中英文模板对齐", () => { + it("英文镜像缺少某个字段时应报错", () => { + const en = template({ + name: "Bug Report", + fields: DEFAULT_FIELDS.filter((field) => field.id !== "browser"), + }); + expect(problemsOf(makeFixtureRoot({ en })).join("\n")).toMatch(/element structure differs/); + }); + + it("同一字段中英文必填性不一致时应报错", () => { + const en = template({ + name: "Bug Report", + fields: DEFAULT_FIELDS.map((field) => (field.id === "browser" ? { ...field, required: false } : field)), + }); + expect(problemsOf(makeFixtureRoot({ en })).join("\n")).toMatch(/element structure differs/); + }); + + it("labels 不一致时应报错", () => { + const en = template({ name: "Bug Report", labels: ["bug", "extra"], fields: DEFAULT_FIELDS }); + expect(problemsOf(makeFixtureRoot({ en })).join("\n")).toMatch(/"labels" differ/); + }); + + it("缺少英文镜像文件应报错", () => { + const root = makeFixtureRoot(); + rmSync(path.join(root, TEMPLATE_DIR, "11_bug_report_en.yaml")); + expect(problemsOf(root).join("\n")).toMatch(/missing English mirror/); + }); + }); + + describe("issues/new 预填契约", () => { + const linkSource = (params) => ` + export function App() { + const onClick = () => { + const issueUrl = + \`https://github.com/scriptscat/scriptcat/issues/new?\` + + \`template=\${isChineseUser() ? "01_bug_report" : "11_bug_report_en"}.yaml&${params}\`; + window.open(issueUrl, "_blank"); + }; + return onClick; + } + `; + + it("预填参数都能对应到字段 id 时应通过", () => { + const source = linkSource("scriptcat-version=${ExtVersion}&browser=${encodeURIComponent(ua)}"); + expect(problemsOf(makeFixtureRoot({ source }))).toEqual([]); + }); + + it("字段 id 被改名导致预填参数失效时应报错", () => { + // browser -> environment:模板本身完全合法,只有链接会静默失效。 + const renamed = DEFAULT_FIELDS.map((field) => (field.id === "browser" ? { ...field, id: "environment" } : field)); + const zh = template({ name: "Bug 反馈", fields: renamed }); + const en = template({ name: "Bug Report", fields: renamed }); + const source = linkSource("scriptcat-version=${ExtVersion}&browser=${encodeURIComponent(ua)}"); + const problems = problemsOf(makeFixtureRoot({ zh, en, source })); + expect(problems.join("\n")).toMatch(/prefills "browser="/); + expect(problems.join("\n")).toMatch(/01_bug_report\.yaml has no field with that id/); + expect(problems.join("\n")).toMatch(/11_bug_report_en\.yaml has no field with that id/); + }); + + it("跨越模板字面量拼接的第二段参数同样要被检查", () => { + // browser= 出现在新的一段模板字面量开头,而不是紧跟 ? 或 &。 + const source = ` + const issueUrl = + \`https://github.com/scriptscat/scriptcat/issues/new?\` + + \`template=01_bug_report.yaml&scriptcat-version=\${ExtVersion}&\` + + \`nonexistent-field=\${x}\`; + `; + expect(problemsOf(makeFixtureRoot({ source })).join("\n")).toMatch(/prefills "nonexistent-field="/); + }); + + it("无法静态解析出模板名时应报错而不是放行", () => { + const source = ` + const issueUrl = \`https://github.com/scriptscat/scriptcat/issues/new?template=\${pickTemplate()}&browser=\${ua}\`; + \`;`; + expect(problemsOf(makeFixtureRoot({ source })).join("\n")).toMatch(/prefill target is unverifiable/); + }); + + it("不带预填参数的 issues/new 链接不应报错", () => { + const source = `const url = "https://github.com/scriptscat/scriptcat/issues/new";`; + expect(problemsOf(makeFixtureRoot({ source }))).toEqual([]); + }); + }); +}); From f3ba452cff3f128366d3accd7d5cf5b60931c19c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:54:45 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=9D=E8=AF=81=20GM=20va?= =?UTF-8?q?lue=20=E6=9B=B4=E6=96=B0=E6=89=B9=E5=A4=84=E7=90=86=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 37 +++++ src/app/service/content/script_executor.ts | 4 +- src/app/service/content/types.ts | 4 +- src/app/service/sandbox/runtime.ts | 20 ++- src/app/service/service_worker/value.test.ts | 153 +++++++++++++++--- src/app/service/service_worker/value.ts | 11 +- 6 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 src/app/service/content/script_executor.test.ts diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts new file mode 100644 index 000000000..6fd8561b0 --- /dev/null +++ b/src/app/service/content/script_executor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import type ExecScript from "./exec_script"; +import { ScriptExecutor } from "./script_executor"; +import type { ValueUpdateDataEncoded } from "./types"; +import type { Message } from "@Packages/message/types"; +import { encodeRValue } from "@App/pkg/utils/message_value"; + +const response = (uuid: string, id: string, value: string): ValueUpdateDataEncoded => ({ + id, + valueChanges: [["key", encodeRValue(value), encodeRValue("previous")]], + uuid, + storageName: "shared", + sender: { runFlag: "worker", tabId: -2 }, +}); + +describe("ScriptExecutor value updates", () => { + it("preserves interleaved updates from scripts sharing a storageName", () => { + const executor = new ScriptExecutor({} as Message, {} as Message); + const update = vi.fn(); + executor.execScriptMap.set("script-a", { valueUpdate: update } as unknown as ExecScript); + + executor.valueUpdate({ + storageName: "shared", + storageChanges: [ + response("script-a", "a1", "a1"), + response("script-b", "b1", "b1"), + response("script-a", "a2", "a2"), + ], + }); + + expect(update.mock.calls.map(([, uuid, responses]) => [uuid, responses[0].id])).toEqual([ + ["script-a", "a1"], + ["script-b", "b1"], + ["script-a", "a2"], + ]); + }); +}); diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index b8c5e4888..78a2c4fec 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -48,9 +48,9 @@ export class ScriptExecutor { valueUpdate(sendData: ValueUpdateSendData) { // runtime/valueUpdate const { storageName, storageChanges } = sendData; - for (const [uuid, responses] of Object.entries(storageChanges)) { + for (const response of storageChanges) { for (const execScript of this.execScriptMap.values()) { - execScript.valueUpdate(storageName, uuid, responses); + execScript.valueUpdate(storageName, response.uuid, [response]); } } } diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index c1d4e6a6a..ae0cbe3a4 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -22,10 +22,10 @@ export type ValueUpdateDataEncoded = { sender: ValueUpdateSender; }; -// 以 storageName 为单位的推送数据;storageChanges 以 uuid 分组,同组内按处理顺序排列 +// 以 storageName 为单位的推送数据;storageChanges 保留跨 uuid 的处理顺序 export type ValueUpdateSendData = { storageName: string; - storageChanges: Record; + storageChanges: ValueUpdateDataEncoded[]; }; // gm_api.ts diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index b1d28d22b..043fe3c2c 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -343,23 +343,21 @@ export class Runtime { valueUpdate(sendData: ValueUpdateSendData) { // runtime/valueUpdate const { storageName, storageChanges } = sendData; - for (const [uuid, responses] of Object.entries(storageChanges)) { + for (const response of storageChanges) { // 转发给脚本 for (const execScript of this.execScriptMap.values()) { - execScript.valueUpdate(storageName, uuid, responses); + execScript.valueUpdate(storageName, response.uuid, [response]); } // 更新crontabScripts中的脚本值 for (const script of this.crontabSripts) { - if (script.uuid === uuid || getStorageName(script) === storageName) { + if (script.uuid === response.uuid || getStorageName(script) === storageName) { const valueStore = script.value; - for (const { valueChanges } of responses) { - for (const [key, rTyped1, _rTyped2] of valueChanges) { - const value = decodeRValue(rTyped1); - if (value !== undefined) { - valueStore[key] = value; - } else if (valueStore[key] !== undefined) { - delete valueStore[key]; - } + for (const [key, rTyped1, _rTyped2] of response.valueChanges) { + const value = decodeRValue(rTyped1); + if (value !== undefined) { + valueStore[key] = value; + } else if (valueStore[key] !== undefined) { + delete valueStore[key]; } } } diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index bd8e22b42..ab876c927 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -151,9 +151,7 @@ describe("ValueService - setValues 方法测试", () => { [mockScript], expect.objectContaining({ storageName: getStorageName(mockScript), - storageChanges: { - [mockScript.uuid]: [expectResponse("testId-4021", mockScript, 1)], - }, + storageChanges: [expectResponse("testId-4021", mockScript, 1)], }) ); @@ -198,9 +196,7 @@ describe("ValueService - setValues 方法测试", () => { [mockScript], expect.objectContaining({ storageName: getStorageName(mockScript), - storageChanges: { - [mockScript.uuid]: [expectResponse("testId-4022", mockScript, 1)], - }, + storageChanges: [expectResponse("testId-4022", mockScript, 1)], }) ); @@ -254,9 +250,7 @@ describe("ValueService - setValues 方法测试", () => { [mockScript], expect.objectContaining({ storageName: getStorageName(mockScript), - storageChanges: { - [mockScript.uuid]: [expectResponse("testId-4023", mockScript, 1)], - }, + storageChanges: [expectResponse("testId-4023", mockScript, 1)], }) ); @@ -306,9 +300,7 @@ describe("ValueService - setValues 方法测试", () => { [], // 没有实际变更的脚本 expect.objectContaining({ storageName: getStorageName(mockScript), - storageChanges: { - [mockScript.uuid]: [expectResponse("testId-4024", mockScript, 0)], - }, + storageChanges: [expectResponse("testId-4024", mockScript, 0)], }) ); // 值未改变 }); @@ -449,12 +441,7 @@ describe("ValueService - setValues 方法测试", () => { [mockScript], expect.objectContaining({ storageName: getStorageName(mockScript), - storageChanges: { - [mockScript.uuid]: [ - expectResponse("testId-4041", mockScript, 1), - expectResponse("testId-4042", mockScript, 1), - ], - }, + storageChanges: [expectResponse("testId-4041", mockScript, 1), expectResponse("testId-4042", mockScript, 1)], }) ); @@ -465,6 +452,134 @@ describe("ValueService - setValues 方法测试", () => { expect(savedValue.data[key2]).toBe(value2); }); + it("普通并发 setValues 应在首个异步读期间合并为一次读写与一次推送", async () => { + const mockScript = createMockScript(); + const mockSender = createMockValueSender(); + const valueGet = deferred(); + + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockImplementation(() => valueGet.promise); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + const first = valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4043", + keyValuePairs: [["first", encodeRValue("value1")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + const second = valueService.setValues({ + uuid: mockScript.uuid, + id: "testId-4044", + keyValuePairs: [["second", encodeRValue("value2")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + await nextMacroTask(); + + expect(mockValueDAO.get).toHaveBeenCalledTimes(1); + valueGet.resolve(undefined); + await Promise.all([first, second]); + + expect(mockValueDAO.get).toHaveBeenCalledTimes(1); + expect(mockValueDAO.save).toHaveBeenCalledTimes(1); + expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); + }); + + it("不同 storageName 的并发 setValues 也应按调用顺序完成写入与推送", async () => { + const firstScript = createMockScript(); + const secondScript = createMockScript(); + const mockSender = createMockValueSender(); + const firstValueGet = deferred(); + const pushOrder: string[] = []; + const firstStorageName = getStorageName(firstScript); + const secondStorageName = getStorageName(secondScript); + + vi.mocked(mockScriptDAO.get).mockImplementation(async (uuid) => + uuid === firstScript.uuid ? firstScript : secondScript + ); + vi.mocked(mockValueDAO.get).mockImplementation((storageName) => + storageName === firstStorageName ? firstValueGet.promise : Promise.resolve(undefined) + ); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + vi.mocked(valueService.pushValueUpdate).mockImplementation(async (_scripts, sendData) => { + pushOrder.push(sendData.storageName); + }); + + const first = valueService.setValues({ + uuid: firstScript.uuid, + id: "testId-4045", + keyValuePairs: [["first", encodeRValue("value1")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + const second = valueService.setValues({ + uuid: secondScript.uuid, + id: "testId-4046", + keyValuePairs: [["second", encodeRValue("value2")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }); + await nextMacroTask(); + + expect(mockValueDAO.get).toHaveBeenCalledTimes(1); + expect(pushOrder).toEqual([]); + firstValueGet.resolve(undefined); + await Promise.all([first, second]); + + expect(pushOrder).toEqual([firstStorageName, secondStorageName]); + }); + + it("共享 storageName 的跨脚本更新应保留交错响应顺序", async () => { + const firstScript = createMockScript({ metadata: { storagename: ["shared-storage"] } }); + const secondScript = createMockScript({ metadata: { storagename: ["shared-storage"] } }); + const mockSender = createMockValueSender(); + const existingValueModel: Value = { + uuid: firstScript.uuid, + storageName: "shared-storage", + data: { key: "initial" }, + createtime: Date.now() - 1000, + updatetime: Date.now() - 1000, + }; + + vi.mocked(mockScriptDAO.get).mockImplementation(async (uuid) => + uuid === firstScript.uuid ? firstScript : secondScript + ); + vi.mocked(mockValueDAO.get).mockResolvedValue(existingValueModel); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + await Promise.all([ + valueService.setValues({ + uuid: firstScript.uuid, + id: "testId-4047", + keyValuePairs: [["key", encodeRValue("a1")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + valueService.setValues({ + uuid: secondScript.uuid, + id: "testId-4048", + keyValuePairs: [["key", encodeRValue("b1")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + valueService.setValues({ + uuid: firstScript.uuid, + id: "testId-4049", + keyValuePairs: [["key", encodeRValue("a2")]] satisfies TKeyValuePair[], + valueSender: mockSender, + isReplace: false, + }), + ]); + + const sendData = vi.mocked(valueService.pushValueUpdate).mock.calls[0][1]; + expect(sendData.storageChanges.map((item) => [item.uuid, item.id])).toEqual([ + [firstScript.uuid, "testId-4047"], + [secondScript.uuid, "testId-4048"], + [firstScript.uuid, "testId-4049"], + ]); + }); + it("setValues 的处理顺序应与调用顺序一致", async () => { // scriptDAO.get 的耗时差异不应打乱 setValues 的处理顺序 const mockScript = createMockScript(); @@ -508,7 +623,7 @@ describe("ValueService - setValues 方法测试", () => { // 推送的响应顺序也与调用顺序一致 expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1); const sendData = vi.mocked(valueService.pushValueUpdate).mock.calls[0][1]; - expect(sendData.storageChanges[mockScript.uuid].map((r) => r.id)).toEqual(["testId-4051", "testId-4052"]); + expect(sendData.storageChanges.map((r) => r.id)).toEqual(["testId-4051", "testId-4052"]); }); it("值变更时 updatetime 应严格递增", async () => { diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index b69c5503f..a18a3051c 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -104,7 +104,7 @@ export class ValueService { // 同步取走整批任务;之后入队的任务会建立新列表,由其对应的队列调用处理 this.valueUpdateTasks.delete(storageName); let valueModel: Value | undefined = await this.valueDAO.get(storageName); - const storageChanges: Record = {}; + const storageChanges: ValueUpdateDataEncoded[] = []; // 有实际值变更的脚本(uuid 去重),供 early-start 脚本重新注册使用 const updatedScripts = new Map(); let valueModelUpdated = false; @@ -170,8 +170,7 @@ export class ValueService { if (entries.length > 0 && !updatedScripts.has(uuid)) { updatedScripts.set(uuid, script); } - const list = storageChanges[uuid] || (storageChanges[uuid] = []); - list.push({ + storageChanges.push({ id, valueChanges: entries, uuid, @@ -211,8 +210,10 @@ export class ValueService { } taskList.push({ script, id, keyValuePairs, valueSender, isReplace, ts }); }); - // valueDAO 读写以 storageName 为单位串行 - await stackAsyncTask(`${CACHE_KEY_SET_VALUE}${storageName}`, () => this.setValuesByStorageName(storageName)); + // 处理任务也进入全局队列:先让当前调用批次完成入队,再按调用顺序完成各 storageName 的写入与推送 + await stackAsyncTask("valueChangeOnSequence", () => + stackAsyncTask(`${CACHE_KEY_SET_VALUE}${storageName}`, () => this.setValuesByStorageName(storageName)) + ); } setScriptValues(params: Pick, _sender: IGetSender) {