diff --git a/packages/message/mock_message.ts b/packages/message/mock_message.ts index 8839c9a..9c09b97 100644 --- a/packages/message/mock_message.ts +++ b/packages/message/mock_message.ts @@ -1,5 +1,6 @@ import EventEmitter from "eventemitter3"; import { Message, MessageConnect, MessageSend } from "./server"; +import { sleep } from "@App/pkg/utils/utils"; export class MockMessageConnect implements MessageConnect { constructor(protected EE: EventEmitter) {} @@ -24,16 +25,16 @@ export class MockMessageConnect implements MessageConnect { } export class MockMessageSend implements MessageSend { - constructor( - protected EE: EventEmitter, - ) {} + constructor(protected EE: EventEmitter) {} connect(data: any): Promise { return new Promise((resolve) => { const EE = new EventEmitter(); const con = new MockMessageConnect(EE); - this.EE.emit("connect", data, con); resolve(con); + sleep(1).then(() => { + this.EE.emit("connect", data, con); + }); }); } diff --git a/src/app/service/offscreen/gm_api.ts b/src/app/service/offscreen/gm_api.ts index 68fce6a..d1ccbd6 100644 --- a/src/app/service/offscreen/gm_api.ts +++ b/src/app/service/offscreen/gm_api.ts @@ -3,27 +3,78 @@ import { Group, MessageConnect } from "@Packages/message/server"; export default class GMApi { constructor(private group: Group) {} - xmlHttpRequest(params: GMSend.XHRDetails, con: MessageConnect | null) { + dealXhrResponse(con: MessageConnect, details: GMSend.XHRDetails, event: string, xhr: XMLHttpRequest, data?: any) { + const finalUrl = xhr.responseURL || details.url; + // 判断是否有headerFlag-final-url,有则替换finalUrl + let response: GMTypes.XHRResponse = { + finalUrl, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + // responseHeaders: xhr.getAllResponseHeaders().replace(removeXCat, ""), + responseType: details.responseType, + }; + if (data) { + response = Object.assign(response, data); + } + con.sendMessage({ + action: event, + data: response, + }); + return response; + } + + xmlHttpRequest(details: GMSend.XHRDetails, con: MessageConnect | null) { const xhr = new XMLHttpRequest(); - xhr.open(params.method || "GET", params.url); + xhr.open(details.method || "GET", details.url); // 添加header - if (params.headers) { - for (const key in params.headers) { - xhr.setRequestHeader(key, params.headers[key]); + if (details.headers) { + for (const key in details.headers) { + xhr.setRequestHeader(key, details.headers[key]); } } - xhr.onload = function () { - console.log(xhr.getAllResponseHeaders()); - con?.sendMessage({ - action: "onload", - data: { - status: xhr.status, - statusText: xhr.statusText, - response: xhr.responseText, - }, - }); + xhr.onload = () => { + this.dealXhrResponse(con!, details, "onload", xhr); }; + xhr.onloadstart = () => { + this.dealXhrResponse(con!, details, "onloadstart", xhr); + }; + xhr.onloadend = () => { + this.dealXhrResponse(con!, details, "onloadend", xhr); + }; + xhr.onabort = () => { + this.dealXhrResponse(con!, details, "onabort", xhr); + }; + xhr.onerror = () => { + this.dealXhrResponse(con!, details, "onerror", xhr); + }; + xhr.onprogress = (event) => { + const respond: GMTypes.XHRProgress = { + done: xhr.DONE, + lengthComputable: event.lengthComputable, + loaded: event.loaded, + total: event.total, + totalSize: event.total, + }; + this.dealXhrResponse(con!, details, "onprogress", xhr, respond); + }; + xhr.onreadystatechange = () => { + this.dealXhrResponse(con!, details, "onreadystatechange", xhr); + }; + xhr.ontimeout = () => { + con?.sendMessage({ action: "ontimeout", data: {} }); + }; + + if (details.timeout) { + xhr.timeout = details.timeout; + } + if (details.overrideMimeType) { + xhr.overrideMimeType(details.overrideMimeType); + } xhr.send(); + con?.onDisconnect(() => { + xhr.abort(); + }); } init() { diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 3e52d3f..483bb33 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -137,7 +137,6 @@ export default class GMApi { return Promise.reject(new Error("param is failed")); } const params = request.params[0] as GMSend.XHRDetails; - console.log("xml request", request, con); // 先处理unsafe hearder // 关联自己生成的请求id与chrome.webRequest的请求id const requestId = 10000 + (await incr(Cache.getInstance(), "gmXhrRequestId", 1)); @@ -155,13 +154,15 @@ export default class GMApi { details.responseHeaders?.forEach((header) => { responseHeader += header.name + ": " + header.value + "\n"; }); - console.log("处理", details, responseHeader); } ); // 再发送到offscreen, 处理请求 const offscreenCon = await connect(this.sender, "gmApi/xmlHttpRequest", request.params[0]); - offscreenCon.onMessage((msg) => { - console.log("offscreenCon", msg); + offscreenCon.onMessage((msg: { action: string; data: any }) => { + // 发送到content + // 替换msg.data.responseHeaders + msg.data.responseHeaders = responseHeader; + con.sendMessage(msg); }); } diff --git a/src/pkg/utils/utils.ts b/src/pkg/utils/utils.ts index 3f00f48..eae72c7 100644 --- a/src/pkg/utils/utils.ts +++ b/src/pkg/utils/utils.ts @@ -211,3 +211,9 @@ export function checkSilenceUpdate(oldMeta: Metadata, newMeta: Metadata): boolea } return true; } + +export function sleep(time: number) { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +} diff --git a/src/runtime/content/gm_api.ts b/src/runtime/content/gm_api.ts index bece83b..2efacdf 100644 --- a/src/runtime/content/gm_api.ts +++ b/src/runtime/content/gm_api.ts @@ -219,8 +219,37 @@ export default class GMApi { let connect: MessageConnect; this.connect("GM_xmlhttpRequest", [param]).then((con) => { connect = con; - con.onMessage((data) => { - console.log("data", data); + con.onMessage((data: { action: string; data: any }) => { + // 处理返回 + switch (data.action) { + case "onload": + details.onload?.(data.data); + break; + case "onloadend": + details.onloadend?.(data.data); + break; + case "onloadstart": + details.onloadstart?.(data.data); + break; + case "onprogress": + details.onprogress?.(data.data); + break; + case "onreadystatechange": + details.onreadystatechange && details.onreadystatechange(data.data); + break; + case "ontimeout": + details.ontimeout?.(); + break; + case "onerror": + details.onerror?.(""); + break; + case "onabort": + details.onabort?.(); + break; + case "onstream": + // controller?.enqueue(new Uint8Array(resp.data)); + break; + } }); }); diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts new file mode 100644 index 0000000..71fbba0 --- /dev/null +++ b/tests/runtime/gm_api.test.ts @@ -0,0 +1,203 @@ +import { Script, ScriptDAO } from "@App/app/repo/scripts"; +import GMApi from "@App/runtime/content/gm_api"; +import chromeMock from "@Packages/chrome-extension-mock"; +import { initTestEnv, initTestGMApi } from "@Tests/utils"; +import { randomUUID } from "crypto"; +import { newMockXhr } from "mock-xmlhttprequest"; +import { beforeAll, describe, expect, it } from "vitest"; + +initTestEnv(); + +const msg = initTestGMApi(); + +const script: Script = { + uuid: randomUUID(), + name: "test", + metadata: { + grant: [ + // gm xhr + "GM_xmlhttpRequest", + ], + connect: ["example.com"], + }, + namespace: "", + type: 1, + status: 1, + sort: 0, + runStatus: "running", + createtime: 0, + checktime: 0, +}; + +beforeAll(async () => { + await new ScriptDAO().save(script); +}); + +describe("GM xmlHttpRequest", () => { + const gmApi = new GMApi(msg); + //@ts-ignore + gmApi.scriptRes = { + uuid: script.uuid, + }; + const mockXhr = newMockXhr(); + mockXhr.onSend = async (request) => { + switch (request.url) { + case "https://www.example.com/": + return request.respond(200, {}, "example"); + case window.location.href: + return request.respond(200, {}, "location"); + case "https://example.com/json": + return request.respond(200, { "Content-Type": "application/json" }, JSON.stringify({ test: 1 })); + case "https://www.example.com/header": + if (request.requestHeaders.getHeader("x-nonce") !== "123456") { + return request.respond(403, {}, "bad"); + } + return request.respond(200, {}, "header"); + case "https://www.example.com/unsafeHeader": + if ( + request.requestHeaders.getHeader("Origin") !== "https://example.com" || + request.requestHeaders.getHeader("Cookie") !== "website=example.com" + ) { + return request.respond(400, {}, "bad request"); + } + return request.respond(200, { "Set-Cookie": "test=1" }, "unsafeHeader"); + case "https://www.wexample.com/unsafeHeader/cookie": + if (request.requestHeaders.getHeader("Cookie") !== "test=1") { + return request.respond(400, {}, "bad request"); + } + return request.respond(200, {}, "unsafeHeader/cookie"); + } + if (request.method === "POST") { + switch (request.url) { + case "https://example.com/form": + if (request.body.get("blob")) { + return request.respond( + 200, + { "Content-Type": "text/html" }, + // mock 一个blob对象 + { + text: () => Promise.resolve("form"), + } + ); + } + return request.respond(400, {}, "bad"); + } + } + return request.respond(200, {}, "test"); + }; + global.XMLHttpRequest = mockXhr; + it("get", () => { + return new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://www.example.com", + onreadystatechange: (resp) => { + if (resp.readyState === 4 && resp.status === 200) { + expect(resp.responseText).toBe("example"); + resolve(); + } + }, + }); + }); + }); + + it("post数据和blob", () => { + const form = new FormData(); + form.append("blob", new Blob(["blob"], { type: "text/html" })); + return new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://example.com/form", + method: "POST", + data: form, + responseType: "blob", + onreadystatechange: async (resp) => { + if (resp.readyState === 4 && resp.status === 200) { + expect(resp.responseText).toBe("form"); + expect(await (resp.response).text()).toBe("form"); + resolve(); + } + }, + }); + }); + }); + // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 + it("json", async () => { + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://example.com/json", + method: "GET", + responseType: "json", + onload: (resp) => { + // @ts-ignore + expect(resp.response.test).toBe(1); + expect(resp.responseText).toBe('{"test":1}'); + resolve(); + }, + }); + }); + // bad json + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://www.example.com/", + method: "GET", + responseType: "json", + onload: (resp) => { + expect(resp.response).toBeUndefined(); + expect(resp.responseText).toBe("example"); + resolve(); + }, + }); + }); + }); + it("header", async () => { + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://www.example.com/header", + method: "GET", + headers: { + "x-nonce": "123456", + }, + onload: (resp) => { + expect(resp.responseText).toBe("header"); + resolve(); + }, + }); + }); + }); + it("unsafeHeader", async () => { + global.XMLHttpRequest = chromeMock.webRequest.mockXhr(mockXhr); + // 模拟header + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://www.example.com/unsafeHeader", + method: "GET", + headers: { + Origin: "https://example.com", + }, + onload: (resp) => { + expect(resp.responseText).toBe("unsafeHeader"); + expect(resp.responseHeaders?.indexOf("set-cookie")).not.toBe(-1); + resolve(); + }, + }); + }); + }); + + it("unsafeHeader/cookie", async () => { + // global.XMLHttpRequest = chromeMock.webRequest.mockXhr(mockXhr); + // 模拟header + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + url: "https://www.wexample.com/unsafeHeader/cookie", + method: "GET", + headers: { + Cookie: "test=1", + }, + anonymous: true, + onload: (resp) => { + expect(resp.responseText).toBe("unsafeHeader/cookie"); + resolve(); + }, + }); + }); + }); +});