diff --git a/packages/eslint/compat-headers.d.ts b/packages/eslint/compat-headers.d.ts new file mode 100644 index 000000000..1363abd86 --- /dev/null +++ b/packages/eslint/compat-headers.d.ts @@ -0,0 +1,7 @@ +// compat-headers.js 是给 rspack alias 用的 CommonJS 覆盖文件(见 rspack.config.ts), +// 这里只声明测试与类型检查需要的形状。 +export declare const compatMap: { + localized: Record; + unlocalized: Record; + nonFunctional: Record; +}; diff --git a/packages/eslint/compat-headers.js b/packages/eslint/compat-headers.js index 2a674d4e7..53c0d0deb 100644 --- a/packages/eslint/compat-headers.js +++ b/packages/eslint/compat-headers.js @@ -17,6 +17,7 @@ const compatMap = { storageName: [], "early-start": [], "require-css": [], + definition: [], allFrames: [], }, }; diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 25bfaa109..f127a99b8 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -7,6 +7,12 @@ export function GMContextApiGet(name: string): ApiValue[] | undefined { return apis.get(name); } +// 已注册的全部 @grant 名。注册表由装饰器在模块载入时填充,无法静态推导, +// 供 script_compat.ts 的静态支持表做一致性守卫(新增 GM API 若漏进表会被测出来)。 +export function GMContextApiNames(): string[] { + return [...apis.keys()]; +} + function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis.get(grant); diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index f22259290..045519f21 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -254,5 +254,13 @@ "expired_title": "Update-Inhalt ist abgelaufen", "expired_desc": "Der für dieses Update vorbereitete Code wurde bereinigt. Bitte erneut nach Updates suchen.", "expired_recheck": "Erneut nach Updates suchen", - "code_loading": "Code wird geladen" + "code_loading": "Code wird geladen", + "compat_ineffective": "ohne Wirkung", + "compat_tag_desc": "ScriptCat unterstützt diese Deklaration nicht; sie wird nach der Installation ignoriert.", + "compat_grant_desc": "ScriptCat implementiert diese API nicht; Aufrufe schlagen fehl und darauf aufbauende Funktionen arbeiten nicht.", + "compat_jump": "Zu Zeile {{line}} springen", + "compat_docs": "Kompatibilitätsdokumentation", + "compat_count": "{{count}} ohne Wirkung", + "perm_other_label": "Weitere Deklarationen", + "perm_other_summary": "Vom Skript deklariert, wird von ScriptCat aber nicht ausgeführt" } diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 64c24de4f..6949c896f 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -254,5 +254,13 @@ "expired_title": "Update content has expired", "expired_desc": "The code prepared for this update has been cleaned up. Please check for updates again.", "expired_recheck": "Check for updates again", - "code_loading": "Loading code" + "code_loading": "Loading code", + "compat_ineffective": "no effect", + "compat_tag_desc": "ScriptCat does not support this declaration; it is ignored after installation.", + "compat_grant_desc": "ScriptCat has not implemented this API; calls to it fail and features relying on it won't work.", + "compat_jump": "Go to line {{line}}", + "compat_docs": "Compatibility docs", + "compat_count": "{{count}} with no effect", + "perm_other_label": "Other declarations", + "perm_other_summary": "Declared by the script but not executed by ScriptCat" } diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 0abd1017c..81b48a379 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -254,5 +254,13 @@ "expired_title": "更新内容の有効期限が切れました", "expired_desc": "この更新のために用意されたコードは削除されました。もう一度更新を確認してください。", "expired_recheck": "更新を再確認", - "code_loading": "コードを読み込み中" + "code_loading": "コードを読み込み中", + "compat_ineffective": "無効", + "compat_tag_desc": "ScriptCat はこの宣言に対応していないため、インストール後は無視されます。", + "compat_grant_desc": "ScriptCat はこの API を実装していないため、スクリプトから呼び出すとエラーになり、これに依存する機能は動作しません。", + "compat_jump": "{{line}} 行目へ移動", + "compat_docs": "互換性ドキュメント", + "compat_count": "無効な項目 {{count}} 件", + "perm_other_label": "その他の宣言", + "perm_other_summary": "宣言されていますが ScriptCat では実行されません" } diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index a06879c34..d99b8e78f 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -254,5 +254,13 @@ "expired_title": "업데이트 내용이 만료되었습니다", "expired_desc": "이번 업데이트를 위해 준비한 코드가 정리되었습니다. 업데이트를 다시 확인해 주세요.", "expired_recheck": "업데이트 다시 확인", - "code_loading": "코드를 불러오는 중" + "code_loading": "코드를 불러오는 중", + "compat_ineffective": "적용되지 않음", + "compat_tag_desc": "ScriptCat은 이 선언을 지원하지 않으므로 설치 후 무시됩니다.", + "compat_grant_desc": "ScriptCat은 이 API를 구현하지 않았습니다. 스크립트에서 호출하면 오류가 발생하고 이에 의존하는 기능은 동작하지 않습니다.", + "compat_jump": "{{line}}번째 줄로 이동", + "compat_docs": "호환성 문서", + "compat_count": "적용되지 않는 항목 {{count}}개", + "perm_other_label": "기타 선언", + "perm_other_summary": "선언되었지만 ScriptCat이 실행하지 않습니다" } diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 93047d702..0990111b3 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -254,5 +254,13 @@ "expired_title": "O conteúdo da atualização expirou", "expired_desc": "O código preparado para esta atualização foi removido. Verifique as atualizações novamente.", "expired_recheck": "Verificar atualizações novamente", - "code_loading": "Carregando código" + "code_loading": "Carregando código", + "compat_ineffective": "sem efeito", + "compat_tag_desc": "O ScriptCat não oferece suporte a esta declaração; ela é ignorada após a instalação.", + "compat_grant_desc": "O ScriptCat não implementou esta API; as chamadas falham e os recursos que dependem dela não funcionam.", + "compat_jump": "Ir para a linha {{line}}", + "compat_docs": "Documentação de compatibilidade", + "compat_count": "{{count}} sem efeito", + "perm_other_label": "Outras declarações", + "perm_other_summary": "Declarado pelo script, mas não executado pelo ScriptCat" } diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index f452106b2..0798b3ac2 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -254,5 +254,13 @@ "expired_title": "Содержимое обновления устарело", "expired_desc": "Код, подготовленный для этого обновления, был очищен. Проверьте обновления ещё раз.", "expired_recheck": "Проверить обновления ещё раз", - "code_loading": "Загрузка кода" + "code_loading": "Загрузка кода", + "compat_ineffective": "не действует", + "compat_tag_desc": "ScriptCat не поддерживает это объявление — после установки оно игнорируется.", + "compat_grant_desc": "ScriptCat не реализует этот API: вызов из скрипта завершится ошибкой, а зависящие от него функции работать не будут.", + "compat_jump": "Перейти к строке {{line}}", + "compat_docs": "Документация по совместимости", + "compat_count": "Не действует: {{count}}", + "perm_other_label": "Прочие объявления", + "perm_other_summary": "Объявлено скриптом, но ScriptCat это не выполняет" } diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 0cb0a6752..4cdc157a4 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -254,5 +254,13 @@ "expired_title": "Güncelleme içeriğinin süresi doldu", "expired_desc": "Bu güncelleme için hazırlanan kod temizlendi. Lütfen güncellemeleri yeniden denetleyin.", "expired_recheck": "Güncellemeleri yeniden denetle", - "code_loading": "Kod yükleniyor" + "code_loading": "Kod yükleniyor", + "compat_ineffective": "etkisiz", + "compat_tag_desc": "ScriptCat bu bildirimi desteklemiyor; kurulumdan sonra yok sayılır.", + "compat_grant_desc": "ScriptCat bu API'yi uygulamadı; betik çağırdığında hata verir ve buna dayanan özellikler çalışmaz.", + "compat_jump": "{{line}}. satıra git", + "compat_docs": "Uyumluluk belgeleri", + "compat_count": "{{count}} etkisiz", + "perm_other_label": "Diğer bildirimler", + "perm_other_summary": "Betikte bildirildi ancak ScriptCat tarafından çalıştırılmıyor" } diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 6f99c54d7..20ff18f52 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -254,5 +254,13 @@ "expired_title": "Nội dung cập nhật đã hết hạn", "expired_desc": "Mã đã chuẩn bị cho lần cập nhật này đã bị dọn dẹp. Vui lòng kiểm tra cập nhật lại.", "expired_recheck": "Kiểm tra cập nhật lại", - "code_loading": "Đang tải mã" + "code_loading": "Đang tải mã", + "compat_ineffective": "không có tác dụng", + "compat_tag_desc": "ScriptCat không hỗ trợ khai báo này; nó sẽ bị bỏ qua sau khi cài đặt.", + "compat_grant_desc": "ScriptCat chưa triển khai API này; script gọi đến sẽ báo lỗi và các tính năng phụ thuộc vào nó sẽ không hoạt động.", + "compat_jump": "Đến dòng {{line}}", + "compat_docs": "Tài liệu tương thích", + "compat_count": "{{count}} mục không có tác dụng", + "perm_other_label": "Khai báo khác", + "perm_other_summary": "Script có khai báo nhưng ScriptCat không thực thi" } diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 3947d3f7a..8afdd420e 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -254,5 +254,13 @@ "expired_title": "更新内容已过期", "expired_desc": "这次更新准备好的代码已被清理,需要重新检查一次更新。", "expired_recheck": "重新检查更新", - "code_loading": "正在加载代码" + "code_loading": "正在加载代码", + "compat_ineffective": "不生效", + "compat_tag_desc": "脚本猫不支持该声明,安装后会被忽略。", + "compat_grant_desc": "脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。", + "compat_jump": "跳到第 {{line}} 行", + "compat_docs": "兼容性文档", + "compat_count": "{{count}} 项不生效", + "perm_other_label": "其他声明", + "perm_other_summary": "已声明,但脚本猫不会执行" } diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 6998fe976..1baf3ab38 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -254,5 +254,13 @@ "expired_title": "更新內容已過期", "expired_desc": "這次更新準備好的程式碼已被清理,需要重新檢查一次更新。", "expired_recheck": "重新檢查更新", - "code_loading": "正在載入程式碼" + "code_loading": "正在載入程式碼", + "compat_ineffective": "不生效", + "compat_tag_desc": "腳本貓不支援此宣告,安裝後會被忽略。", + "compat_grant_desc": "腳本貓尚未實作此 API,腳本呼叫時會出錯,依賴它的功能無法使用。", + "compat_jump": "跳至第 {{line}} 行", + "compat_docs": "相容性文件", + "compat_count": "{{count}} 項不生效", + "perm_other_label": "其他宣告", + "perm_other_summary": "已宣告,但腳本貓不會執行" } diff --git a/src/pages/components/CodeEditor/index.test.tsx b/src/pages/components/CodeEditor/index.test.tsx index 8661091b4..09e9b60e0 100644 --- a/src/pages/components/CodeEditor/index.test.tsx +++ b/src/pages/components/CodeEditor/index.test.tsx @@ -5,6 +5,8 @@ import { render, cleanup, act, waitFor } from "@testing-library/react"; // 用 hoisted 持有可在测试内变更的主题与 monaco 桩,供被提升的 vi.mock 工厂引用 const h = vi.hoisted(() => { const makeEditor = () => ({ + revealLineInCenter: vi.fn(), + setSelection: vi.fn(), setModel: vi.fn(), setValue: vi.fn(), updateOptions: vi.fn(), @@ -18,7 +20,10 @@ const h = vi.hoisted(() => { return { resolvedTheme: "light" as string, setTheme: vi.fn(), - createDiffEditor: vi.fn((_container?: unknown, _options?: any) => makeEditor()), + createDiffEditor: vi.fn((_container?: unknown, _options?: any) => { + const modified = makeEditor(); + return { ...makeEditor(), getModifiedEditor: () => modified, __modified: modified }; + }), create: vi.fn((_container?: unknown, _options?: any) => makeEditor()), createModel: vi.fn(() => ({ dispose: vi.fn(), @@ -36,7 +41,14 @@ vi.mock("monaco-editor", () => ({ setTheme: h.setTheme, setModelMarkers: vi.fn(), }, - Range: class {}, + Range: class { + constructor( + public startLineNumber: number, + public startColumn: number, + public endLineNumber: number, + public endColumn: number + ) {} + }, })); vi.mock("./theme", () => ({ resolveMonacoTheme: (t: string) => t })); vi.mock("@App/pkg/utils/monaco-editor", () => ({ @@ -126,3 +138,40 @@ describe("CodeEditor 可访问性与主题", () => { expect(h.setTheme).toHaveBeenCalledWith("dark"); }); }); + +describe("CodeEditor 就绪信号与定位", () => { + it("内联 diff 也报告就绪——否则加载占位会一直留在无障碍树里说「正在加载」", async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + + it("普通编辑器同样报告就绪", async () => { + const onReady = vi.fn(); + await act(async () => { + render(); + }); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + + it("revealLine 在普通编辑器上滚动并选中整行", async () => { + const ref = createRef>(); + await act(async () => { + render(); + }); + await waitFor(() => expect(h.create).toHaveBeenCalled()); + act(() => ref.current?.revealLine(2)); + const instance = h.create.mock.results[0].value; + expect(instance.revealLineInCenter).toHaveBeenCalledWith(2); + expect(instance.setSelection).toHaveBeenCalled(); + }); + + it("revealLine 在 diff 预览里定位到修改侧——诊断说的是新版本的那一行", async () => { + const ref = createRef>(); + render(); + await waitFor(() => expect(h.createDiffEditor).toHaveBeenCalled()); + act(() => ref.current?.revealLine(2)); + const modified = (h.createDiffEditor.mock.results[0].value as any).__modified; + expect(modified.revealLineInCenter).toHaveBeenCalledWith(2); + }); +}); diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx index d0465e68a..621336ce3 100644 --- a/src/pages/components/CodeEditor/index.tsx +++ b/src/pages/components/CodeEditor/index.tsx @@ -7,15 +7,25 @@ import { clearModelEslintFixes, getModelEslintFixKey } from "@App/pkg/utils/mona import { useTheme } from "@App/pages/components/theme-provider"; import { resolveMonacoTheme } from "./theme"; +export interface CodeEditorHandle { + /** 普通编辑器实例;diff 预览没有可编辑实例,为 undefined */ + editor: editor.IStandaloneCodeEditor | undefined; + /** 滚动到指定行并选中整行;diff 预览定位到修改侧 */ + revealLine: (line: number) => void; +} + type Props = { - ref?: Ref<{ editor: editor.IStandaloneCodeEditor | undefined }>; + ref?: Ref; className?: string; diffCode?: string; // 代码加载是异步的:undefined=不确定(不加载),""=无 diff,有值=diff editable?: boolean; id: string; code?: string; onChange?: (code: string) => void; + /** 普通编辑器实例就绪;diff 预览没有可编辑实例,不会触发 */ onEditorMount?: (editor: editor.IStandaloneCodeEditor) => void; + /** 编辑器已创建(含 diff 预览);用于收起加载占位 */ + onReady?: () => void; }; type TMarker = { @@ -44,7 +54,7 @@ function toMonacoEditorPreferenceOptions( } satisfies editor.IEditorOptions; } -function CodeEditor({ id, className, code, diffCode, editable, onChange, onEditorMount, ref }: Props) { +function CodeEditor({ id, className, code, diffCode, editable, onChange, onEditorMount, onReady, ref }: Props) { const [monacoEditor, setEditor] = useState(); const editorInstanceRef = useRef(undefined); // 普通 editor 与 diff editor 都会置位,供主题切换 effect 判断实例是否就绪 @@ -58,14 +68,27 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito // 用 ref 保存最新回调,避免 stale closure 同时不让创建 effect 重跑 const onChangeRef = useRef(onChange); const onEditorMountRef = useRef(onEditorMount); + const onReadyRef = useRef(onReady); // ref 赋值须在创建 effect 之前,确保 mount 时创建 effect 同步读到最新 onEditorMount useEffect(() => { onChangeRef.current = onChange; onEditorMountRef.current = onEditorMount; + onReadyRef.current = onReady; }); const divRef = useRef(null); - useImperativeHandle(ref, () => ({ editor: monacoEditor })); + useImperativeHandle(ref, () => ({ + editor: monacoEditor, + revealLine: (line: number) => { + const instance = editorInstanceRef.current; + if (!instance) return; + // diff 预览的行号说的是新版本,定位到修改侧 + const target = "getModifiedEditor" in instance ? instance.getModifiedEditor() : instance; + target.revealLineInCenter(line); + const maxColumn = target.getModel()?.getLineMaxColumn(line) ?? 1; + target.setSelection(new Range(line, 1, line, maxColumn)); + }, + })); // 注册 monaco 全局环境(只需执行一次) useEffect(() => { @@ -180,6 +203,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito }); editorInstanceRef.current = edit; editorReadyRef.current = true; + onReadyRef.current?.(); } else { const standaloneEdit = editor.create(container, { language: "javascript", @@ -199,6 +223,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito editorInstanceRef.current = standaloneEdit; editorReadyRef.current = true; onEditorMountRef.current?.(standaloneEdit); + onReadyRef.current?.(); } }); diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index 48b874fd4..aa3413d04 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -7,6 +7,7 @@ vi.mock("./useInstallData", () => ({ useInstallData: vi.fn() })); // Monaco 编辑器无法在 DOM 测试环境中渲染(需 worker + ThemeProvider),用桩替换 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); +import { revealLine } from "@Tests/mocks/CodeEditor"; import { useInstallData, type InstallView } from "./useInstallData"; import App from "./App"; @@ -41,11 +42,13 @@ const readyView = (over: Partial = {}): InstallView => ({ schedule: null, code: "// a\n// b", subscribeScripts: [], + compat: { grants: new Map(), tags: [] }, ...over, }); beforeEach(() => { mockMatchMedia(); + revealLine.calls.length = 0; }); beforeAll(() => initTestLanguage("zh-CN")); @@ -386,3 +389,32 @@ describe("Install App 不再渲染安全警示条", () => { expect(screen.queryByTestId("install-warning-risk")).not.toBeInTheDocument(); }); }); + +describe("安装页的不生效标记", () => { + it("把不受支持的 GM 能力标在权限行上,点击跳到代码对应行", () => { + mockHook.mockReturnValue({ + ...baseHook(), + state: { + status: "ready", + view: readyView({ + permissions: [{ kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }], + code: "// ==UserScript==\n// @name X\n// @grant GM_audio\n// ==/UserScript==", + compat: { grants: new Map([["GM_audio", 3]]), tags: [] }, + }), + }, + }); + render(); + + const chip = screen.getByTestId("compat-chip"); + expect(chip).toHaveTextContent("GM_audio"); + fireEvent.click(chip); + expect(revealLine.calls).toEqual([3]); + }); + + it("没有不生效项时权限区一字不改", () => { + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index b3629ba45..9f446e384 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, RefreshCw, Rss, HardDrive, RotateCcw, PlugZap } from "lucide-react"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; @@ -8,7 +8,7 @@ import { ScriptIdentity } from "./components/ScriptIdentity"; import { PermissionCard } from "./components/PermissionCard"; import { SubscribeScripts } from "./components/SubscribeScripts"; import { SkillInstallView } from "./components/SkillInstallView"; -import { CodePreview } from "./components/CodePreview"; +import { CodePreview, type CodePreviewHandle } from "./components/CodePreview"; import { InstallActions } from "./components/InstallActions"; import { InstallLoading, InstallError, InstallExpired } from "./components/InstallStates"; import { WatchingBanner } from "./components/WatchingBanner"; @@ -52,6 +52,7 @@ export default function App() { retryInstall, } = useInstallData(); const [bgPrompt, setBgPrompt] = useState<{ scriptType: string; permission: PromptPermission } | null>(null); + const codePreviewRef = useRef(null); const installed = outcome.phase === "installed" ? outcome.result : null; const externalAccessFailure = state.status === "ready" && !!state.view.externalAccess; const errorBar = @@ -237,9 +238,16 @@ export default function App() { codePreviewRef.current?.jumpToLine(line) }} /> )} - + { + const code = `// ==UserScript==\n${header}// ==/UserScript==\n\nconsole.log(1);\n`; + const metadata: SCMetadata = {}; + for (const line of header.split("\n")) { + const m = /^\/\/ @(\S+)[ \t]*(.*)$/.exec(line); + if (!m) continue; + (metadata[m[1].toLowerCase()] ||= []).push(m[2].trim()); + } + return { code, metadata }; +}; + +describe("安装页兼容性标记", () => { + it("全部受支持时不产生任何标记", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @grant GM_setValue\n`); + expect(deriveCompatMarks(metadata, code)).toEqual({ grants: new Map(), tags: [] }); + }); + + it("标出脚本猫未实现的 @grant,并给出所在行", () => { + const { code, metadata } = build(`// @name X\n// @grant GM_setValue\n// @grant GM_audio\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.grants).toEqual(new Map([["GM_audio", 4]])); + }); + + it("@grant none 不是能力请求,不标记", () => { + const { code, metadata } = build(`// @name X\n// @grant none\n`); + expect(deriveCompatMarks(metadata, code).grants.size).toBe(0); + }); + + it("标出不生效的元数据指令,@exclude-match 归到运行网站一行", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @exclude-match *://b.com/*\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "exclude-match", group: "match", line: 4 }]); + }); + + it("归不到任何权限类别的指令落在「其他指令」组", () => { + const { code, metadata } = build(`// @name X\n// @sandbox raw\n// @top-level-await\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([ + { tag: "sandbox", group: "other", line: 3 }, + { tag: "top-level-await", group: "other", line: 4 }, + ]); + }); + + it("同一指令写了多行只标一枚,行号取第一次出现处", () => { + const { code, metadata } = build(`// @name X\n// @sandbox a\n// @sandbox b\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "sandbox", group: "other", line: 3 }]); + }); + + it("代码里定位不到时仍然成条,只是没有行号——诊断不能因为缺位置而消失", () => { + const metadata: SCMetadata = { name: ["X"], sandbox: ["raw"], grant: ["GM_audio"] }; + const marks = deriveCompatMarks(metadata, ""); + expect(marks.tags).toEqual([{ tag: "sandbox", group: "other", line: undefined }]); + expect(marks.grants).toEqual(new Map([["GM_audio", undefined]])); + }); + + it("标记顺序跟随代码出现顺序,便于与代码对读", () => { + const { code, metadata } = build(`// @name X\n// @top-level-await\n// @sandbox raw\n`); + expect(deriveCompatMarks(metadata, code).tags.map((t) => t.tag)).toEqual(["top-level-await", "sandbox"]); + }); +}); diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts new file mode 100644 index 000000000..8260f4f07 --- /dev/null +++ b/src/pages/install/compat.ts @@ -0,0 +1,77 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; +import { parseMetadataLines } from "@App/pkg/utils/script"; +import { isSupportedGrant, isSupportedMetadataTag, resolveMetadataTagBase } from "@App/pkg/utils/script_compat"; + +/** 不生效的指令挂到权限卡的哪一行呈现 */ +export type IneffectiveTagGroup = "match" | "other"; + +export interface IneffectiveTag { + /** 小写归一后的指令名,不含 @ */ + tag: string; + group: IneffectiveTagGroup; + line: number | undefined; +} + +export interface CompatMarks { + /** 不生效的 @grant → 所在行号;键与权限卡 GM 能力行的 chip 取值一致,直接按名字打标 */ + grants: Map; + /** 不生效的元数据指令;权限卡里没有对应 chip,按 group 追加呈现 */ + tags: IneffectiveTag[]; +} + +// 不生效的指令归到「它本该影响什么」那一行,读者才能就地判断后果;归不到的落在「其他指令」。 +const TAG_GROUP: Readonly> = { + "exclude-match": "match", + matchaboutblank: "match", +}; + +/** 传给权限行的兼容性标记与跳转入口 */ +export interface CompatView { + marks: CompatMarks; + /** 跳到代码预览的指定行;无预览可跳时不传 */ + onJump?: (line: number) => void; +} + +/** 该权限行要额外呈现的不生效指令(只有 match 组落在既有权限行上,其余归「其他指令」) */ +export const tagsForGroup = (marks: CompatMarks, group: IneffectiveTagGroup): IneffectiveTag[] => + marks.tags.filter((tag) => tag.group === group); + +/** 不生效项总数,用于卡头徽章 */ +export const compatMarkCount = (marks: CompatMarks): number => marks.grants.size + marks.tags.length; + +/** + * 派生安装页的兼容性标记:脚本写了、但脚本猫不会执行的指令与 GM 能力。 + * 判定是二元的(见 script_compat.ts),这里只负责定位与归组,不再分兼容程度。 + */ +export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMarks { + const lines = parseMetadataLines(code); + const firstLineOf = new Map(); + for (const { tag, value, line } of lines) { + const tagKey = `@${tag}`; + if (!firstLineOf.has(tagKey)) firstLineOf.set(tagKey, line); + // @grant 按取值定位,同一指令的不同能力各自成行 + if (tag === "grant") { + const grantKey = `grant:${value}`; + if (!firstLineOf.has(grantKey)) firstLineOf.set(grantKey, line); + } + } + + const grants = new Map(); + for (const grant of metadata.grant || []) { + if (grant === "none" || isSupportedGrant(grant) || grants.has(grant)) continue; + grants.set(grant, firstLineOf.get(`grant:${grant}`)); + } + + const seen = new Set(); + const tags: IneffectiveTag[] = []; + // 以代码出现顺序为准;metadata 是对象,键序不表达脚本里的书写顺序 + const ordered = [...lines.map((l) => l.tag), ...Object.keys(metadata)]; + for (const rawTag of ordered) { + const tag = resolveMetadataTagBase(rawTag); + if (seen.has(tag) || isSupportedMetadataTag(tag)) continue; + seen.add(tag); + tags.push({ tag, group: TAG_GROUP[tag] ?? "other", line: firstLineOf.get(`@${tag}`) }); + } + + return { grants, tags }; +} diff --git a/src/pages/install/components/CodePreview.test.tsx b/src/pages/install/components/CodePreview.test.tsx index 8bf1029d4..1915b5b13 100644 --- a/src/pages/install/components/CodePreview.test.tsx +++ b/src/pages/install/components/CodePreview.test.tsx @@ -1,11 +1,12 @@ +import { createRef, type ComponentRef } from "react"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; // Monaco 无法在 DOM 测试环境中渲染(需 worker),用轻量桩替换,仅暴露 props 供断言接线 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); -import { setEditorMounts } from "@Tests/mocks/CodeEditor"; +import { setEditorMounts, revealLine } from "@Tests/mocks/CodeEditor"; import { CodePreview } from "./CodePreview"; const code = "// line1\nconst a = 1;\nconsole.log(a);"; @@ -79,3 +80,37 @@ describe("CodePreview 编辑器加载期的占位", () => { expect(screen.queryByTestId("code-skeleton")).not.toBeInTheDocument(); }); }); + +describe("CodePreview 跳到指定行", () => { + afterEach(() => { + revealLine.calls.length = 0; + setEditorMounts(true); + }); + + it("跳转把编辑器定位到该行", async () => { + const ref = createRef>(); + render(); + await act(async () => ref.current!.jumpToLine(2)); + expect(revealLine.calls).toEqual([2]); + }); + + it("代码卡折叠时先展开再定位——移动端默认折叠,点了却什么都没发生说不过去", async () => { + const ref = createRef>(); + render(); + expect(screen.queryByTestId("code-body")).not.toBeInTheDocument(); + await act(async () => ref.current!.jumpToLine(3)); + expect(screen.getByTestId("code-body")).toBeInTheDocument(); + expect(revealLine.calls).toEqual([3]); + }); + + it("编辑器还没就绪时把定位排队,就绪后补上", async () => { + setEditorMounts(false); + const ref = createRef>(); + const { rerender } = render(); + await act(async () => ref.current!.jumpToLine(2)); + expect(revealLine.calls).toEqual([]); + setEditorMounts(true); + await act(async () => rerender()); + expect(revealLine.calls).toEqual([2]); + }); +}); diff --git a/src/pages/install/components/CodePreview.tsx b/src/pages/install/components/CodePreview.tsx index ad69f74ad..05e0fde20 100644 --- a/src/pages/install/components/CodePreview.tsx +++ b/src/pages/install/components/CodePreview.tsx @@ -1,7 +1,7 @@ -import { useMemo, useState } from "react"; +import { useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { CodeXml, Copy, Check, ChevronDown, ChevronRight } from "lucide-react"; -import CodeEditor from "@App/pages/components/CodeEditor"; +import CodeEditor, { type CodeEditorHandle } from "@App/pages/components/CodeEditor"; import { Skeleton } from "@App/pages/components/ui/skeleton"; import { cn } from "@App/pkg/utils/cn"; @@ -16,7 +16,13 @@ const CODE_SKELETON_LINES = [ "ml-4 w-[62%]", ]; +export interface CodePreviewHandle { + /** 展开代码卡并滚动到指定行 */ + jumpToLine: (line: number) => void; +} + export interface CodePreviewProps { + ref?: Ref; code: string; /** 更新态的旧版本代码;与 code 不同则触发内联 diff,全新安装为 undefined */ oldCode?: string; @@ -26,6 +32,7 @@ export interface CodePreviewProps { } export function CodePreview({ + ref, code, oldCode, language = "JavaScript", @@ -36,6 +43,33 @@ export function CodePreview({ const [collapsed, setCollapsed] = useState(defaultCollapsed); const [copied, setCopied] = useState(false); const [editorReady, setEditorReady] = useState(false); + const editorRef = useRef(null); + const sectionRef = useRef(null); + // 折叠态下编辑器实例尚未创建,跳转请求先排队,等 onReady 再补上定位 + const pendingLineRef = useRef(null); + + const revealLine = (line: number) => { + if (editorRef.current) editorRef.current.revealLine(line); + else pendingLineRef.current = line; + }; + + useImperativeHandle(ref, () => ({ + jumpToLine: (line: number) => { + setCollapsed(false); + sectionRef.current?.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); + if (editorReady) revealLine(line); + else pendingLineRef.current = line; + }, + })); + + const handleReady = () => { + setEditorReady(true); + const pending = pendingLineRef.current; + if (pending !== null) { + pendingLineRef.current = null; + revealLine(pending); + } + }; const lineCount = useMemo(() => code.split("\n").length, [code]); // diffCode 语义:""=无 diff(普通只读预览),有值=内联 diff;切勿传 undefined(表示不加载) @@ -48,7 +82,7 @@ export function CodePreview({ }; return ( -
+
{t("editor:code")} @@ -99,11 +133,12 @@ export function CodePreview({
)} setEditorReady(true)} + onReady={handleReady} className="h-full w-full" /> diff --git a/src/pages/install/components/CompatChip.test.tsx b/src/pages/install/components/CompatChip.test.tsx new file mode 100644 index 000000000..6da8e8b30 --- /dev/null +++ b/src/pages/install/components/CompatChip.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { CompatChip } from "./CompatChip"; + +beforeAll(() => initTestLanguage("zh-CN")); +afterEach(cleanup); + +describe("CompatChip 不生效标记", () => { + it("渲染取值本身,并带上不生效的可读标注——含义不只靠颜色传达", () => { + render(); + const chip = screen.getByTestId("compat-chip"); + expect(chip).toHaveTextContent("GM_audio"); + expect(chip).toHaveAccessibleName(expect.stringContaining("不生效")); + }); + + it("鼠标移入弹出说明:GM 能力说清调用会报错", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。")).toBeInTheDocument(); + }); + + it("鼠标移入弹出说明:元数据指令说清会被忽略", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫不支持该声明,安装后会被忽略。")).toBeInTheDocument(); + }); + + it("移出后收起说明", async () => { + vi.useFakeTimers(); + try { + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + fireEvent.mouseLeave(chip); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.queryByTestId("compat-popover")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("鼠标从 chip 移到浮层上不会关闭——否则浮层里的文档链接永远点不到", async () => { + vi.useFakeTimers(); + try { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + const popover = screen.getByTestId("compat-popover"); + fireEvent.mouseEnter(popover); + fireEvent.mouseLeave(screen.getByTestId("compat-chip")); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + expect(screen.getByTestId("compat-docs")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("键盘聚焦同样弹出说明——没有鼠标也拿得到这段信息", () => { + render(); + fireEvent.focus(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + }); + + it("点击跳到该指令所在行", () => { + const onJump = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("compat-chip")); + expect(onJump).toHaveBeenCalledWith(7); + }); + + it("浮层给出行号,让用户知道点下去会去哪", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("跳到第 7 行")).toBeInTheDocument(); + }); + + it("代码里定位不到时仍然成条,只是不可跳转", () => { + const onJump = vi.fn(); + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.click(chip); + expect(onJump).not.toHaveBeenCalled(); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + expect(screen.queryByText(/跳到第/)).not.toBeInTheDocument(); + }); + + it("浮层提供兼容性文档链接,元数据与 GM 能力各自指向对应文档页", () => { + const { unmount } = render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/meta")); + unmount(); + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/api")); + }); +}); + +describe("多枚标记之间的浮层互斥", () => { + it("悬停另一枚 chip 时上一枚的浮层立刻收起——两枚浮层会互相盖住", () => { + render( + <> + + + + ); + const [first, second] = screen.getAllByTestId("compat-chip"); + fireEvent.mouseEnter(first); + expect(screen.getAllByTestId("compat-popover")).toHaveLength(1); + fireEvent.mouseEnter(second); + const open = screen.getAllByTestId("compat-popover"); + expect(open).toHaveLength(1); + expect(open[0]).toHaveTextContent("GM_audio"); + }); +}); diff --git a/src/pages/install/components/CompatChip.tsx b/src/pages/install/components/CompatChip.tsx new file mode 100644 index 000000000..40fba35ef --- /dev/null +++ b/src/pages/install/components/CompatChip.tsx @@ -0,0 +1,106 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Ban, ExternalLink } from "lucide-react"; +import { DocumentationSite } from "@App/app/const"; +import { localePath } from "@App/locales/locales"; +import { Popover, PopoverAnchor, PopoverContent } from "@App/pages/components/ui/popover"; +import { useHoverMenu } from "@App/pages/components/ui/use-hover-menu"; + +export type CompatChipKind = "metadata" | "grant"; + +// 同一时刻只开一枚浮层:标记 chip 常常并排,浮层有 288px 宽,两枚同时开会互相盖住。 +// 悬停切换时旧浮层要等关闭延迟才收,靠这里立刻收掉。 +let closeActivePopover: (() => void) | null = null; + +const DOC_PATH: Record = { + metadata: "/docs/dev/meta", + grant: "/docs/dev/api", +}; + +const DESC_KEY: Record = { + metadata: "install:compat_tag_desc", + grant: "install:compat_grant_desc", +}; + +/** + * 写了但不会生效的指令 / GM 能力。就近标在它所属的权限行上,不单独成卡。 + * 说明走浮层(hover 与键盘聚焦都能开),点击本体跳到代码对应行—— + * 点击恒为跳转,不做浮层开关,否则「点一下跳过去」这个主动作会被折叠状态吃掉。 + */ +export function CompatChip({ + label, + kind, + line, + onJump, +}: { + label: string; + kind: CompatChipKind; + line?: number; + onJump?: (line: number) => void; +}) { + const { t } = useTranslation(["install", "common"]); + // 浮层里有文档链接,必须把浮层本体也纳入悬停范围:只盯 chip 的话鼠标一移向链接浮层就关了 + const { rootProps, hoverProps, contentProps, close } = useHoverMenu(150); + const canJump = line !== undefined && !!onJump; + // close 来自 useHoverMenu 的 useCallback([]),恒定,故清理只在卸载时发生 + useEffect( + () => () => { + if (closeActivePopover === close) closeActivePopover = null; + }, + [close] + ); + + const openPopover = () => { + if (closeActivePopover && closeActivePopover !== close) closeActivePopover(); + closeActivePopover = close; + hoverProps.onMouseEnter(); + }; + + return ( + + + + + +

