diff --git a/package.json b/package.json index c52e7a52b..8b26477f4 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-userscripts": "^0.5.6", + "fake-indexeddb": "^6.2.5", "globals": "^16.5.0", "happy-dom": "^20.10.2", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6666c6dc7..1bd3800e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: eslint-plugin-userscripts: specifier: ^0.5.6 version: 0.5.6(eslint@9.39.5(jiti@2.6.1)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 globals: specifier: ^16.5.0 version: 16.5.0 @@ -2961,6 +2964,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -8080,6 +8087,8 @@ snapshots: extend@3.0.2: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} diff --git a/src/app/migrate.test.ts b/src/app/migrate.test.ts new file mode 100644 index 000000000..bf48498ee --- /dev/null +++ b/src/app/migrate.test.ts @@ -0,0 +1,127 @@ +import Dexie from "dexie"; +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type migrateType from "./migrate"; + +// 0.16.x(MV2)留在 IndexedDB「ScriptCat」库里的最终表结构,各表取 0.16.15 中最后一次声明的定义。 +// 0.16.7 起 0.16 自己声明了 db.version(18)(防降级,见 faae3b83),此后的用户库已是 18。 +const MV2_STORES = { + scripts: + "++id,&uuid,name,namespace,author,originDomain,subscribeUrl,type,sort,status,runStatus,createtime,updatetime,checktime", + logger: "++id,level,createtime", + permission: "++id,scriptId,[scriptId+permission+permissionValue],createtime,updatetime", + value: "++id,[scriptId+key],[storageName+key]", + resource: "++id,&url,content,type,createtime,updatetime", + resourceLink: "++id,url,scriptId,createtime", + export: "++id,&scriptId", + subscribe: "++id,&url,createtime,updatetime,checktime", + sync: "++id,&key,[user+device+type],createtime", +}; + +const MV2_UUID = "0f6b5f3e-2a4b-4c1d-9e8f-1a2b3c4d5e6f"; + +async function seedMV2Database(version: number) { + const legacy = new Dexie("ScriptCat"); + legacy.version(version).stores(MV2_STORES); + await legacy.open(); + const scriptId = await legacy.table("scripts").add({ + uuid: MV2_UUID, + name: "mv2 script", + namespace: "mv2", + origin: "https://greasyfork.org/scripts/1-mv2.user.js", + originDomain: "greasyfork.org", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + metadata: { match: ["https://example.com/*"] }, + code: "// ==UserScript==\n// @name mv2 script\n// ==/UserScript==\nconsole.log(1);", + createtime: 1, + updatetime: 1, + checktime: 1, + }); + await legacy.table("value").add({ scriptId, storageName: "", key: "count", value: 3, createtime: 1 }); + legacy.close(); +} + +// 模拟扩展一次启动:与 src/service_worker.ts 相同的调用顺序 +async function startExtension() { + const mod = await import("./migrate.js"); + const migrate = mod.default as unknown as typeof migrateType; + migrate(); + await mod.migrateChromeStorage(); +} + +async function readMigrated() { + const { ScriptDAO, ScriptCodeDAO } = await import("./repo/scripts.js"); + const { ValueDAO } = await import("./repo/value.js"); + const scripts = await new ScriptDAO().all(); + const code = await new ScriptCodeDAO().get(MV2_UUID); + const value = await new ValueDAO().get(MV2_UUID); + return { + names: scripts.map((s) => s.name), + origin: scripts[0]?.origin, + code: code?.code, + values: value?.data, + }; +} + +beforeEach(async () => { + // 每个用例一个全新的 IndexedDB 与模块实例(dao.ts 的 Dexie 单例、Repo 缓存都在模块级)。 + // dexie 是外部依赖,不随 resetModules 重新求值,其 IndexedDB 来源要直接替换。 + Dexie.dependencies.indexedDB = new IDBFactory(); + Dexie.dependencies.IDBKeyRange = IDBKeyRange; + vi.resetModules(); + await chrome.storage.local.clear(); + // 迁移成功后会重载扩展,mock 中没有 runtime.reload + chrome.runtime.reload = vi.fn(); +}); + +describe("0.16(MV2 IndexedDB)数据迁移到 chrome.storage", () => { + it("0.16.7+ 留下的 v18 库:首次启动即迁移脚本、代码与 GM 值", async () => { + await seedMV2Database(18); + + await startExtension(); + + expect(await readMigrated()).toEqual({ + names: ["mv2 script"], + origin: "https://greasyfork.org/scripts/1-mv2.user.js", + code: expect.stringContaining("console.log(1);"), + values: { count: 3 }, + }); + }); + + it("0.16.6 及更早的 v17 库同样迁移,且只迁移一份", async () => { + await seedMV2Database(17); + + await startExtension(); + + expect(await readMigrated()).toEqual({ + names: ["mv2 script"], + origin: "https://greasyfork.org/scripts/1-mv2.user.js", + code: expect.stringContaining("console.log(1);"), + values: { count: 3 }, + }); + }); + + it("chrome.storage 已有脚本(早已迁移过的用户)时不用 IndexedDB 里的旧副本覆盖", async () => { + await seedMV2Database(18); + const { ScriptDAO } = await import("./repo/scripts.js"); + await new ScriptDAO().save({ + uuid: "c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b", + name: "current script", + namespace: "current", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + metadata: {}, + createtime: 2, + checktime: 2, + }); + + await startExtension(); + + expect((await readMigrated()).names).toEqual(["current script"]); + }); +}); diff --git a/src/app/migrate.ts b/src/app/migrate.ts index 63244e79e..23e37a846 100644 --- a/src/app/migrate.ts +++ b/src/app/migrate.ts @@ -11,196 +11,189 @@ import { PermissionDAO } from "./repo/permission"; import { DocumentationSite } from "./const"; import { LocalStorageDAO } from "./repo/localStorage"; -// 迁移数据到chrome.storage -export function migrateToChromeStorage() { - // 默认使用的事务,这里加个延时,用db.open()打开数据库后,再执行 - setTimeout(async () => { - try { - // 迁移脚本 - const scripts = await db.table("scripts").toArray(); - const scriptDAO = new ScriptDAO(); - const scriptCodeDAO = new ScriptCodeDAO(); - console.log("开始迁移脚本数据", scripts.length); - await Promise.all( - // 不处理 Promise.reject ? - scripts.map(async (script: ScriptAndCode) => { - const { - uuid, - name, - namespace, - author, - originDomain, - subscribeUrl, - type, - sort, - status, - runStatus, - metadata, - createtime, - checktime, - code, - checkUpdateUrl, - downloadUrl, - selfMetadata, - config, - error, - updatetime, - lastruntime, - nextruntime, - } = script; - const s = await scriptDAO.save({ - uuid, - name, - namespace, - author, - originDomain, - origin, - checkUpdateUrl, - downloadUrl, - metadata, - selfMetadata, - subscribeUrl, - config, - type, - status, - sort, - runStatus, - error, - createtime, - updatetime, - checktime, - lastruntime, - nextruntime, - }); - return scriptCodeDAO - .save({ - uuid: s.uuid, - code, - }) - .catch((e) => { - console.log("脚本代码迁移失败", e); - return Promise.reject(e); - }); +// 把 0.16(MV2)IndexedDB 中的数据迁移到 chrome.storage;失败时抛出,由调用方决定是否重试 +export async function migrateToChromeStorage() { + await db.open(); + // 迁移脚本 + const scripts = await db.table("scripts").toArray(); + const scriptDAO = new ScriptDAO(); + const scriptCodeDAO = new ScriptCodeDAO(); + console.log("开始迁移脚本数据", scripts.length); + await Promise.all( + // 不处理 Promise.reject ? + scripts.map(async (script: ScriptAndCode) => { + const { + uuid, + name, + namespace, + author, + originDomain, + origin, + subscribeUrl, + type, + sort, + status, + runStatus, + metadata, + createtime, + checktime, + code, + checkUpdateUrl, + downloadUrl, + selfMetadata, + config, + error, + updatetime, + lastruntime, + nextruntime, + } = script; + const s = await scriptDAO.save({ + uuid, + name, + namespace, + author, + originDomain, + origin, + checkUpdateUrl, + downloadUrl, + metadata, + selfMetadata, + subscribeUrl, + config, + type, + status, + sort, + runStatus, + error, + createtime, + updatetime, + checktime, + lastruntime, + nextruntime, + }); + return scriptCodeDAO + .save({ + uuid: s.uuid, + code, }) - ); - // 迁移订阅 - const subscribe = await db.table("subscribe").toArray(); - const subscribeDAO = new SubscribeDAO(); - if (subscribe.length) { - await Promise.all( - subscribe.map((s: Subscribe) => { - const { url, name, code, author, scripts, metadata, status, createtime, updatetime, checktime } = s; - return subscribeDAO.save({ - url, - name, - code, - author, - scripts, - metadata, - status, + .catch((e) => { + console.log("脚本代码迁移失败", e); + return Promise.reject(e); + }); + }) + ); + // 迁移订阅 + const subscribe = await db.table("subscribe").toArray(); + const subscribeDAO = new SubscribeDAO(); + if (subscribe.length) { + await Promise.all( + subscribe.map((s: Subscribe) => { + const { url, name, code, author, scripts, metadata, status, createtime, updatetime, checktime } = s; + return subscribeDAO.save({ + url, + name, + code, + author, + scripts, + metadata, + status, + createtime, + updatetime, + checktime, + }); + }) + ); + } + console.log("订阅数据迁移完成", subscribe.length); + // 迁移value + interface MV2Value { + id: number; + scriptId: number; + storageName?: string; + key: string; + value: any; + createtime: number; + updatetime: number; + } + const values = await db.table("value").toArray(); + const valueDAO = new ValueDAO(); + const valueMap = new Map(); + await Promise.all( + values.map((v: MV2Value) => { + const { scriptId, key, value, createtime } = v; + return db + .table("scripts") + .where("id") + .equals(scriptId) + .first((script: Script) => { + if (script) { + let data: { [key: string]: any } = {}; + if (!valueMap.has(script.uuid)) { + valueMap.set(script.uuid, { + uuid: script.uuid, + storageName: getStorageName(script), + data: data, + createtime, + updatetime: 0, + }); + } else { + data = valueMap.get(script.uuid)!.data; + } + data[key] = value; + } + }); + }) + ); + // 保存到数据库 + await Promise.all( + Array.from(valueMap.keys()).map((uuid) => { + const { storageName, data, createtime } = valueMap.get(uuid)!; + return valueDAO.save(storageName!, { + uuid, + storageName, + data, + createtime, + updatetime: 0, + }); + }) + ); + console.log("脚本value数据迁移完成", values.length); + // 迁移permission + const permissions = await db.table("permission").toArray(); + const permissionDAO = new PermissionDAO(); + await Promise.all( + permissions.map((p: Permission & { scriptId: number }) => { + const { scriptId, permission, permissionValue, createtime, updatetime, allow } = p; + return db + .table("scripts") + .where("id") + .equals(scriptId) + .first((script: Script) => { + if (script) { + return permissionDAO.save({ + uuid: script.uuid, + permission, + permissionValue, createtime, updatetime, - checktime, - }); - }) - ); - } - console.log("订阅数据迁移完成", subscribe.length); - // 迁移value - interface MV2Value { - id: number; - scriptId: number; - storageName?: string; - key: string; - value: any; - createtime: number; - updatetime: number; - } - const values = await db.table("value").toArray(); - const valueDAO = new ValueDAO(); - const valueMap = new Map(); - await Promise.all( - values.map((v: MV2Value) => { - const { scriptId, key, value, createtime } = v; - return db - .table("scripts") - .where("id") - .equals(scriptId) - .first((script: Script) => { - if (script) { - let data: { [key: string]: any } = {}; - if (!valueMap.has(script.uuid)) { - valueMap.set(script.uuid, { - uuid: script.uuid, - storageName: getStorageName(script), - data: data, - createtime, - updatetime: 0, - }); - } else { - data = valueMap.get(script.uuid)!.data; - } - data[key] = value; - } - }); - }) - ); - // 保存到数据库 - await Promise.all( - Array.from(valueMap.keys()).map((uuid) => { - const { storageName, data, createtime } = valueMap.get(uuid)!; - return valueDAO.save(storageName!, { - uuid, - storageName, - data, - createtime, - updatetime: 0, - }); - }) - ); - console.log("脚本value数据迁移完成", values.length); - // 迁移permission - const permissions = await db.table("permission").toArray(); - const permissionDAO = new PermissionDAO(); - await Promise.all( - permissions.map((p: Permission & { scriptId: number }) => { - const { scriptId, permission, permissionValue, createtime, updatetime, allow } = p; - return db - .table("scripts") - .where("id") - .equals(scriptId) - .first((script: Script) => { - if (script) { - return permissionDAO.save({ - uuid: script.uuid, - permission, - permissionValue, - createtime, - updatetime, - allow, - }); - } + allow, }); - }) - ); - console.log("脚本permission数据迁移完成", permissions.length); - // 打开页面,告知数据储存+升级至了mv3,重启一次扩展 - setTimeout(async () => { - const scripts = await scriptDAO.all(); - console.log("脚本数据迁移完成", scripts.length); - if (scripts.length > 0) { - chrome.tabs.create({ - url: `${DocumentationSite}/docs/change/v0.17/`, - }); - setTimeout(() => { - chrome.runtime.reload(); - }, 1000); - } - }, 2000); - } catch (e) { - console.error("脚本数据迁移失败", e); - } - }, 200); + } + }); + }) + ); + console.log("脚本permission数据迁移完成", permissions.length); + // 打开页面,告知数据储存+升级至了mv3,重启一次扩展 + const migrated = await scriptDAO.all(); + console.log("脚本数据迁移完成", migrated.length); + if (migrated.length > 0) { + chrome.tabs.create({ + url: `${DocumentationSite}/docs/change/v0.17/`, + }); + setTimeout(() => { + chrome.runtime.reload(); + }, 1000); + } } // 0.10.0重构,重命名字段,统一使用小峰驼 @@ -234,10 +227,9 @@ function renameField() { // export是0.10.x时的兼容性处理 export: "++id,&scriptId", }); - // 将脚本数据迁移到chrome.storage - db.version(18).upgrade(() => { - migrateToChromeStorage(); - }); + // 0.16.7 起 0.16 自身也声明了 version 18(防降级),这些用户的库打开时不会触发 upgrade, + // 所以迁移不挂在版本升级上,而是由 migrateChromeStorage 按数据判断 + db.version(18); return db.open(); } @@ -267,9 +259,23 @@ export function migrateChromeStorage() { ); }, }, + { + version: 2, + upgrade: async () => { + // chrome.storage 已有脚本说明早已迁移过,IndexedDB 里只剩旧副本,不能拿来覆盖 + if ((await new ScriptDAO().all()).length > 0) { + return; + } + await db.open(); + if ((await db.table("scripts").count()) === 0) { + return; + } + await migrateToChromeStorage(); + }, + }, ]; const localstorageDAO = new LocalStorageDAO(); - localstorageDAO.get("migrations").then(async (item) => { + return localstorageDAO.get("migrations").then(async (item) => { const migrations = item?.value || []; for (let i = 0; i < migrationList.length; i++) { const m = migrationList[i]; diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index d872a01f8..0f6a0aa66 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -518,6 +518,10 @@ export class SystemClient extends Client { connectVSCode(params: VSCodeConnectParam): Promise { return this.do("connectVSCode", params); } + + migrateToChromeStorage(): Promise { + return this.do("migrateToChromeStorage"); + } } export class AgentClient extends Client { diff --git a/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx b/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx index 1ea1ea004..76a08bb0b 100644 --- a/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx @@ -1,22 +1,48 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; -const { migrate } = vi.hoisted(() => ({ migrate: vi.fn() })); -vi.mock("@App/app/migrate", () => ({ migrateToChromeStorage: migrate })); +const { migrateToChromeStorage } = vi.hoisted(() => ({ migrateToChromeStorage: vi.fn() })); +vi.mock("@App/app/service/service_worker/client", () => ({ + SystemClient: class { + migrateToChromeStorage = migrateToChromeStorage; + }, +})); +vi.mock("@App/pages/store/global", () => ({ message: {} })); + +const { notifyError } = vi.hoisted(() => ({ notifyError: vi.fn() })); +vi.mock("@App/pages/components/ui/toast", () => ({ notify: { error: notifyError } })); import { MigrationSection } from "./MigrationSection"; afterEach(() => { cleanup(); - migrate.mockClear(); + migrateToChromeStorage.mockReset(); + notifyError.mockReset(); }); +async function confirmMigration() { + render( () => {}} />); + fireEvent.click(screen.getByTestId("retry_migration")); + const confirm = await screen.findByTestId("popconfirm-confirm"); + await act(async () => fireEvent.click(confirm)); +} + describe("数据迁移分区", () => { - it("确认后触发存储迁移", async () => { - render( () => {}} />); - fireEvent.click(screen.getByTestId("retry_migration")); - const confirm = await screen.findByTestId("popconfirm-confirm"); - await act(async () => fireEvent.click(confirm)); - expect(migrate).toHaveBeenCalled(); + // 页面里的 Dexie 没有声明表结构,迁移只能交给 Service Worker 执行 + it("确认后请求 Service Worker 执行存储迁移", async () => { + migrateToChromeStorage.mockResolvedValue(undefined); + + await confirmMigration(); + + expect(migrateToChromeStorage).toHaveBeenCalledTimes(1); + expect(notifyError).not.toHaveBeenCalled(); + }); + + it("迁移失败时提示错误", async () => { + migrateToChromeStorage.mockRejectedValue(new Error("boom")); + + await confirmMigration(); + + expect(notifyError).toHaveBeenCalledWith(expect.stringContaining("boom")); }); }); diff --git a/src/pages/options/routes/Tools/sections/MigrationSection.tsx b/src/pages/options/routes/Tools/sections/MigrationSection.tsx index a92bc1ee2..58a836c5b 100644 --- a/src/pages/options/routes/Tools/sections/MigrationSection.tsx +++ b/src/pages/options/routes/Tools/sections/MigrationSection.tsx @@ -1,11 +1,15 @@ import { SettingCard } from "../../../components/SettingCard"; import { Button } from "@App/pages/components/ui/button"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; -import { migrateToChromeStorage } from "@App/app/migrate"; +import { SystemClient } from "@App/app/service/service_worker/client"; +import { message } from "@App/pages/store/global"; +import { notify } from "@App/pages/components/ui/toast"; import { useTranslation } from "react-i18next"; export function MigrationSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { const { t } = useTranslation(); + const migrate = () => + new SystemClient(message).migrateToChromeStorage().catch((e) => notify.error(`${t("error")}: ${e}`)); return ( (el: description={t("settings:migration_confirm_message")} confirmText={t("confirm")} cancelText={t("editor:cancel")} - onConfirm={() => migrateToChromeStorage()} + onConfirm={migrate} >