From 49e8fe22b5c0c865b78bff337e880634f1a00225 Mon Sep 17 00:00:00 2001 From: CodFrm Date: Tue, 21 Jul 2026 12:04:01 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=85=20add=20@storageName=20shared=20s?= =?UTF-8?q?torage=20e2e=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/storage-name.spec.ts | 372 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 e2e/storage-name.spec.ts diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts new file mode 100644 index 000000000..9193f4ad6 --- /dev/null +++ b/e2e/storage-name.spec.ts @@ -0,0 +1,372 @@ +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; + storageNames: 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 SharedScriptPair = { + token: string; + storageName: string; + syncKey: string; + asyncKey: string; + syncValue: string; + asyncValue: string; + writerName: string; + readerName: string; + writeEvent: string; + readEvent: string; + writerReadyAttribute: string; + readerReadyAttribute: string; + writerResultAttribute: string; + readerResultAttribute: string; + remoteChangeAttribute: string; + writerCode: string; + readerCode: string; +}; + +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 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 readerName = `E2E storageName reader ${token}`; + const writeEvent = `scriptcat-e2e-${token}-write`; + const readEvent = `scriptcat-e2e-${token}-read`; + const writerReadyAttribute = `data-sc-${token}-writer-ready`; + const readerReadyAttribute = `data-sc-${token}-reader-ready`; + const writerResultAttribute = `data-sc-${token}-writer-result`; + const readerResultAttribute = `data-sc-${token}-reader-result`; + const remoteChangeAttribute = `data-sc-${token}-remote-change`; + + 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 readerCode = `// ==UserScript== +// @name ${readerName} +// @namespace https://e2e.scriptcat.test/${token}/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(readerResultAttribute)}, + JSON.stringify({ + ok: true, + asyncReadOfSyncWrite: await GM.getValue(${JSON.stringify(syncKey)}, "missing"), + syncReadOfAsyncWrite: GM_getValue(${JSON.stringify(asyncKey)}, "missing"), + }), + ); + } catch (error) { + setReaderMarker( + ${JSON.stringify(readerResultAttribute)}, + JSON.stringify({ ok: false, error: String(error) }), + ); + } +}); + +setReaderMarker(${JSON.stringify(readerReadyAttribute)}, "true"); +`; + + return { + token, + storageName, + syncKey, + asyncKey, + syncValue, + asyncValue, + writerName, + readerName, + writeEvent, + readEvent, + writerReadyAttribute, + readerReadyAttribute, + writerResultAttribute, + readerResultAttribute, + remoteChangeAttribute, + writerCode, + readerCode, + }; +} + +async function readScriptIdentities(page: Page, names: string[]): Promise { + return page.evaluate(async (expectedNames) => { + type InstalledScript = { + name: string; + uuid: string; + metadata?: { storagename?: 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, + storageNames: script.metadata?.storagename || [], + })); + }, names); +} + +async function installSharedScriptPair( + context: BrowserContext, + extensionId: string, + pair: SharedScriptPair +): Promise { + await installScriptByCode(context, extensionId, pair.writerCode); + await installScriptByCode(context, extensionId, pair.readerCode); + autoApprovePermissions(context); + + const optionsPage = await openOptionsPage(context, extensionId); + try { + return await readScriptIdentities(optionsPage, [pair.writerName, pair.readerName]); + } finally { + await optionsPage.close(); + } +} + +function assertSharedMetadataUsesDifferentIdentities(identities: ScriptIdentity[], pair: SharedScriptPair): void { + expect(identities).toHaveLength(2); + const writer = identities.find((script) => script.name === pair.writerName); + const reader = identities.find((script) => script.name === pair.readerName); + expect(writer, "未找到 storageName 写入脚本").toBeDefined(); + expect(reader, "未找到 storageName 读取脚本").toBeDefined(); + expect(writer!.storageNames).toEqual([pair.storageName]); + expect(reader!.storageNames).toEqual([pair.storageName]); + expect(writer!.uuid).not.toBe(reader!.uuid); +} + +async function waitForReady(page: Page, pair: SharedScriptPair, includeWriter: boolean): Promise { + const root = page.locator("html"); + await expect(root).toHaveAttribute(pair.readerReadyAttribute, "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 readSharedValues(page: Page, pair: SharedScriptPair): Promise { + await page.evaluate((eventName) => document.dispatchEvent(new Event(eventName)), pair.readEvent); + return waitForJsonAttribute(page, pair.readerResultAttribute); +} + +async function deleteAndPurgeScript(page: Page, uuid: string): Promise { + // 使用 options 页产品代码调用的同一消息端点,覆盖真实 SW 删除链路且不依赖界面语言。 + const results = await page.evaluate(async (scriptUuid) => { + const send = (action: string) => + chrome.runtime.sendMessage({ action, data: [scriptUuid] }) as Promise>; + return { + deleted: await send("serviceWorker/script/deletes"), + purged: await send("serviceWorker/script/purges"), + }; + }, uuid); + + expect(results.deleted.code || 0, results.deleted.message).toBe(0); + expect(results.deleted.data).toBe(true); + expect(results.purged.code || 0, results.purged.message).toBe(0); + expect(results.purged.data).toBe(true); +} + +test.describe("@storageName 真实浏览器共享存储", () => { + test.setTimeout(180_000); + + test("两个不同 UUID 的普通脚本应共享同步与异步值,并报告远程变更", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createSharedScriptPair(); + const identities = await installSharedScriptPair(context, extensionId, pair); + assertSharedMetadataUsesDifferentIdentities(identities, pair); + + const page = await context.newPage(); + try { + await page.goto(`${TARGET_ORIGIN}/page?shared=${pair.token}`, { waitUntil: "domcontentloaded" }); + await waitForReady(page, pair, true); + await triggerWrite(page, pair); + + const remoteChange = await waitForJsonAttribute(page, pair.remoteChangeAttribute); + expect(remoteChange).toEqual({ + name: pair.asyncKey, + oldValue: null, + newValue: pair.asyncValue, + remote: true, + }); + await expect(readSharedValues(page, pair)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: pair.syncValue, + syncReadOfAsyncWrite: pair.asyncValue, + }); + } finally { + await page.close(); + } + }); + + test("彻底删除写入脚本后,另一个共享脚本应在新页面继续读取值", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createSharedScriptPair(); + const identities = await installSharedScriptPair(context, extensionId, pair); + assertSharedMetadataUsesDifferentIdentities(identities, pair); + + const writer = identities.find((script) => script.name === pair.writerName)!; + const initialPage = await context.newPage(); + try { + await initialPage.goto(`${TARGET_ORIGIN}/page?before-purge=${pair.token}`, { + waitUntil: "domcontentloaded", + }); + await waitForReady(initialPage, pair, true); + await triggerWrite(initialPage, pair); + await expect(readSharedValues(initialPage, pair)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: pair.syncValue, + syncReadOfAsyncWrite: pair.asyncValue, + }); + } finally { + await initialPage.close(); + } + + const optionsPage = await openOptionsPage(context, extensionId); + try { + await deleteAndPurgeScript(optionsPage, writer.uuid); + await expect + .poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.readerName])) + .toEqual([expect.objectContaining({ name: pair.readerName, storageNames: [pair.storageName] })]); + } finally { + await optionsPage.close(); + } + + const reloadedPage = await context.newPage(); + try { + await reloadedPage.goto(`${TARGET_ORIGIN}/page?after-purge=${pair.token}`, { + waitUntil: "domcontentloaded", + }); + await waitForReady(reloadedPage, pair, false); + await expect(readSharedValues(reloadedPage, pair)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: pair.syncValue, + syncReadOfAsyncWrite: pair.asyncValue, + }); + } finally { + await reloadedPage.close(); + } + }); +}); From 60df7bbc05b73904cc0f6bf1e859e822eac96f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 13:48:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=85=20cover=20storageName=20across=20?= =?UTF-8?q?background=20and=20page=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/storage-name.spec.ts | 163 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 9193f4ad6..04e4cc2d9 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -36,6 +36,26 @@ type WriterResult = { error?: string; }; +type CrossContextResult = { + ok: boolean; + backgroundValue?: string; + foregroundObservedRemote?: boolean; + backgroundObservedRemote?: boolean; + error?: string; +}; + +type CrossContextScriptPair = { + token: string; + storageName: string; + backgroundName: string; + foregroundName: string; + runEvent: string; + readyAttribute: string; + resultAttribute: string; + backgroundCode: string; + foregroundCode: string; +}; + type SharedScriptPair = { token: string; storageName: string; @@ -192,6 +212,96 @@ setReaderMarker(${JSON.stringify(readerReadyAttribute)}, "true"); }; } +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, + storageName, + backgroundName, + foregroundName, + runEvent, + readyAttribute, + resultAttribute, + backgroundCode, + foregroundCode, + }; +} + async function readScriptIdentities(page: Page, names: string[]): Promise { return page.evaluate(async (expectedNames) => { type InstalledScript = { @@ -234,6 +344,33 @@ async function installSharedScriptPair( } } +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, pair.foregroundName]); + 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); + return identities; + } finally { + await optionsPage.close(); + } +} + function assertSharedMetadataUsesDifferentIdentities(identities: ScriptIdentity[], pair: SharedScriptPair): void { expect(identities).toHaveLength(2); const writer = identities.find((script) => script.name === pair.writerName); @@ -321,6 +458,32 @@ test.describe("@storageName 真实浏览器共享存储", () => { } }); + test("后台脚本与前台脚本应跨运行环境双向共享值,并报告远程变更", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createCrossContextScriptPair(); + const identities = await installCrossContextScriptPair(context, extensionId, pair); + expect(identities).toHaveLength(2); + expect(identities.map((script) => script.storageNames)).toEqual([[pair.storageName], [pair.storageName]]); + expect(new Set(identities.map((script) => script.uuid)).size).toBe(2); + + 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("彻底删除写入脚本后,另一个共享脚本应在新页面继续读取值", async ({ context, extensionId }) => { await serveTargetPage(context); const pair = createSharedScriptPair(); From 2f7978e8943dc4226c5480453ef1415f5d1be5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 13:59:18 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=85=20=E8=A1=A5=E5=85=A8=20@storageNa?= =?UTF-8?q?me=20=E9=9A=94=E7=A6=BB=E4=B8=8E=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/storage-name.spec.ts | 114 +++++++++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 04e4cc2d9..3bb6d1347 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -86,9 +86,10 @@ async function serveTargetPage(context: BrowserContext): Promise { ); } -function createSharedScriptPair(): SharedScriptPair { +function createSharedScriptPair(options: { storageName?: string; readerStorageName?: string } = {}): SharedScriptPair { const token = randomUUID().replaceAll("-", ""); - const storageName = `scriptcat-e2e-storage-${token}`; + const storageName = options.storageName || `scriptcat-e2e-storage-${token}`; + const readerStorageName = options.readerStorageName || storageName; const syncKey = `sync-${token}`; const asyncKey = `async-${token}`; const syncValue = `sync-value-${token}`; @@ -149,7 +150,7 @@ setWriterMarker(${JSON.stringify(writerReadyAttribute)}, "true"); // @grant GM_getValue // @grant GM.getValue // @grant GM_addValueChangeListener -// @storageName ${storageName} +// @storageName ${readerStorageName} // ==/UserScript== const setReaderMarker = (name, value) => { @@ -409,21 +410,18 @@ async function readSharedValues(page: Page, pair: SharedScriptPair): Promise(page, pair.readerResultAttribute); } -async function deleteAndPurgeScript(page: Page, uuid: string): Promise { - // 使用 options 页产品代码调用的同一消息端点,覆盖真实 SW 删除链路且不依赖界面语言。 - const results = await page.evaluate(async (scriptUuid) => { - const send = (action: string) => - chrome.runtime.sendMessage({ action, data: [scriptUuid] }) as Promise>; - return { - deleted: await send("serviceWorker/script/deletes"), - purged: await send("serviceWorker/script/purges"), - }; - }, uuid); - - expect(results.deleted.code || 0, results.deleted.message).toBe(0); - expect(results.deleted.data).toBe(true); - expect(results.purged.code || 0, results.purged.message).toBe(0); - expect(results.purged.data).toBe(true); +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 真实浏览器共享存储", () => { @@ -484,13 +482,36 @@ test.describe("@storageName 真实浏览器共享存储", () => { } }); - test("彻底删除写入脚本后,另一个共享脚本应在新页面继续读取值", async ({ context, extensionId }) => { + test("不同 storageName 的脚本使用相同 key 时应严格隔离", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createSharedScriptPair({ + readerStorageName: `scriptcat-e2e-isolated-${randomUUID().replaceAll("-", "")}`, + }); + await installSharedScriptPair(context, extensionId, pair); + + const page = await context.newPage(); + try { + await page.goto(`${TARGET_ORIGIN}/page?isolated=${pair.token}`, { waitUntil: "domcontentloaded" }); + await waitForReady(page, pair, true); + await triggerWrite(page, pair); + await expect(readSharedValues(page, pair)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: "missing", + syncReadOfAsyncWrite: "missing", + }); + } finally { + await page.close(); + } + }); + + test("共享脚本仅剩回收站 owner 时,purge 其他脚本不得清理共享值", async ({ context, extensionId }) => { await serveTargetPage(context); const pair = createSharedScriptPair(); const identities = await installSharedScriptPair(context, extensionId, pair); assertSharedMetadataUsesDifferentIdentities(identities, pair); const writer = identities.find((script) => script.name === pair.writerName)!; + const reader = identities.find((script) => script.name === pair.readerName)!; const initialPage = await context.newPage(); try { await initialPage.goto(`${TARGET_ORIGIN}/page?before-purge=${pair.token}`, { @@ -509,10 +530,16 @@ test.describe("@storageName 真实浏览器共享存储", () => { const optionsPage = await openOptionsPage(context, extensionId); try { - await deleteAndPurgeScript(optionsPage, writer.uuid); + expect(await runScriptAction(optionsPage, "deletes", [writer.uuid, reader.uuid])).toBe(true); + expect(await runScriptAction(optionsPage, "purges", [writer.uuid])).toBe(true); + await expect.poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.readerName])).toEqual([]); + const restore = await runScriptAction<{ restored: string[]; conflicts: unknown[] }>(optionsPage, "restores", [ + reader.uuid, + ]); + expect(restore).toEqual({ restored: [reader.uuid], conflicts: [] }); await expect .poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.readerName])) - .toEqual([expect.objectContaining({ name: pair.readerName, storageNames: [pair.storageName] })]); + .toEqual([expect.objectContaining({ name: pair.readerName })]); } finally { await optionsPage.close(); } @@ -532,4 +559,49 @@ test.describe("@storageName 真实浏览器共享存储", () => { await reloadedPage.close(); } }); + + test("最后一个 storageName owner 被 purge 后,后续安装的脚本不得读到旧值", async ({ context, extensionId }) => { + await serveTargetPage(context); + const pair = createSharedScriptPair(); + const identities = await installSharedScriptPair(context, extensionId, pair); + assertSharedMetadataUsesDifferentIdentities(identities, pair); + + const initialPage = await context.newPage(); + try { + await initialPage.goto(`${TARGET_ORIGIN}/page?before-final-purge=${pair.token}`, { + waitUntil: "domcontentloaded", + }); + await waitForReady(initialPage, pair, true); + await triggerWrite(initialPage, pair); + } finally { + await initialPage.close(); + } + + const optionsPage = await openOptionsPage(context, extensionId); + try { + const uuids = identities.map((script) => script.uuid); + expect(await runScriptAction(optionsPage, "deletes", uuids)).toBe(true); + expect(await runScriptAction(optionsPage, "purges", uuids)).toBe(true); + await expect.poll(() => readScriptIdentities(optionsPage, [pair.writerName, pair.readerName])).toEqual([]); + } finally { + await optionsPage.close(); + } + + await installScriptByCode(context, extensionId, pair.readerCode); + autoApprovePermissions(context); + const reinstalledPage = await context.newPage(); + try { + await reinstalledPage.goto(`${TARGET_ORIGIN}/page?after-final-purge=${pair.token}`, { + waitUntil: "domcontentloaded", + }); + await waitForReady(reinstalledPage, pair, false); + await expect(readSharedValues(reinstalledPage, pair)).resolves.toEqual({ + ok: true, + asyncReadOfSyncWrite: "missing", + syncReadOfAsyncWrite: "missing", + }); + } finally { + await reinstalledPage.close(); + } + }); }); From d947999aa20c114a30a3f3bbf702e0726adc3191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 14:41:34 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=85=20=E8=A1=A5=E5=85=A8=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=20E2E=20=E5=B9=B6?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E9=87=8D=E5=A4=8D=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/agent-navigation.spec.ts | 20 +--- e2e/backup-zip.spec.ts | 57 ++++++++++- e2e/gm-api.spec.ts | 30 +++++- e2e/gm-xhr-site-access.spec.ts | 19 +--- e2e/install.spec.ts | 95 +++++++++++------- e2e/script-editor.spec.ts | 19 ---- e2e/settings.spec.ts | 34 ------- e2e/standalone-pages-smoke.spec.ts | 50 --------- e2e/subscribe-lifecycle.spec.ts | 156 +++++++++++++++++++++++++++++ 9 files changed, 297 insertions(+), 183 deletions(-) delete mode 100644 e2e/settings.spec.ts create mode 100644 e2e/subscribe-lifecycle.spec.ts 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..73c8cc5f0 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -1,54 +1,73 @@ -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 page.getByTestId("install-primary").click(); + await page.waitForEvent("close", { timeout: 10_000 }); +} + +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/subscribe-lifecycle.spec.ts b/e2e/subscribe-lifecycle.spec.ts new file mode 100644 index 000000000..7d6d124cb --- /dev/null +++ b/e2e/subscribe-lifecycle.spec.ts @@ -0,0 +1,156 @@ +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 page.getByTestId("install-primary").click(); + await page.waitForEvent("close", { timeout: 10_000 }); +} + +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()))); + } + }); +}); From d2b4e90784857318436eea815226d27ad1b4db79 Mon Sep 17 00:00:00 2001 From: CodFrm Date: Tue, 21 Jul 2026 18:35:54 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=85=20=E5=90=88=E5=B9=B6=20@storageNa?= =?UTF-8?q?me=20=E5=9C=BA=E6=99=AF=E5=B9=B6=E4=BF=AE=E5=A4=8D=20E2E=20?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/install.spec.ts | 3 +- e2e/storage-name.spec.ts | 476 ++++++++++++++++---------------- e2e/subscribe-lifecycle.spec.ts | 3 +- 3 files changed, 233 insertions(+), 249 deletions(-) diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index 73c8cc5f0..81112431d 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -32,8 +32,7 @@ async function openInstallPage(context: BrowserContext, extensionId: string): Pr } async function installFromPage(page: Page): Promise { - await page.getByTestId("install-primary").click(); - await page.waitForEvent("close", { timeout: 10_000 }); + await Promise.all([page.waitForEvent("close", { timeout: 10_000 }), page.getByTestId("install-primary").click()]); } async function expectExecutedVersion(context: BrowserContext, version: string): Promise { diff --git a/e2e/storage-name.spec.ts b/e2e/storage-name.spec.ts index 3bb6d1347..66a6101ac 100644 --- a/e2e/storage-name.spec.ts +++ b/e2e/storage-name.spec.ts @@ -8,7 +8,6 @@ const TARGET_ORIGIN = "http://storage-name.test"; type ScriptIdentity = { name: string; uuid: string; - storageNames: string[]; }; type ScriptActionResponse = { @@ -46,9 +45,7 @@ type CrossContextResult = { type CrossContextScriptPair = { token: string; - storageName: string; backgroundName: string; - foregroundName: string; runEvent: string; readyAttribute: string; resultAttribute: string; @@ -56,6 +53,15 @@ type CrossContextScriptPair = { foregroundCode: string; }; +type StorageReaderScript = { + name: string; + readEvent: string; + readyAttribute: string; + resultAttribute: string; + remoteChangeAttribute: string; + code: string; +}; + type SharedScriptPair = { token: string; storageName: string; @@ -64,16 +70,11 @@ type SharedScriptPair = { syncValue: string; asyncValue: string; writerName: string; - readerName: string; writeEvent: string; - readEvent: string; writerReadyAttribute: string; - readerReadyAttribute: string; writerResultAttribute: string; - readerResultAttribute: string; - remoteChangeAttribute: string; writerCode: string; - readerCode: string; + reader: StorageReaderScript; }; async function serveTargetPage(context: BrowserContext): Promise { @@ -86,23 +87,84 @@ async function serveTargetPage(context: BrowserContext): Promise { ); } -function createSharedScriptPair(options: { storageName?: string; readerStorageName?: string } = {}): SharedScriptPair { +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 = options.storageName || `scriptcat-e2e-storage-${token}`; - const readerStorageName = options.readerStorageName || storageName; + 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 readerName = `E2E storageName reader ${token}`; const writeEvent = `scriptcat-e2e-${token}-write`; - const readEvent = `scriptcat-e2e-${token}-read`; const writerReadyAttribute = `data-sc-${token}-writer-ready`; - const readerReadyAttribute = `data-sc-${token}-reader-ready`; const writerResultAttribute = `data-sc-${token}-writer-result`; - const readerResultAttribute = `data-sc-${token}-reader-result`; - const remoteChangeAttribute = `data-sc-${token}-remote-change`; const writerCode = `// ==UserScript== // @name ${writerName} @@ -140,57 +202,7 @@ document.addEventListener( setWriterMarker(${JSON.stringify(writerReadyAttribute)}, "true"); `; - - const readerCode = `// ==UserScript== -// @name ${readerName} -// @namespace https://e2e.scriptcat.test/${token}/reader -// @version 1.0.0 -// @match ${TARGET_ORIGIN}/* -// @run-at document-start -// @grant GM_getValue -// @grant GM.getValue -// @grant GM_addValueChangeListener -// @storageName ${readerStorageName} -// ==/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(readerResultAttribute)}, - JSON.stringify({ - ok: true, - asyncReadOfSyncWrite: await GM.getValue(${JSON.stringify(syncKey)}, "missing"), - syncReadOfAsyncWrite: GM_getValue(${JSON.stringify(asyncKey)}, "missing"), - }), - ); - } catch (error) { - setReaderMarker( - ${JSON.stringify(readerResultAttribute)}, - JSON.stringify({ ok: false, error: String(error) }), - ); - } -}); - -setReaderMarker(${JSON.stringify(readerReadyAttribute)}, "true"); -`; + const reader = createStorageReaderScript({ token, role: "shared", storageName, syncKey, asyncKey }); return { token, @@ -200,16 +212,11 @@ setReaderMarker(${JSON.stringify(readerReadyAttribute)}, "true"); syncValue, asyncValue, writerName, - readerName, writeEvent, - readEvent, writerReadyAttribute, - readerReadyAttribute, writerResultAttribute, - readerResultAttribute, - remoteChangeAttribute, writerCode, - readerCode, + reader, }; } @@ -292,9 +299,7 @@ setMarker(${JSON.stringify(readyAttribute)}, "true"); return { token, - storageName, backgroundName, - foregroundName, runEvent, readyAttribute, resultAttribute, @@ -308,7 +313,6 @@ async function readScriptIdentities(page: Page, names: string[]): Promise