+

+

{t(DESC_KEY[kind])}

+
+ {canJump && ( + + {t("install:compat_jump", { line })} + + )} + + {t("install:compat_docs")} + +
+
+
+ ); +} diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index 605c48c61..8dde86bd2 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -198,3 +198,78 @@ describe("PermissionCard 更新零变化态", () => { expect(screen.queryByText("api.a.com")).not.toBeInTheDocument(); }); }); + +describe("PermissionCard 上的不生效标记", () => { + const rows: PermissionRow[] = [ + { kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }, + { kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }, + ]; + + it("卡头给出不生效项总数,权限与 GM 能力合并计数", () => { + render( + + ); + expect(screen.getByText("2 项不生效")).toBeInTheDocument(); + }); + + it("归不到任何权限类别的指令单独成行,只在有内容时出现", () => { + render( + + ); + const row = screen.getByTestId("permission-row-other"); + expect(row).toHaveTextContent("其他声明"); + expect(within(row).getByTestId("compat-chip")).toHaveTextContent("@sandbox"); + }); + + it("没有不生效项时既无徽章也无其他声明行——全兼容的安装页一字不改", () => { + render(); + expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); + expect(screen.queryByText(/项不生效/)).not.toBeInTheDocument(); + }); +}); + +describe("不生效标记与折叠形态的关系", () => { + const rows: PermissionRow[] = [ + { kind: "grant", risk: "warn", values: ["GM_audio"], sensitive: [], diff: { added: [], removed: [] } }, + ]; + + it("权限一项没变但有不生效项时整卡不塌——塌了这些标记就没人看得见", () => { + render( + + ); + expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + }); + + it("没有不生效项时仍按原样塌成单行", () => { + render(); + expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); + }); +}); + +describe("移动端的不生效标记", () => { + it("有不生效项的类别默认展开,否则标记藏在折叠面板里等于没做", () => { + mobile = true; + render( + + ); + mobile = false; + expect(screen.getByTestId("compat-chip")).toBeVisible(); + }); +}); diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index c989d5be9..836573930 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -1,19 +1,24 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, ShieldCheck } from "lucide-react"; +import { ChevronDown, FileCode2, ShieldCheck } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; +import { compatMarkCount, tagsForGroup, type CompatView } from "../compat"; +import { CompatChip } from "./CompatChip"; import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; import { PermissionRow, PermissionChips, PermissionDelta, NoChangeTag, KIND_META, RISK_STYLE } from "./PermissionRow"; -function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { +function MobilePermissions({ rows, compat }: { rows: PermissionRowData[]; compat?: CompatView }) { const { t } = useTranslation(["install", "common"]); // 有变动时默认只展开有变动的类别;全新安装、以及用户主动点开的零变化整卡都退回只展开高风险项, // 否则零变化整卡展开后每一类都是收起的,「点开即得到全量清单」在移动端会落空。 const hasChanged = rows.some(isPermissionChanged); + const isMarked = (row: PermissionRowData) => + (row.kind === "grant" && row.values.some((v) => compat?.marks.grants.has(v))) || + (row.kind === "match" && tagsForGroup(compat?.marks ?? { grants: new Map(), tags: [] }, "match").length > 0); const defaultValue = rows - .filter((r) => (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) + .filter((r) => isMarked(r) || (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) .map((r) => r.kind); return ( @@ -37,7 +42,7 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { - + ); @@ -47,13 +52,13 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { } /** 未变动类别的单行形态:名称、计数与「无变化」,点开即还原成完整权限行 */ -function CollapsedRow({ row }: { row: PermissionRowData }) { +function CollapsedRow({ row, compat }: { row: PermissionRowData; compat?: CompatView }) { const { t } = useTranslation(["install", "common"]); const [open, setOpen] = useState(false); const { icon: Icon, labelKey } = KIND_META[row.kind]; const style = RISK_STYLE[row.risk]; - if (open) return ; + if (open) return ; return (
); diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 36b11c75a..63b61032e 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { render, screen, cleanup, within, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; +import type { IneffectiveTag } from "../compat"; import { PermissionRow } from "./PermissionRow"; beforeAll(() => initTestLanguage("zh-CN")); @@ -149,3 +150,59 @@ describe("PermissionRow 零变动行的取值折叠", () => { expect(within(row).queryByTestId("permission-more")).not.toBeInTheDocument(); }); }); + +describe("PermissionRow 上的不生效标记", () => { + const compat = (over: Partial<{ grants: Map; tags: IneffectiveTag[] }> = {}) => ({ + marks: { grants: new Map(), tags: [], ...over }, + }); + + it("不受支持的 GM 能力就地换成不生效标记,其余 chip 不变", () => { + render( + + ); + const marks = screen.getAllByTestId("compat-chip"); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent("GM_audio"); + expect(screen.getByText("GM_setValue")).toBeInTheDocument(); + expect(screen.getByText("GM_setValue").closest('[data-testid="compat-chip"]')).toBeNull(); + }); + + it("不生效的匹配类指令追加到运行网站行——它本该影响的就是这一行", () => { + render( + + ); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("@exclude-match"); + }); + + it("其他类别的行不会被别的组的标记污染", () => { + render( + + ); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + }); + + it("更新态里被移除的能力不标记——它已经不在新版本里了", () => { + render( + + ); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index c83d703fe..db79fcbec 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; +import { tagsForGroup, type CompatView } from "../compat"; +import { CompatChip } from "./CompatChip"; import { isPermissionChanged, type PermissionKind, @@ -101,22 +103,38 @@ function MoreButton({ label, onClick }: { label: string; onClick: () => void }) export function PermissionChips({ row, maxVisible = DEFAULT_MAX_VISIBLE, + compat, }: { row: PermissionRowData; maxVisible?: number; + compat?: CompatView; }) { const { t } = useTranslation(["install", "common"]); const [expanded, setExpanded] = useState(false); + // 不受支持的 @grant 就地换成不生效标记;已被移除的取值不标——它已经不在新版本里了 + const renderChip = (value: string, change?: ChangeState) => { + const line = row.kind === "grant" && change !== "removed" ? compat?.marks.grants.get(value) : undefined; + if (row.kind === "grant" && change !== "removed" && compat?.marks.grants.has(value)) { + return ; + } + return ; + }; + + // 权限卡里没有对应 chip 的不生效指令,追加到它本该影响的这一行 + const appended = compat && row.kind === "match" ? tagsForGroup(compat.marks, "match") : []; + const appendedChips = appended.map((tag) => ( + + )); + if (!row.diff) { const visible = expanded ? row.values : row.values.slice(0, maxVisible); const hidden = row.values.length - visible.length; return (
- {visible.map((v) => ( - - ))} + {visible.map((v) => renderChip(v))} {hidden > 0 && setExpanded(true)} />} + {appendedChips}
); } @@ -132,21 +150,16 @@ export function PermissionChips({ return (
- {added.map((v) => ( - - ))} - {removed.map((v) => ( - - ))} - {visibleUnchanged.map((v) => ( - - ))} + {added.map((v) => renderChip(v, "added"))} + {removed.map((v) => renderChip(v, "removed"))} + {visibleUnchanged.map((v) => renderChip(v, "unchanged"))} {hidden > 0 && ( 0 ? t("install:perm_unchanged_more", { count: hidden }) : `+${hidden}`} onClick={() => setExpanded(true)} /> )} + {appendedChips}
); } @@ -173,7 +186,15 @@ export function PermissionDelta({ row }: { row: PermissionRowData }) { ); } -export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; maxVisible?: number }) { +export function PermissionRow({ + row, + maxVisible, + compat, +}: { + row: PermissionRowData; + maxVisible?: number; + compat?: CompatView; +}) { const { t } = useTranslation(["install", "common"]); const { icon: Icon, labelKey, summaryKey } = KIND_META[row.kind]; const style = RISK_STYLE[row.risk]; @@ -191,7 +212,7 @@ export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; max {row.diff && !isPermissionChanged(row) && } {t(summaryKey)} - + ); diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index caee21fd2..663255b4f 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -117,6 +117,24 @@ describe("assembleInstallView 组装安装视图", () => { expect(assembleInstallView(base).inTrash).toBe(false); }); + it("组装时派生不生效标记,行号取自待安装代码", () => { + const metadata = { name: ["示例脚本"], version: ["2.3.1"], "exclude-match": ["*://a.com/*"], grant: ["GM_audio"] }; + const code = `// ==UserScript== +// @name 示例脚本 +// @exclude-match *://a.com/* +// @grant GM_audio +// ==/UserScript==`; + const view = assembleInstallView({ + isUpdate: false, + scriptInfo: makeScriptInfo(metadata), + action: makeAction(metadata), + code, + oldVersion: null, + }); + expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); + expect(view.compat.tags).toEqual([{ tag: "exclude-match", group: "match", line: 3 }]); + }); + it("全新安装组装名称、来源、版本与权限", () => { const metadata = { name: ["示例脚本"], diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index 64683a847..9c6eeaee5 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -29,6 +29,7 @@ import { type ScheduleInfo, type DiffStat, } from "./model"; +import { deriveCompatMarks, type CompatMarks } from "./compat"; export interface InstallView { isUpdate: boolean; @@ -54,6 +55,8 @@ export interface InstallView { subscribeScripts: string[]; /** 由 MCP 客户端请求安装时附加;非 MCP 来源为 undefined */ externalAccess?: ScriptInfo["externalAccess"]; + /** 写了但脚本猫不会执行的指令与 GM 能力,就近标在权限行上 */ + compat: CompatMarks; } /** @@ -93,6 +96,7 @@ export function assembleInstallView(args: { diffStat: oldCode !== undefined && oldCode !== code ? deriveDiffStat(oldCode, code) : undefined, subscribeScripts: scriptInfo.userSubscribe ? metadata.scripturl || [] : [], externalAccess: scriptInfo.externalAccess, + compat: deriveCompatMarks(metadata, code), }; } diff --git a/src/pkg/utils/monaco-editor/index.ts b/src/pkg/utils/monaco-editor/index.ts index 90ce5a151..d3d2a5b25 100644 --- a/src/pkg/utils/monaco-editor/index.ts +++ b/src/pkg/utils/monaco-editor/index.ts @@ -13,11 +13,11 @@ import { getUndefinedMetadataTagMatches, isMetadataAlignmentBlockAligned, metadataHoverPattern, - resolveMetadataTagBase, type MetadataAlignmentBlock, type MetadataAlignmentLine, type MetadataBlockRange, } from "./metadata"; +import { resolveMetadataTagBase, SUPPORTED_METADATA_TAGS } from "@App/pkg/utils/script_compat"; interface ILinterWorker extends Worker { myLinterHook: EventEmitter; @@ -59,14 +59,12 @@ const configuredLanguagePromise = systemConfig.getLanguage(); let currentEditorLang: EditorLangEntry; type EditorLangEntryPrompt = typeof currentEditorLang.prompt; let promptByMetadataTag: EditorLangEntryPrompt; -let knownMetadataTagSet: ReadonlySet; const loadEditorLangEntry = (languageCode: EditorLangCode) => { currentEditorLang = asEditorLangEntry(languageCode); promptByMetadataTag = Object.fromEntries( Object.entries(currentEditorLang.prompt).map(([metadataTag, prompt]) => [metadataTag.toLowerCase(), prompt]) ) as typeof currentEditorLang.prompt; - knownMetadataTagSet = new Set(Object.keys(promptByMetadataTag)); }; loadEditorLangEntry("en-US"); @@ -660,7 +658,7 @@ const getUndefinedMetadataTagMarkers = ( model: editor.ITextModel, blocks: MetadataAlignmentBlock[] ): editor.IMarkerData[] => - getUndefinedMetadataTagMatches(model, blocks, knownMetadataTagSet).map((match) => ({ + getUndefinedMetadataTagMatches(model, blocks, SUPPORTED_METADATA_TAGS).map((match) => ({ severity: MarkerSeverity.Warning, message: currentEditorLang.undefinedPrompt, source: scriptcatMarkerOwner, diff --git a/src/pkg/utils/monaco-editor/metadata.test.ts b/src/pkg/utils/monaco-editor/metadata.test.ts index 0d49b13d2..704706819 100644 --- a/src/pkg/utils/monaco-editor/metadata.test.ts +++ b/src/pkg/utils/monaco-editor/metadata.test.ts @@ -5,7 +5,6 @@ import { getMetadataAlignmentBlocks, getUndefinedMetadataTagMatches, isKnownMetadataTag, - resolveMetadataTagBase, } from "./metadata"; // 与 utils.test.ts 相同风格的简单 Monaco Editor 模型 mock @@ -60,17 +59,7 @@ describe("getMetadataAlignmentBlocks", () => { }); }); -describe("resolveMetadataTagBase / isKnownMetadataTag", () => { - it("本地化标签(name:)应解析为对应的基础标签", () => { - expect(resolveMetadataTagBase("name:zh-CN")).toBe("name"); - expect(resolveMetadataTagBase("description:en")).toBe("description"); - }); - - it("非本地化标签保持原样(小写化)", () => { - expect(resolveMetadataTagBase("Grant")).toBe("grant"); - expect(resolveMetadataTagBase("run-at")).toBe("run-at"); - }); - +describe("isKnownMetadataTag", () => { it("已知的本地化标签应视为已定义", () => { expect(isKnownMetadataTag("name:zh-CN", knownTags)).toBe(true); expect(isKnownMetadataTag("description:ja", knownTags)).toBe(true); diff --git a/src/pkg/utils/monaco-editor/metadata.ts b/src/pkg/utils/monaco-editor/metadata.ts index 0a248232e..a81621e8a 100644 --- a/src/pkg/utils/monaco-editor/metadata.ts +++ b/src/pkg/utils/monaco-editor/metadata.ts @@ -1,4 +1,5 @@ import type { editor } from "monaco-editor"; +import { resolveMetadataTagBase } from "@App/pkg/utils/script_compat"; export type MetadataAlignmentLine = { lineNumber: number; @@ -32,9 +33,6 @@ export const metadataLineStartPattern = /^\s*\/\/[ \t]*@/; export const userscriptHeaderPattern = /^\s*\/\/[ \t]*==UserScript==[ \t]*$/; export const userscriptEndPattern = /^\s*\/\/[ \t]*==\/UserScript==[ \t]*$/; const metadataAlignmentPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]+)(.*)$/; -// ScriptCat 运行时消费 `name:`/`description:` (src/locales/locales.ts) 等本地化标签, -// 它们是合法标签而非拼写错误,需与其余标签区分开来单独判断。 -const localeSuffixedMetadataTagPattern = /^(name|description):(.+)$/; export const getMetadataAlignmentLine = (lineNumber: number, lineText: string): MetadataAlignmentLine | null => { const match = metadataAlignmentPattern.exec(lineText); @@ -94,12 +92,6 @@ export const isMetadataAlignmentBlockAligned = (block: MetadataAlignmentBlock) = return block.lines.every((line) => line.valueColumn === firstValueColumn); }; -export const resolveMetadataTagBase = (tag: string): string => { - const normalizedTag = tag.toLowerCase(); - const localeMatch = localeSuffixedMetadataTagPattern.exec(normalizedTag); - return localeMatch ? localeMatch[1] : normalizedTag; -}; - export const isKnownMetadataTag = (tag: string, knownTags: ReadonlySet): boolean => knownTags.has(resolveMetadataTagBase(tag)); diff --git a/src/pkg/utils/script.test.ts b/src/pkg/utils/script.test.ts index 2c1cf044b..48acb1c1e 100644 --- a/src/pkg/utils/script.test.ts +++ b/src/pkg/utils/script.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { parseMetadata, parseScriptFromCode, fetchScriptBody, prepareScriptByCode } from "./script"; +import { parseMetadata, parseMetadataLines, parseScriptFromCode, fetchScriptBody, prepareScriptByCode } from "./script"; import { getMetadataStr, getUserConfigStr } from "./utils"; import { parseUserConfig } from "./yaml"; import { @@ -724,6 +724,65 @@ console.log('Hello World'); }); }); +describe.concurrent("parseMetadataLines", () => { + const code = `// 开头的普通注释 +// ==UserScript== +// @name 示例 +// @namespace https://example.com +// @match *://example.com/* +// @exclude-match *://live.example.com/* +// @grant GM_setValue +// ==/UserScript== + +console.log(1); +`; + + it("逐条给出指令名、取值与 1 起算的全文行号", () => { + expect(parseMetadataLines(code)).toEqual([ + { tag: "name", value: "示例", line: 3 }, + { tag: "namespace", value: "https://example.com", line: 4 }, + { tag: "match", value: "*://example.com/*", line: 5 }, + { tag: "exclude-match", value: "*://live.example.com/*", line: 6 }, + { tag: "grant", value: "GM_setValue", line: 7 }, + ]); + }); + + it("指令名小写归一,与 parseMetadata 的取键一致", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @Name X +// @MATCH *://a.com/* +// ==/UserScript==`); + expect(lines.map((l) => l.tag)).toEqual(["name", "match"]); + }); + + it("同名指令重复出现时逐条保留,不合并", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @name X +// @match *://a.com/* +// @match *://b.com/* +// ==/UserScript==`); + expect(lines.filter((l) => l.tag === "match").map((l) => l.line)).toEqual([3, 4]); + }); + + it("没有元数据区块时返回空列表", () => { + expect(parseMetadataLines("console.log(1);")).toEqual([]); + }); + + it("只认第一个闭合区块——与 parseMetadata 的 HEADER_BLOCK 语义一致", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @name X +// ==/UserScript== +// ==UserScript== +// @name Y +// ==/UserScript==`); + expect(lines).toEqual([{ tag: "name", value: "X", line: 2 }]); + }); + + it("区块未闭合时不产出任何指令——与 parseMetadata 一致", () => { + expect(parseMetadataLines(`// ==UserScript==\n// @name X\n`)).toEqual([]); + }); +}); + describe.concurrent("getMetadataStr", () => { it.concurrent("提取UserScript元数据字符串", () => { const code = ` diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index 8473cedeb..8af95fe9f 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -21,6 +21,46 @@ import { readRawContent } from "@App/pkg/utils/encoding"; const HEADER_BLOCK = /\/\/[ \t]*==User(Script|Subscribe)==([\s\S]+?)\/\/[ \t]*==\/User\1==/m; const META_LINE = /\/\/[ \t]*@(\S+)[ \t]*(.*)$/gm; +export interface MetadataLine { + /** 小写归一后的指令名,与 parseMetadata 的取键一致 */ + tag: string; + value: string; + /** 1 起算的全文行号 */ + line: number; +} + +const HEADER_OPEN = /^\/\/[ \t]*==User(?:Script|Subscribe)==/; + +/** + * 带位置的元数据解析:与 parseMetadata 共用 HEADER_BLOCK / META_LINE, + * 保证诊断看到的指令集合与运行时实际解析出的完全一致(否则会对着一条运行时根本没读到的行报警)。 + * parseMetadata 按指令名聚合取值、丢弃位置,这里逐条保留顺序与行号。 + */ +export function parseMetadataLines(code: string): MetadataLine[] { + const block = HEADER_BLOCK.exec(code); + if (!block) return []; + const headerContent = block[2]; + const open = HEADER_OPEN.exec(block[0]); + if (!open) return []; + const headerStart = block.index + open[0].length; + + const lines: MetadataLine[] = []; + // META_LINE 的匹配按位置递增,逐段累计换行数即可,无需为每条指令从头数 + let scanned = 0; + let line = 1; + let m: RegExpExecArray | null; + META_LINE.lastIndex = 0; + while ((m = META_LINE.exec(headerContent)) !== null) { + const absolute = headerStart + m.index; + for (let i = scanned; i < absolute; i++) { + if (code.charCodeAt(i) === 10) line += 1; + } + scanned = absolute; + lines.push({ tag: m[1].toLowerCase(), value: m[2]?.trim() ?? "", line }); + } + return lines; +} + // 从脚本代码抽出Metadata export function parseMetadata(code: string): SCMetadata | null { let isSubscribe = false; diff --git a/src/pkg/utils/script_compat.test.ts b/src/pkg/utils/script_compat.test.ts new file mode 100644 index 000000000..cef91decb --- /dev/null +++ b/src/pkg/utils/script_compat.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import "@App/app/service/content/gm_api/gm_api"; +import { GMContextApiNames } from "@App/app/service/content/gm_api/gm_context"; +import { compatMap as eslintHeaderCompatMap } from "@Packages/eslint/compat-headers"; +import { + CONTEXT_PROVIDED_GRANTS, + SUPPORTED_GRANTS, + SUPPORTED_METADATA_TAGS, + SYNTHETIC_METADATA_TAGS, + isSupportedGrant, + isSupportedMetadataTag, + resolveMetadataTagBase, +} from "./script_compat"; + +describe("元数据指令支持判定", () => { + it("运行时会消费的指令视为支持", () => { + for (const tag of ["match", "include", "exclude", "grant", "run-at", "run-in", "noframes", "connect"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("脚本猫独有的指令视为支持", () => { + for (const tag of ["crontab", "background", "early-start", "require-css", "storagename", "cloudcat"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("别家管理器会执行、脚本猫未实现的指令视为不支持", () => { + for (const tag of ["exclude-match", "top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(false); + } + }); + + it("脚本站与著作信息类指令不报不支持——脚本猫不执行它们,但也不影响脚本行为", () => { + for (const tag of ["license", "compatible", "contributionURL", "uso:script", "oujs:author", "screenshot"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("指令名大小写不敏感", () => { + expect(isSupportedMetadataTag("MATCH")).toBe(true); + expect(isSupportedMetadataTag("Run-At")).toBe(true); + expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(false); + }); + + it("name/description 的语言后缀归到同一指令", () => { + expect(isSupportedMetadataTag("name:zh-CN")).toBe(true); + expect(isSupportedMetadataTag("description:ja")).toBe(true); + expect(resolveMetadataTagBase("Name:zh-CN")).toBe("name"); + // 只有 name/description 有语言后缀,其余指令的冒号后缀不参与归一 + expect(resolveMetadataTagBase("uso:script")).toBe("uso:script"); + }); + + it("拼写错误等未知指令视为不支持", () => { + expect(isSupportedMetadataTag("mathc")).toBe(false); + expect(isSupportedMetadataTag("matchAboutBlank")).toBe(false); + }); +}); + +describe("GM 能力支持判定", () => { + it("注册表中的 GM API 视为支持", () => { + for (const grant of ["GM_setValue", "GM.setValue", "CAT_fileStorage", "CAT.agent.dom", "window.close"]) { + expect(isSupportedGrant(grant), grant).toBe(true); + } + }); + + it("GM_ 与 GM. 前缀互认——与运行时 getGrantCandidates 同一套规则", () => { + // 注册表只登记了 GM_xmlhttpRequest / GM.xmlHttpRequest 两种写法,交叉写法靠候选规则兜住 + expect(isSupportedGrant("GM.deleteValues")).toBe(true); + expect(isSupportedGrant("GM_deleteValues")).toBe(true); + }); + + it("不经注册表、由上下文直接提供的能力也视为支持", () => { + for (const grant of ["unsafeWindow", "window.onurlchange", "GM_info", "none"]) { + expect(isSupportedGrant(grant), grant).toBe(true); + } + }); + + it("脚本猫未实现的 GM API 视为不支持", () => { + for (const grant of ["GM_audio", "GM_webRequest", "GM_addScript", "GM_createObjectURL"]) { + expect(isSupportedGrant(grant), grant).toBe(false); + } + }); +}); + +describe("支持表与运行时注册表的一致性", () => { + it("注册表里的每个 @grant 都在支持表内——新增 GM API 漏进表会在此转红", () => { + const missing = GMContextApiNames().filter((name) => !SUPPORTED_GRANTS.has(name)); + expect(missing).toEqual([]); + }); + + it("支持表不含注册表之外的名字——上下文直接提供的能力单独登记,便于审阅", () => { + const registered = new Set(GMContextApiNames()); + const extra = [...SUPPORTED_GRANTS].filter((name) => !registered.has(name) && !CONTEXT_PROVIDED_GRANTS.has(name)); + expect(extra).toEqual([]); + }); +}); + +describe("支持表与编辑器 ESLint 合法 header 集合的一致性", () => { + it("支持表里的指令,编辑器都应认为是合法 header——否则用户会在编辑器里收到「不是合法 header」的误报", () => { + // no-invalid-headers 按原样比对 header 名,而脚本猫运行时把指令名小写化,故按小写比对 + const validHeaders = new Set( + [ + ...Object.keys(eslintHeaderCompatMap.unlocalized), + ...Object.keys(eslintHeaderCompatMap.nonFunctional), + ...Object.keys(eslintHeaderCompatMap.localized), + ].map((key) => key.toLowerCase()) + ); + const missing = [...SUPPORTED_METADATA_TAGS].filter( + (tag) => !SYNTHETIC_METADATA_TAGS.has(tag) && !validHeaders.has(tag) + ); + expect(missing).toEqual([]); + }); +}); diff --git a/src/pkg/utils/script_compat.ts b/src/pkg/utils/script_compat.ts new file mode 100644 index 000000000..2a8ec950b --- /dev/null +++ b/src/pkg/utils/script_compat.ts @@ -0,0 +1,208 @@ +import { getGrantCandidates } from "@App/app/service/content/gm_api/grant"; + +/** + * 脚本猫的兼容性支持表:安装页与编辑器共用的唯一判定来源。 + * + * 判定是二元的——指令/能力要么被脚本猫消费,要么写了也不会生效,没有中间档。 + * 表外即「不生效」,因此收录标准是「脚本猫会消费它」或「脚本猫不消费但它也不改变脚本运行行为」; + * 只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻意留在表外(如 @exclude-match)。 + */ + +// name/description/antifeature 可带 `:` 后缀取本地化值(src/locales/locales.ts), +// 是同一条指令的语言变体而非独立指令。其余指令的冒号(uso:script 等)是名字的一部分。 +const LOCALE_SUFFIXED_TAG = /^(name|description|antifeature):(.+)$/; + +/** 归一到判定用的指令名:小写,并去掉本地化后缀 */ +export const resolveMetadataTagBase = (tag: string): string => { + const normalized = tag.toLowerCase(); + const localeMatch = LOCALE_SUFFIXED_TAG.exec(normalized); + return localeMatch ? localeMatch[1] : normalized; +}; + +// 脚本猫会读取的指令:注入与匹配链路、脚本类型判定、云端/订阅、以及列表与安装页的展示字段。 +const CONSUMED_TAGS = [ + "name", + "namespace", + "version", + "description", + "author", + "match", + "include", + "exclude", + "connect", + "grant", + "require", + "require-css", + "resource", + "run-at", + "run-in", + "noframes", + "inject-into", + "unwrap", + "early-start", + "background", + "crontab", + "antifeature", + "tag", + "storagename", + "cloudcat", + "cloudserver", + "exportvalue", + "exportcookie", + "scripturl", + "usersubscribe", + "updateurl", + "downloadurl", + "icon", + "iconurl", + "icon64", + "icon64url", + "defaulticon", +]; + +// 脚本猫不消费,但也不改变脚本运行行为的指令:著作信息、脚本站元数据、构建提示。 +// 它们出现在脚本里是正常的,标成「不生效」只会制造噪音。 +// definition 是脚本猫自己文档化的编辑器指令,当前没有任何消费方;在它被实现或从文档撤下之前 +// 按信息类处理,避免安装页对着脚本猫自己的文档报警。 +const INFORMATIONAL_TAGS = [ + "license", + "copyright", + "homepage", + "homepageurl", + "website", + "source", + "supporturl", + "installurl", + "compatible", + "definition", + "contributor", + "contributors", + "collaborator", + "creator", + "developer", + "contributionurl", + "contributionamount", + "screenshot", + "history", + "id", + "major", + "minor", + "build", + "unstableminify", + "oujs:author", + "oujs:collaborator", + "uso:script", + "uso:version", + "uso:timestamp", + "uso:hash", + "uso:rating", + "uso:installs", + "uso:reviews", + "uso:discussions", + "uso:fans", + "uso:unlisted", +]; + +// 不是作者写出来的指令,而是 parseMetadata 为 .user.sub.js 合成的标记; +// 订阅脚本的 metadata 里会出现这个键,必须视为支持,否则安装页会把它标成不生效。 +export const SYNTHETIC_METADATA_TAGS: ReadonlySet = new Set(["usersubscribe"]); + +export const SUPPORTED_METADATA_TAGS: ReadonlySet = new Set([...CONSUMED_TAGS, ...INFORMATIONAL_TAGS]); + +export const isSupportedMetadataTag = (tag: string): boolean => + SUPPORTED_METADATA_TAGS.has(resolveMetadataTagBase(tag)); + +// 不经 GMContext 注册表、由沙盒上下文直接提供或无需授权的能力: +// unsafeWindow 与 GM_info 恒定注入(src/app/service/content/create_context.ts、exec_script.ts), +// window.onurlchange 在 createContext 里单独接管,none 表示不请求任何 GM 能力。 +export const CONTEXT_PROVIDED_GRANTS: ReadonlySet = new Set([ + "none", + "unsafeWindow", + "GM_info", + "GM.info", + "window.onurlchange", +]); + +// GMContext 注册表在 src/app/service/content/gm_api/ 由装饰器填充,安装页不能为了查一次支持性 +// 把整套 GM 实现拉进包里,因此在此静态镜像一份;script_compat.test.ts 守卫两者一致。 +const REGISTERED_GRANTS = [ + "CAT.agent.conversation", + "CAT.agent.dom", + "CAT.agent.model", + "CAT.agent.opfs", + "CAT.agent.skills", + "CAT.agent.task", + "CAT_createBlobUrl", + "CAT_fetchBlob", + "CAT_fetchDocument", + "CAT_fileStorage", + "CAT_registerMenuInput", + "CAT_scriptLoaded", + "CAT_unregisterMenuInput", + "CAT_userConfig", + "GM.addElement", + "GM.addStyle", + "GM.addValueChangeListener", + "GM.closeInTab", + "GM.closeNotification", + "GM.cookie", + "GM.deleteValue", + "GM.deleteValues", + "GM.download", + "GM.getResourceText", + "GM.getResourceURL", + "GM.getResourceUrl", + "GM.getTab", + "GM.getTabs", + "GM.getValue", + "GM.getValues", + "GM.listValues", + "GM.log", + "GM.notification", + "GM.openInTab", + "GM.registerMenuCommand", + "GM.removeValueChangeListener", + "GM.saveTab", + "GM.setClipboard", + "GM.setValue", + "GM.setValues", + "GM.unregisterMenuCommand", + "GM.updateNotification", + "GM.xmlHttpRequest", + "GM_addElement", + "GM_addStyle", + "GM_addValueChangeListener", + "GM_closeInTab", + "GM_closeNotification", + "GM_cookie", + "GM_deleteValue", + "GM_deleteValues", + "GM_download", + "GM_getResourceText", + "GM_getResourceURL", + "GM_getTab", + "GM_getTabs", + "GM_getValue", + "GM_getValues", + "GM_listValues", + "GM_log", + "GM_notification", + "GM_openInTab", + "GM_registerMenuCommand", + "GM_removeValueChangeListener", + "GM_saveTab", + "GM_setClipboard", + "GM_setValue", + "GM_setValues", + "GM_unregisterMenuCommand", + "GM_updateNotification", + "GM_xmlhttpRequest", + "window.close", + "window.focus", +]; + +export const SUPPORTED_GRANTS: ReadonlySet = new Set([...REGISTERED_GRANTS, ...CONTEXT_PROVIDED_GRANTS]); + +// 与运行时同一套候选规则:@grant GM.foo 与 GM_foo 互认(src/app/service/content/gm_api/grant.ts) +export const isSupportedGrant = (grant: string): boolean => + getGrantCandidates(grant).some((candidate) => SUPPORTED_GRANTS.has(candidate)); diff --git a/tests/mocks/CodeEditor.tsx b/tests/mocks/CodeEditor.tsx index 2d9b93f47..6bde3d896 100644 --- a/tests/mocks/CodeEditor.tsx +++ b/tests/mocks/CodeEditor.tsx @@ -1,25 +1,38 @@ -import { useEffect } from "react"; +import { useEffect, useImperativeHandle, type Ref } from "react"; -// 真实编辑器要等偏好设置读出来才创建实例并回调 onEditorMount。默认模拟「已就绪」, +// 真实编辑器要等偏好设置读出来才创建实例并回调。默认模拟「已就绪」, // 需要停在就绪之前(例如断言代码骨架)的用例调 setEditorMounts(false)。 let editorMounts = true; export function setEditorMounts(v: boolean) { editorMounts = v; } +export const revealLine = { calls: [] as number[] }; + export default function MockCodeEditor({ id, code, diffCode, onEditorMount, + onReady, + ref, }: { id: string; code?: string; diffCode?: string; onEditorMount?: (editor: unknown) => void; + onReady?: () => void; + ref?: Ref<{ editor: unknown; revealLine: (line: number) => void }>; }) { + useImperativeHandle(ref, () => ({ + editor: {}, + revealLine: (line: number) => revealLine.calls.push(line), + })); useEffect(() => { - if (editorMounts) onEditorMount?.({}); - }, [onEditorMount]); + if (!editorMounts) return; + // 与真实实现一致:diff 预览没有可编辑实例,只报告就绪 + if (!diffCode) onEditorMount?.({}); + onReady?.(); + }, [diffCode, onEditorMount, onReady]); return
; }