diff --git a/e2e/agent-navigation.spec.ts b/e2e/agent-navigation.spec.ts index 81866487f..c4418f71a 100644 --- a/e2e/agent-navigation.spec.ts +++ b/e2e/agent-navigation.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures"; -import { openAgentChatPage, openAgentProviderPage, openOptionsPage } from "./utils"; +import { openOptionsPage } from "./utils"; // new-ui 侧边栏 Agent 子菜单(shadcn):折叠态切换按钮 data-testid="nav-agent", // 展开后子项为 NavLink(a[href="#/agent/..."]),容器 data-testid="sidebar-agent-submenu"。 @@ -27,22 +27,4 @@ test.describe("Agent 导航", () => { await expect(page).toHaveURL(/#\/agent\/provider/); await page.close(); }); - - test("应能直接加载 Agent 会话页", async ({ context, extensionId }) => { - const page = await openAgentChatPage(context, extensionId); - await expect(page.getByTestId("conv-new")).toBeVisible({ timeout: 10_000 }); - await page.close(); - }); - - test("应能直接加载 Agent 模型服务页", async ({ context, extensionId }) => { - const page = await openAgentProviderPage(context, extensionId); - await expect(page.getByTestId("model-add")).toBeVisible({ timeout: 10_000 }); - await page.close(); - }); - - test("未配置模型时模型服务页应显示空状态", async ({ context, extensionId }) => { - const page = await openAgentProviderPage(context, extensionId); - await expect(page.getByTestId("empty-state")).toBeVisible({ timeout: 10_000 }); - await page.close(); - }); }); diff --git a/e2e/backup-zip.spec.ts b/e2e/backup-zip.spec.ts index 89318e330..1c0332adc 100644 --- a/e2e/backup-zip.spec.ts +++ b/e2e/backup-zip.spec.ts @@ -1,6 +1,6 @@ import fs from "fs"; import type { BrowserContext, Page } from "@playwright/test"; -import { test, expect } from "./fixtures"; +import { testWithUserScripts as test, expect } from "./fixtures"; import { installScriptByCode, openOptionsPage } from "./utils"; /** @@ -18,18 +18,43 @@ import { installScriptByCode, openOptionsPage } from "./utils"; */ const SCRIPT_NAME = "Backup RoundTrip E2E"; +const TARGET_ORIGIN = "http://backup-roundtrip.test"; const script = `// ==UserScript== // @name ${SCRIPT_NAME} // @namespace https://e2e.test // @version 1.0.0 // @description backup zip e2e // @author E2E -// @match http://example.com/* -// @grant none +// @match ${TARGET_ORIGIN}/* +// @grant GM_getValue +// @grant GM_setValue // ==/UserScript== -(function(){})(); + +document.documentElement.setAttribute("data-restored-value", GM_getValue("backup-key", "missing")); +GM_setValue("backup-key", "value-from-backup"); `; +async function purgeInstalledScript(page: Page): Promise { + const result = await page.evaluate(async (name) => { + const all = await chrome.runtime.sendMessage({ action: "serviceWorker/script/getAllScripts" }); + const script = all.data.find((item: { name: string }) => item.name === name) as { uuid: string } | undefined; + if (!script) throw new Error("导出后未找到待清理脚本"); + const deleted = await chrome.runtime.sendMessage({ + action: "serviceWorker/script/deletes", + data: [script.uuid], + }); + const purged = await chrome.runtime.sendMessage({ + action: "serviceWorker/script/purges", + data: [script.uuid], + }); + return { deleted, purged }; + }, SCRIPT_NAME); + expect(result.deleted.code || 0, result.deleted.message).toBe(0); + expect(result.deleted.data).toBe(true); + expect(result.purged.code || 0, result.purged.message).toBe(0); + expect(result.purged.data).toBe(true); +} + /** 轮询 SW 的 chrome.downloads,拿到刚导出的 blob 备份文件在磁盘上的路径 */ async function waitForExportedZipPath(context: BrowserContext, timeoutMs = 15_000): Promise { const sw = context.serviceWorkers()[0]; @@ -57,9 +82,17 @@ async function waitForExportedZipPath(context: BrowserContext, timeoutMs = 15_00 } test.describe("Backup zip export/import round-trip (#1479)", () => { - test("导出备份生成合法 zip,再导入能被 loadAsyncJSZip 解析并列出脚本", async ({ context, extensionId }) => { + test("导出后彻底删除原脚本,真正导入应恢复脚本与 GM Value", async ({ context, extensionId }) => { + await context.route(`${TARGET_ORIGIN}/**`, (route) => + route.fulfill({ status: 200, contentType: "text/html", body: "" }) + ); await installScriptByCode(context, extensionId, script); + const seedPage = await context.newPage(); + await seedPage.goto(`${TARGET_ORIGIN}/seed`, { waitUntil: "domcontentloaded" }); + await expect(seedPage.locator("html")).toHaveAttribute("data-restored-value", "missing", { timeout: 20_000 }); + await seedPage.close(); + const page = await openOptionsPage(context, extensionId); await page.goto(`chrome-extension://${extensionId}/src/options.html#/tools`); await page.waitForLoadState("domcontentloaded"); @@ -76,6 +109,9 @@ test.describe("Backup zip export/import round-trip (#1479)", () => { expect(buf[0]).toBe(0x50); // 'P' expect(buf[1]).toBe(0x4b); // 'K' + // 导入前彻底删除原脚本和共享值,避免导入页只显示预览也造成假阳性。 + await purgeInstalledScript(page); + // 3) 导入:点击“导入文件”→ 触发隐藏 file input 的 click(filechooser),选中刚导出的 zip, // openImportWindow 会把文件写入 cache 并经扩展 API 新开导入页标签,从该标签 URL 取 uuid。 // 注意:扩展首启且 userScripts 未开启时会异步用 chrome.tabs.create 打开「开启开发者模式」 @@ -109,6 +145,17 @@ test.describe("Backup zip export/import round-trip (#1479)", () => { await importPage.waitForLoadState("domcontentloaded"); await expect(importPage.locator("body")).toContainText(SCRIPT_NAME, { timeout: 15_000 }); + await expect(importPage.getByTestId("import-btn")).toBeEnabled({ timeout: 10_000 }); + await importPage.getByTestId("import-btn").click(); + await expect(importPage.getByTestId("import-complete")).toBeVisible({ timeout: 30_000 }); + + const restoredPage = await context.newPage(); + await restoredPage.goto(`${TARGET_ORIGIN}/restored`, { waitUntil: "domcontentloaded" }); + await expect(restoredPage.locator("html")).toHaveAttribute("data-restored-value", "value-from-backup", { + timeout: 20_000, + }); + + await restoredPage.close(); await importPage.close(); await page.close(); }); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 9e30f5ddc..b482ca7e8 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -212,7 +212,7 @@ function patchTargetMatchCode(code: string, targetUrl: string): string { const url = new URL(targetUrl); const targetPattern = `${url.protocol}//${url.hostname}/*${url.search}`; return code.replace( - /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test)$/gm, + /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|early_inject_content|early_inject_page|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test)$/gm, `// @match ${targetPattern}` ); } @@ -358,6 +358,34 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); + test("@early-start page world 脚本应在 CSP 页面的解析早期执行", async ({ context, extensionId }) => { + const { passed, failed, logs } = await runTestScript( + context, + extensionId, + "early_inject_page_test.js", + `${gmApiMockServer.cspOrigin}/?early_inject_page`, + 60_000 + ); + + if (failed !== 0) console.log("[early_inject_page_test] logs:", logs.join("\n")); + expect(failed, "Some early page-world injection tests failed").toBe(0); + expect(passed, "No early page-world results found - script may not have run").toBeGreaterThan(0); + }); + + test("@early-start content world 脚本应在 CSP 页面的解析早期执行", async ({ context, extensionId }) => { + const { passed, failed, logs } = await runTestScript( + context, + extensionId, + "early_inject_content_test.js", + `${gmApiMockServer.cspOrigin}/?early_inject_content`, + 60_000 + ); + + if (failed !== 0) console.log("[early_inject_content_test] logs:", logs.join("\n")); + expect(failed, "Some early content-world injection tests failed").toBe(0); + expect(passed, "No early content-world results found - script may not have run").toBeGreaterThan(0); + }); + test("Unwrap scriptlet tests (unwrap_e2e_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, diff --git a/e2e/gm-xhr-site-access.spec.ts b/e2e/gm-xhr-site-access.spec.ts index 153bdc9e4..f9b0d2a0d 100644 --- a/e2e/gm-xhr-site-access.spec.ts +++ b/e2e/gm-xhr-site-access.spec.ts @@ -116,23 +116,6 @@ test.describe("GM_xmlhttpRequest site-access & DNR marker (#1477)", () => { await server.close(); }); - test("声明 @connect 的跨域 GET 请求成功", async ({ context, extensionId }) => { - const code = xhrScript({ - name: "XHR connect ok", - matchHost: "sitea.test", - connect: ["xhrtarget.test"], - url: server.url("xhrtarget.test", "/xhr"), - port: server.port, - }); - await installScriptByCode(context, extensionId, code); - - const { data, logs } = await runXhr(context, server.url("sitea.test", "/page")); - expect(data.phase, `期望 load,实际: ${JSON.stringify(data)}\n${logs.join("\n")}`).toBe("load"); - expect(data.status).toBe(200); - expect(data.ok).toBe(true); - expect(data.method).toBe("GET"); - }); - test("自定义请求头透传到服务器,x-sc-request-marker 标记头被 DNR 剥离", async ({ context, extensionId }) => { const code = xhrScript({ name: "XHR header marker", @@ -147,6 +130,8 @@ test.describe("GM_xmlhttpRequest site-access & DNR marker (#1477)", () => { const { data } = await runXhr(context, server.url("sitea.test", "/page")); expect(data.phase).toBe("load"); expect(data.status).toBe(200); + expect(data.ok).toBe(true); + expect(data.method).toBe("GET"); // 以服务器实际收到的请求为准(DNR 在网络层剥离标记头) const xhrReq = server.requestLog.find((r) => r.pathname === "/xhr"); diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index 57753d1b9..81112431d 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -1,54 +1,72 @@ -import type { BrowserContext } from "@playwright/test"; -import { test, expect } from "./fixtures"; - -// new-ui 安装页(shadcn,data-testid 丰富):?url= 触发页面级 fetch 拉取脚本, -// 用 page.route 拦截避免真实网络抖动;脚本元信息渲染后 content-area 可见、 -// 主操作按钮(install-primary)可用、脚本名出现在 h1。 -const SCRIPT_URL = "https://e2e.test/install-test.user.js"; -const SCRIPT_NAME = "E2E Install Test"; -const SCRIPT_BODY = `// ==UserScript== +import type { BrowserContext, Page } from "@playwright/test"; +import { testWithUserScripts as test, expect } from "./fixtures"; + +const SCRIPT_URL = "https://e2e.test/install-update.user.js"; +const TARGET_ORIGIN = "http://install-update.test"; +const SCRIPT_NAME = "E2E Install Update"; + +function scriptBody(version: string): string { + return `// ==UserScript== // @name ${SCRIPT_NAME} -// @namespace https://e2e.test -// @version 1.0.0 -// @description install page e2e -// @author E2E -// @match https://example.com/* +// @namespace https://e2e.test/install-update +// @version ${version} +// @description install and update e2e +// @match ${TARGET_ORIGIN}/* +// @updateURL ${SCRIPT_URL} +// @downloadURL ${SCRIPT_URL} +// @grant none // ==/UserScript== -console.log("install e2e"); +document.documentElement.setAttribute("data-install-update-version", ${JSON.stringify(version)}); `; +} -async function openMockedInstallPage(context: BrowserContext, extensionId: string) { +async function openInstallPage(context: BrowserContext, extensionId: string): Promise { const page = await context.newPage(); - // 必须在 goto 之前注册路由,安装页挂载即发起 fetch - await page.route("**/install-test.user.js", (route) => - route.fulfill({ - status: 200, - headers: { "Content-Type": "application/javascript; charset=utf-8" }, - body: SCRIPT_BODY, - }) - ); - // 安装页按原始子串读取 url=(location.search.slice),不做 decode,故此处不能 encodeURIComponent await page.goto(`chrome-extension://${extensionId}/src/install.html?url=${SCRIPT_URL}`, { waitUntil: "domcontentloaded", }); + await expect(page.getByText(SCRIPT_NAME).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("install-primary")).toBeEnabled({ timeout: 10_000 }); return page; } -test.describe("Install 安装页", () => { - test("应通过 URL 参数打开安装页且标题为 ScriptCat", async ({ context, extensionId }) => { - const page = await openMockedInstallPage(context, extensionId); - await expect(page).toHaveTitle(/ScriptCat/i); - }); +async function installFromPage(page: Page): Promise { + await Promise.all([page.waitForEvent("close", { timeout: 10_000 }), page.getByTestId("install-primary").click()]); +} + +async function expectExecutedVersion(context: BrowserContext, version: string): Promise { + const page = await context.newPage(); + try { + await page.goto(`${TARGET_ORIGIN}/?version=${version}`, { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("data-install-update-version", version, { timeout: 20_000 }); + } finally { + await page.close(); + } +} + +test.describe("安装与更新真实执行链路", () => { + test("从安装页安装 v1 后应执行,并可从同一来源更新到 v2", async ({ context, extensionId }) => { + let version = "1.0.0"; + await context.route(SCRIPT_URL, (route) => + route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + body: scriptBody(version), + }) + ); + await context.route(`${TARGET_ORIGIN}/**`, (route) => + route.fulfill({ status: 200, contentType: "text/html", body: "" }) + ); - test("加载脚本后应展示脚本元信息并可安装", async ({ context, extensionId }) => { - const page = await openMockedInstallPage(context, extensionId); + await installFromPage(await openInstallPage(context, extensionId)); + await expectExecutedVersion(context, "1.0.0"); - // 脚本名渲染(元信息加载完成的可见信号) - await expect(page.getByText(SCRIPT_NAME).first()).toBeVisible({ timeout: 15_000 }); - // 内容区已填充 - await expect(page.getByTestId("content-area")).toBeVisible({ timeout: 10_000 }); - // 主操作按钮可用(非 disabled) - await expect(page.getByTestId("install-primary")).toBeEnabled({ timeout: 10_000 }); + version = "2.0.0"; + const updatePage = await openInstallPage(context, extensionId); + await expect(updatePage.getByTestId("version-old")).toHaveText("v1.0.0"); + await expect(updatePage.getByTestId("version-new")).toHaveText("v2.0.0"); + await installFromPage(updatePage); + await expectExecutedVersion(context, "2.0.0"); }); }); diff --git a/e2e/script-editor.spec.ts b/e2e/script-editor.spec.ts index 722baa86a..143e5f95f 100644 --- a/e2e/script-editor.spec.ts +++ b/e2e/script-editor.spec.ts @@ -4,25 +4,6 @@ import { openEditorPage, openOptionsPage, saveCurrentEditor } from "./utils"; // new-ui 脚本编辑器:路由 #/script/editor 加载空白模板(normal.tpl,含 ==UserScript==); // Monaco 选择器(.monaco-editor/.view-lines) 为框架级不变;保存成功为 sonner toast。 test.describe("Script 编辑器", () => { - test("应加载编辑器页并渲染 Monaco", async ({ context, extensionId }) => { - const page = await openEditorPage(context, extensionId); - await expect(page.locator(".monaco-editor")).toBeVisible({ timeout: 10_000 }); - }); - - test("应载入新建脚本模板", async ({ context, extensionId }) => { - const page = await openEditorPage(context, extensionId); - await expect(page.locator(".monaco-editor")).toBeVisible({ timeout: 10_000 }); - await expect(page.locator(".view-lines")).toContainText("==UserScript==", { timeout: 10_000 }); - }); - - test("应能成功保存脚本", async ({ context, extensionId }) => { - const page = await openEditorPage(context, extensionId); - await expect(page.locator(".monaco-editor")).toBeVisible({ timeout: 10_000 }); - await expect(page.locator(".view-lines")).toContainText("==UserScript==", { timeout: 10_000 }); - - await saveCurrentEditor(context, extensionId, page); - }); - test("保存后脚本应出现在列表中", async ({ context, extensionId }) => { const editorPage = await openEditorPage(context, extensionId); await expect(editorPage.locator(".monaco-editor")).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts deleted file mode 100644 index de4295a1d..000000000 --- a/e2e/settings.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openOptionsPage } from "./utils"; - -// new-ui 设置页(shadcn,scroll-spy 一次性渲染全部分区):路由 #/settings, -// 滚动容器 data-testid="setting-page";交互控件为 Radix Select(role=combobox)/ -// Switch(role=switch)/Checkbox(role=checkbox) 及原生 input。 -test.describe("Settings 设置页", () => { - test("应渲染设置页内容容器", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - await page.goto(`chrome-extension://${extensionId}/src/options.html#/settings`); - await page.waitForLoadState("domcontentloaded"); - - await expect(page.getByTestId("setting-page")).toBeVisible({ timeout: 10_000 }); - }); - - test("应包含可见且可交互的设置项", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - await page.goto(`chrome-extension://${extensionId}/src/options.html#/settings`); - await page.waitForLoadState("domcontentloaded"); - - const combobox = page.getByRole("combobox"); - const switches = page.getByRole("switch"); - const checkboxes = page.getByRole("checkbox"); - const inputs = page.locator("input"); - - await expect - .poll( - async () => - (await combobox.count()) + (await switches.count()) + (await checkboxes.count()) + (await inputs.count()), - { timeout: 10_000 } - ) - .toBeGreaterThan(0); - }); -}); diff --git a/e2e/standalone-pages-smoke.spec.ts b/e2e/standalone-pages-smoke.spec.ts index 8f63c63ee..0b561ef6b 100644 --- a/e2e/standalone-pages-smoke.spec.ts +++ b/e2e/standalone-pages-smoke.spec.ts @@ -1,18 +1,5 @@ import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; -import { openOptionsPage, openPopupPage } from "./utils"; - -const SCRIPT_URL = "https://e2e.test/standalone-install.user.js"; -const SCRIPT_BODY = `// ==UserScript== -// @name Standalone Install Smoke -// @namespace https://e2e.test -// @version 1.0.0 -// @description standalone page smoke -// @match https://example.com/* -// ==/UserScript== - -console.log("standalone install smoke"); -`; function collectPageErrors(page: Page): string[] { const errors: string[] = []; @@ -21,43 +8,6 @@ function collectPageErrors(page: Page): string[] { } test.describe("独立扩展页面加载冒烟", () => { - test("options.html 根页面应渲染脚本列表入口", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - const errors = collectPageErrors(page); - - await expect(page.getByTestId("view-toggle")).toBeVisible({ timeout: 20_000 }); - expect(errors).toEqual([]); - }); - - test("popup.html 应渲染弹窗主界面", async ({ context, extensionId }) => { - const page = await openPopupPage(context, extensionId); - const errors = collectPageErrors(page); - - await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("switch").first()).toBeVisible({ timeout: 10_000 }); - expect(errors).toEqual([]); - }); - - test("install.html 应加载远程脚本并渲染安装操作", async ({ context, extensionId }) => { - const page = await context.newPage(); - const errors = collectPageErrors(page); - await page.route("**/standalone-install.user.js", (route) => - route.fulfill({ - status: 200, - headers: { "Content-Type": "application/javascript; charset=utf-8" }, - body: SCRIPT_BODY, - }) - ); - - await page.goto(`chrome-extension://${extensionId}/src/install.html?url=${SCRIPT_URL}`, { - waitUntil: "domcontentloaded", - }); - - await expect(page.getByText("Standalone Install Smoke").first()).toBeVisible({ timeout: 15_000 }); - await expect(page.getByTestId("install-primary")).toBeEnabled({ timeout: 10_000 }); - expect(errors).toEqual([]); - }); - test("import.html 在缺失缓存文件时应渲染可恢复的错误态", async ({ context, extensionId }) => { const page = await context.newPage(); const errors = collectPageErrors(page); diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts new file mode 100644 index 000000000..66a6101ac --- /dev/null +++ b/e2e/storage-name.spec.ts @@ -0,0 +1,593 @@ +import { randomUUID } from "crypto"; +import type { BrowserContext, Page } from "@playwright/test"; +import { testWithUserScripts as test, expect } from "./fixtures"; +import { autoApprovePermissions, installScriptByCode, openOptionsPage } from "./utils"; + +const TARGET_ORIGIN = "http://storage-name.test"; + +type ScriptIdentity = { + name: string; + uuid: string; +}; + +type ScriptActionResponse = { + code?: number; + data?: T; + message?: string; +}; + +type SharedReadResult = { + ok: boolean; + asyncReadOfSyncWrite?: string; + syncReadOfAsyncWrite?: string; + error?: string; +}; + +type RemoteChangeResult = { + name: string; + oldValue: string | null; + newValue: string; + remote: boolean; +}; + +type WriterResult = { + ok: boolean; + error?: string; +}; + +type CrossContextResult = { + ok: boolean; + backgroundValue?: string; + foregroundObservedRemote?: boolean; + backgroundObservedRemote?: boolean; + error?: string; +}; + +type CrossContextScriptPair = { + token: string; + backgroundName: string; + runEvent: string; + readyAttribute: string; + resultAttribute: string; + backgroundCode: string; + foregroundCode: string; +}; + +type StorageReaderScript = { + name: string; + readEvent: string; + readyAttribute: string; + resultAttribute: string; + remoteChangeAttribute: string; + code: string; +}; + +type SharedScriptPair = { + token: string; + storageName: string; + syncKey: string; + asyncKey: string; + syncValue: string; + asyncValue: string; + writerName: string; + writeEvent: string; + writerReadyAttribute: string; + writerResultAttribute: string; + writerCode: string; + reader: StorageReaderScript; +}; + +async function serveTargetPage(context: BrowserContext): Promise { + await context.route(`${TARGET_ORIGIN}/**`, (route) => + route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: "storageName E2E", + }) + ); +} + +function createStorageReaderScript(options: { + token: string; + role: "shared" | "isolated"; + storageName: string; + syncKey: string; + asyncKey: string; +}): StorageReaderScript { + const { token, role, storageName, syncKey, asyncKey } = options; + const name = `E2E storageName ${role} reader ${token}`; + const readEvent = `scriptcat-e2e-${token}-${role}-read`; + const readyAttribute = `data-sc-${token}-${role}-reader-ready`; + const resultAttribute = `data-sc-${token}-${role}-reader-result`; + const remoteChangeAttribute = `data-sc-${token}-${role}-remote-change`; + const code = `// ==UserScript== +// @name ${name} +// @namespace https://e2e.scriptcat.test/${token}/${role}-reader +// @version 1.0.0 +// @match ${TARGET_ORIGIN}/* +// @run-at document-start +// @grant GM_getValue +// @grant GM.getValue +// @grant GM_addValueChangeListener +// @storageName ${storageName} +// ==/UserScript== + +const setReaderMarker = (name, value) => { + const apply = () => document.documentElement?.setAttribute(name, value); + if (document.documentElement) apply(); + else document.addEventListener("DOMContentLoaded", apply, { once: true }); +}; + +GM_addValueChangeListener(${JSON.stringify(asyncKey)}, (name, oldValue, newValue, remote) => { + setReaderMarker( + ${JSON.stringify(remoteChangeAttribute)}, + JSON.stringify({ + name, + oldValue: oldValue === undefined ? null : oldValue, + newValue, + remote, + }), + ); +}); + +document.addEventListener(${JSON.stringify(readEvent)}, async () => { + try { + setReaderMarker( + ${JSON.stringify(resultAttribute)}, + JSON.stringify({ + ok: true, + asyncReadOfSyncWrite: await GM.getValue(${JSON.stringify(syncKey)}, "missing"), + syncReadOfAsyncWrite: GM_getValue(${JSON.stringify(asyncKey)}, "missing"), + }), + ); + } catch (error) { + setReaderMarker( + ${JSON.stringify(resultAttribute)}, + JSON.stringify({ ok: false, error: String(error) }), + ); + } +}); + +setReaderMarker(${JSON.stringify(readyAttribute)}, "true"); +`; + + return { name, readEvent, readyAttribute, resultAttribute, remoteChangeAttribute, code }; +} + +function createSharedScriptPair(): SharedScriptPair { + const token = randomUUID().replaceAll("-", ""); + const storageName = `scriptcat-e2e-storage-${token}`; + const syncKey = `sync-${token}`; + const asyncKey = `async-${token}`; + const syncValue = `sync-value-${token}`; + const asyncValue = `async-value-${token}`; + const writerName = `E2E storageName writer ${token}`; + const writeEvent = `scriptcat-e2e-${token}-write`; + const writerReadyAttribute = `data-sc-${token}-writer-ready`; + const writerResultAttribute = `data-sc-${token}-writer-result`; + + const writerCode = `// ==UserScript== +// @name ${writerName} +// @namespace https://e2e.scriptcat.test/${token}/writer +// @version 1.0.0 +// @match ${TARGET_ORIGIN}/* +// @run-at document-start +// @grant GM_setValue +// @grant GM.setValue +// @storageName ${storageName} +// ==/UserScript== + +const setWriterMarker = (name, value) => { + const apply = () => document.documentElement?.setAttribute(name, value); + if (document.documentElement) apply(); + else document.addEventListener("DOMContentLoaded", apply, { once: true }); +}; + +document.addEventListener( + ${JSON.stringify(writeEvent)}, + async () => { + try { + GM_setValue(${JSON.stringify(syncKey)}, ${JSON.stringify(syncValue)}); + await GM.setValue(${JSON.stringify(asyncKey)}, ${JSON.stringify(asyncValue)}); + setWriterMarker(${JSON.stringify(writerResultAttribute)}, JSON.stringify({ ok: true })); + } catch (error) { + setWriterMarker( + ${JSON.stringify(writerResultAttribute)}, + JSON.stringify({ ok: false, error: String(error) }), + ); + } + }, + { once: true }, +); + +setWriterMarker(${JSON.stringify(writerReadyAttribute)}, "true"); +`; + const reader = createStorageReaderScript({ token, role: "shared", storageName, syncKey, asyncKey }); + + return { + token, + storageName, + syncKey, + asyncKey, + syncValue, + asyncValue, + writerName, + writeEvent, + writerReadyAttribute, + writerResultAttribute, + writerCode, + reader, + }; +} + +function createCrossContextScriptPair(): CrossContextScriptPair { + const token = randomUUID().replaceAll("-", ""); + const storageName = `scriptcat-e2e-cross-context-${token}`; + const backgroundName = `E2E storageName background ${token}`; + const foregroundName = `E2E storageName foreground ${token}`; + const backgroundKey = `background-${token}`; + const requestKey = `request-${token}`; + const responseKey = `response-${token}`; + const backgroundValue = `background-value-${token}`; + const foregroundValue = `foreground-value-${token}`; + const runEvent = `scriptcat-e2e-${token}-cross-context`; + const readyAttribute = `data-sc-${token}-cross-ready`; + const resultAttribute = `data-sc-${token}-cross-result`; + + const backgroundCode = `// ==UserScript== +// @name ${backgroundName} +// @namespace https://e2e.scriptcat.test/${token}/background +// @version 1.0.0 +// @background +// @grant GM_setValue +// @grant GM_addValueChangeListener +// @storageName ${storageName} +// ==/UserScript== + +return new Promise(() => { + GM_addValueChangeListener(${JSON.stringify(requestKey)}, (name, oldValue, newValue, remote) => { + GM_setValue( + ${JSON.stringify(responseKey)}, + JSON.stringify({ requestValue: newValue, backgroundObservedRemote: remote }), + ); + }); + GM_setValue(${JSON.stringify(backgroundKey)}, ${JSON.stringify(backgroundValue)}); +}); +`; + + const foregroundCode = `// ==UserScript== +// @name ${foregroundName} +// @namespace https://e2e.scriptcat.test/${token}/foreground +// @version 1.0.0 +// @match ${TARGET_ORIGIN}/* +// @run-at document-start +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_addValueChangeListener +// @storageName ${storageName} +// ==/UserScript== + +const setMarker = (name, value) => { + const apply = () => document.documentElement?.setAttribute(name, value); + if (document.documentElement) apply(); + else document.addEventListener("DOMContentLoaded", apply, { once: true }); +}; + +document.addEventListener(${JSON.stringify(runEvent)}, () => { + try { + const sharedBackgroundValue = GM_getValue(${JSON.stringify(backgroundKey)}, "missing"); + GM_addValueChangeListener(${JSON.stringify(responseKey)}, (name, oldValue, newValue, remote) => { + const response = JSON.parse(newValue); + setMarker( + ${JSON.stringify(resultAttribute)}, + JSON.stringify({ + ok: response.requestValue === ${JSON.stringify(foregroundValue)}, + backgroundValue: sharedBackgroundValue, + foregroundObservedRemote: remote, + backgroundObservedRemote: response.backgroundObservedRemote, + }), + ); + }); + GM_setValue(${JSON.stringify(requestKey)}, ${JSON.stringify(foregroundValue)}); + } catch (error) { + setMarker(${JSON.stringify(resultAttribute)}, JSON.stringify({ ok: false, error: String(error) })); + } +}); + +setMarker(${JSON.stringify(readyAttribute)}, "true"); +`; + + return { + token, + backgroundName, + runEvent, + readyAttribute, + resultAttribute, + backgroundCode, + foregroundCode, + }; +} + +async function readScriptIdentities(page: Page, names: string[]): Promise { + return page.evaluate(async (expectedNames) => { + type InstalledScript = { + name: string; + uuid: string; + }; + + const response = (await chrome.runtime.sendMessage({ + action: "serviceWorker/script/getAllScripts", + })) as ScriptActionResponse; + if (!response || response.code) { + throw new Error(`读取已安装脚本失败: ${response?.message || "无响应"}`); + } + + return (response.data || []) + .filter((script) => expectedNames.includes(script.name)) + .map((script) => ({ + name: script.name, + uuid: script.uuid, + })); + }, names); +} + +async function installSharedScripts( + context: BrowserContext, + extensionId: string, + pair: SharedScriptPair, + readers: StorageReaderScript[] = [pair.reader] +): Promise { + await installScriptByCode(context, extensionId, pair.writerCode); + for (const reader of readers) await installScriptByCode(context, extensionId, reader.code); + autoApprovePermissions(context); +} + +async function installCrossContextScriptPair( + context: BrowserContext, + extensionId: string, + pair: CrossContextScriptPair +): Promise { + await installScriptByCode(context, extensionId, pair.backgroundCode); + await installScriptByCode(context, extensionId, pair.foregroundCode); + autoApprovePermissions(context); + + const optionsPage = await openOptionsPage(context, extensionId); + try { + const identities = await readScriptIdentities(optionsPage, [pair.backgroundName]); + const background = identities.find((script) => script.name === pair.backgroundName); + expect(background, "未找到 storageName 后台脚本").toBeDefined(); + const response = await optionsPage.evaluate(async (uuid) => { + return chrome.runtime.sendMessage({ + action: "serviceWorker/script/enable", + data: { uuid, enable: true }, + }) as Promise>>; + }, background!.uuid); + expect(response.code || 0, response.message).toBe(0); + } finally { + await optionsPage.close(); + } +} + +async function waitForReady( + page: Page, + pair: SharedScriptPair, + readers: StorageReaderScript[], + includeWriter: boolean +): Promise { + const root = page.locator("html"); + for (const reader of readers) { + await expect(root).toHaveAttribute(reader.readyAttribute, "true", { timeout: 20_000 }); + } + if (includeWriter) { + await expect(root).toHaveAttribute(pair.writerReadyAttribute, "true", { timeout: 20_000 }); + } +} + +async function waitForJsonAttribute(page: Page, attribute: string): Promise { + const root = page.locator("html"); + await expect.poll(() => root.getAttribute(attribute), { timeout: 20_000, intervals: [100, 250, 500] }).not.toBeNull(); + const serialized = await root.getAttribute(attribute); + if (serialized === null) throw new Error(`页面未写入 ${attribute}`); + return JSON.parse(serialized) as T; +} + +async function triggerWrite(page: Page, pair: SharedScriptPair): Promise { + await page.evaluate((eventName) => document.dispatchEvent(new Event(eventName)), pair.writeEvent); + const result = await waitForJsonAttribute(page, pair.writerResultAttribute); + expect(result).toEqual({ ok: true }); +} + +async function readStorageValues(page: Page, reader: StorageReaderScript): Promise { + await page.evaluate((eventName) => document.dispatchEvent(new Event(eventName)), reader.readEvent); + return waitForJsonAttribute(page, reader.resultAttribute); +} + +async function runScriptAction(page: Page, action: "deletes" | "purges" | "restores", uuids: string[]): Promise { + // 使用 options 页产品代码调用的同一消息端点,专注验证 SW 中的存储生命周期。 + const response = await page.evaluate( + async ({ scriptAction, scriptUuids }) => + chrome.runtime.sendMessage({ + action: `serviceWorker/script/${scriptAction}`, + data: scriptUuids, + }) as Promise>, + { scriptAction: action, scriptUuids: uuids } + ); + expect(response.code || 0, response.message).toBe(0); + return response.data as T; +} + +test.describe("@storageName 真实浏览器共享存储", () => { + test.setTimeout(180_000); + + test("普通脚本应按 storageName 共享或隔离值与变更事件", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createSharedScriptPair(); + const isolatedReader = createStorageReaderScript({ + token: pair.token, + role: "isolated", + storageName: `scriptcat-e2e-isolated-${pair.token}`, + syncKey: pair.syncKey, + asyncKey: pair.asyncKey, + }); + await installSharedScripts(context, extensionId, pair, [pair.reader, isolatedReader]); + + const page = await context.newPage(); + try { + await page.goto(`${TARGET_ORIGIN}/page?shared-and-isolated=${pair.token}`, { waitUntil: "domcontentloaded" }); + await waitForReady(page, pair, [pair.reader, isolatedReader], true); + + await test.step("writer 一次写入同步与异步值", async () => { + await triggerWrite(page, pair); + }); + + await test.step("相同 storageName reader 交叉读取并收到远程变更", async () => { + const remoteChange = await waitForJsonAttribute(page, pair.reader.remoteChangeAttribute); + expect(remoteChange).toEqual({ + name: pair.asyncKey, + oldValue: null, + newValue: pair.asyncValue, + remote: true, + }); + await expect(readStorageValues(page, pair.reader)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: pair.syncValue, + syncReadOfAsyncWrite: pair.asyncValue, + }); + }); + + await test.step("不同 storageName reader 读取 missing 且不收到变更标记", async () => { + await expect(readStorageValues(page, isolatedReader)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: "missing", + syncReadOfAsyncWrite: "missing", + }); + // shared marker 与 isolated reader 的异步读取均已完成,无需用固定长等待证明隔离事件不存在。 + expect(await page.locator("html").getAttribute(isolatedReader.remoteChangeAttribute)).toBeNull(); + }); + } finally { + await page.close(); + } + }); + + test("后台脚本与前台脚本应跨运行环境双向共享值,并报告远程变更", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createCrossContextScriptPair(); + await installCrossContextScriptPair(context, extensionId, pair); + + const page = await context.newPage(); + try { + await page.goto(`${TARGET_ORIGIN}/page?cross-context=${pair.token}`, { waitUntil: "domcontentloaded" }); + const root = page.locator("html"); + await expect(root).toHaveAttribute(pair.readyAttribute, "true", { timeout: 20_000 }); + await page.evaluate((eventName) => document.dispatchEvent(new Event(eventName)), pair.runEvent); + const result = await waitForJsonAttribute(page, pair.resultAttribute); + expect(result).toEqual({ + ok: true, + backgroundValue: `background-value-${pair.token}`, + foregroundObservedRemote: true, + backgroundObservedRemote: true, + }); + } finally { + await page.close(); + } + }); + + test("storageName owner 应在 2→1→0 生命周期中保留并最终清理共享值", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createSharedScriptPair(); + + const owners = await test.step("seed:两个 owner 写入并读回共享值", async () => { + await installSharedScripts(context, extensionId, pair); + + const optionsPage = await openOptionsPage(context, extensionId); + let identities: ScriptIdentity[]; + try { + identities = await readScriptIdentities(optionsPage, [pair.writerName, pair.reader.name]); + } finally { + await optionsPage.close(); + } + const writer = identities.find((script) => script.name === pair.writerName); + const reader = identities.find((script) => script.name === pair.reader.name); + expect(writer, "未找到 storageName 写入脚本").toBeDefined(); + expect(reader, "未找到 storageName 读取脚本").toBeDefined(); + + const seedPage = await context.newPage(); + try { + await seedPage.goto(`${TARGET_ORIGIN}/page?seed=${pair.token}`, { waitUntil: "domcontentloaded" }); + await waitForReady(seedPage, pair, [pair.reader], true); + await triggerWrite(seedPage, pair); + await expect(readStorageValues(seedPage, pair.reader)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: pair.syncValue, + syncReadOfAsyncWrite: pair.asyncValue, + }); + } finally { + await seedPage.close(); + } + + return { writer: writer!, reader: reader! }; + }); + + await test.step("2→1 owner:purge writer 后 restore reader 仍保留旧值", async () => { + const optionsPage = await openOptionsPage(context, extensionId); + try { + expect(await runScriptAction(optionsPage, "deletes", [owners.writer.uuid, owners.reader.uuid])).toBe( + true + ); + expect(await runScriptAction(optionsPage, "purges", [owners.writer.uuid])).toBe(true); + await expect.poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.reader.name])).toEqual([]); + const restore = await runScriptAction<{ restored: string[]; conflicts: unknown[] }>(optionsPage, "restores", [ + owners.reader.uuid, + ]); + expect(restore).toEqual({ restored: [owners.reader.uuid], conflicts: [] }); + await expect + .poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.reader.name])) + .toEqual([expect.objectContaining({ name: pair.reader.name })]); + } finally { + await optionsPage.close(); + } + + const restoredPage = await context.newPage(); + try { + await restoredPage.goto(`${TARGET_ORIGIN}/page?one-owner=${pair.token}`, { + waitUntil: "domcontentloaded", + }); + await waitForReady(restoredPage, pair, [pair.reader], false); + await expect(readStorageValues(restoredPage, pair.reader)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: pair.syncValue, + syncReadOfAsyncWrite: pair.asyncValue, + }); + } finally { + await restoredPage.close(); + } + }); + + await test.step("1→0 owner:purge reader 后重装不得读到旧值", async () => { + const optionsPage = await openOptionsPage(context, extensionId); + try { + expect(await runScriptAction(optionsPage, "deletes", [owners.reader.uuid])).toBe(true); + expect(await runScriptAction(optionsPage, "purges", [owners.reader.uuid])).toBe(true); + await expect.poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.reader.name])).toEqual([]); + } finally { + await optionsPage.close(); + } + + await installScriptByCode(context, extensionId, pair.reader.code); + const reinstalledPage = await context.newPage(); + try { + await reinstalledPage.goto(`${TARGET_ORIGIN}/page?zero-owners=${pair.token}`, { + waitUntil: "domcontentloaded", + }); + await waitForReady(reinstalledPage, pair, [pair.reader], false); + await expect(readStorageValues(reinstalledPage, pair.reader)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: "missing", + syncReadOfAsyncWrite: "missing", + }); + } finally { + await reinstalledPage.close(); + } + }); + }); +}); diff --git a/e2e/subscribe-lifecycle.spec.ts b/e2e/subscribe-lifecycle.spec.ts new file mode 100644 index 000000000..ee299165a --- /dev/null +++ b/e2e/subscribe-lifecycle.spec.ts @@ -0,0 +1,155 @@ +import { createServer } from "http"; +import type { AddressInfo } from "net"; +import type { BrowserContext, Page } from "@playwright/test"; +import { testWithUserScripts as test, expect } from "./fixtures"; + +const SUBSCRIBE_NAME = "E2E Subscribe Lifecycle"; + +function subscribeBody(version: "1.0.0" | "2.0.0" | "3.0.0", scriptUrls: string[]): string { + return `// ==UserSubscribe== +// @name ${SUBSCRIBE_NAME} +// @description subscribe lifecycle e2e +// @version ${version} +// @author E2E +${scriptUrls.map((url) => `// @scriptURL ${url}`).join("\n")} +// ==/UserSubscribe==`; +} + +function childScript(name: string, marker: string, origin: string): string { + return `// ==UserScript== +// @name ${name} +// @namespace ${origin}/subscribe-lifecycle +// @version 1.0.0 +// @match ${origin}/* +// @grant none +// ==/UserScript== + +document.documentElement.setAttribute(${JSON.stringify(marker)}, "true"); +`; +} + +async function installSubscription(context: BrowserContext, extensionId: string, subscribeUrl: string): Promise { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/src/install.html?url=${subscribeUrl}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByText(SUBSCRIBE_NAME).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("install-primary")).toBeEnabled(); + await Promise.all([page.waitForEvent("close", { timeout: 10_000 }), page.getByTestId("install-primary").click()]); +} + +async function openTarget(context: BrowserContext, origin: string, suffix: string): Promise { + const page = await context.newPage(); + await page.goto(`${origin}/${suffix}`, { waitUntil: "domcontentloaded" }); + return page; +} + +test.describe("订阅真实生命周期", () => { + test("订阅更新应增删关联脚本,删除订阅后脚本不再执行", async ({ context, extensionId }) => { + let subscribeVersion: "1.0.0" | "2.0.0" | "3.0.0" = "1.0.0"; + let origin = ""; + const server = createServer((req, res) => { + const scriptAUrl = `${origin}/sub-a.user.js`; + const scriptBUrl = `${origin}/sub-b.user.js`; + const scriptCUrl = `${origin}/sub-c.user.js`; + const bodies = new Map([ + [ + "/lifecycle.user.sub.js", + subscribeBody(subscribeVersion, [ + scriptAUrl, + ...(subscribeVersion === "3.0.0" ? [] : [scriptBUrl]), + ...(subscribeVersion === "1.0.0" ? [] : [scriptCUrl]), + ]), + ], + ["/sub-a.user.js", childScript("E2E Subscribe A", "data-subscribe-a", origin)], + ["/sub-b.user.js", childScript("E2E Subscribe B", "data-subscribe-b", origin)], + ["/sub-c.user.js", childScript("E2E Subscribe C", "data-subscribe-c", origin)], + ]); + const path = new URL(req.url || "/", origin).pathname; + const body = bodies.get(path); + res.setHeader("Cache-Control", "no-store"); + if (body) { + res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); + res.end(body); + } else { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(""); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const subscribeUrl = `${origin}/lifecycle.user.sub.js`; + + try { + await installSubscription(context, extensionId, subscribeUrl); + const initial = await openTarget(context, origin, "initial"); + await expect(initial.locator("html")).toHaveAttribute("data-subscribe-a", "true", { timeout: 20_000 }); + await expect(initial.locator("html")).toHaveAttribute("data-subscribe-b", "true", { timeout: 20_000 }); + await expect(initial.locator("html")).not.toHaveAttribute("data-subscribe-c", "true"); + await initial.close(); + + subscribeVersion = "2.0.0"; + await installSubscription(context, extensionId, subscribeUrl); + const updated = await openTarget(context, origin, "updated"); + await expect + .poll( + async () => { + await updated.reload({ waitUntil: "domcontentloaded" }); + const root = updated.locator("html"); + return { + a: await root.getAttribute("data-subscribe-a"), + b: await root.getAttribute("data-subscribe-b"), + c: await root.getAttribute("data-subscribe-c"), + }; + }, + { timeout: 30_000, intervals: [250, 500, 1_000] } + ) + .toEqual({ a: "true", b: "true", c: "true" }); + + subscribeVersion = "3.0.0"; + await installSubscription(context, extensionId, subscribeUrl); + await expect + .poll( + async () => { + await updated.reload({ waitUntil: "domcontentloaded" }); + const root = updated.locator("html"); + return { + a: await root.getAttribute("data-subscribe-a"), + b: await root.getAttribute("data-subscribe-b"), + c: await root.getAttribute("data-subscribe-c"), + }; + }, + { timeout: 30_000, intervals: [250, 500, 1_000] } + ) + .toEqual({ a: "true", b: null, c: "true" }); + await updated.close(); + + const optionsPage = await context.newPage(); + await optionsPage.goto(`chrome-extension://${extensionId}/src/options.html#/subscribe`, { + waitUntil: "domcontentloaded", + }); + const row = optionsPage + .getByText(SUBSCRIBE_NAME, { exact: true }) + .locator("xpath=ancestor::div[contains(@class, 'group/row')]"); + await expect(row).toBeVisible({ timeout: 20_000 }); + await row.getByRole("button", { name: /delete|删除/i }).click(); + await optionsPage + .getByRole("alertdialog") + .getByRole("button", { name: /delete|删除/i }) + .click(); + await expect(optionsPage.getByText(SUBSCRIBE_NAME, { exact: true })).toHaveCount(0, { timeout: 20_000 }); + + const afterDelete = await openTarget(context, origin, "deleted"); + await expect(afterDelete.locator("html")).not.toHaveAttribute("data-subscribe-a", "true"); + await expect(afterDelete.locator("html")).not.toHaveAttribute("data-subscribe-c", "true"); + await afterDelete.close(); + await optionsPage.close(); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } + }); +});