From 7154ad0e7bd590507e28bf8a8f2181f9c32d2ae8 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Sun, 8 Nov 2026 03:12:35 +0100 Subject: [PATCH] fix(extension): run user-script evaluate synchronously first so it works in Search Search's userScripts.execute does not await a promise returned by the injected script, so the async wrapper came back with no result on CSP-strict pages (e.g. Todoist). Try a synchronous wrapper first and only fall back to the async one when the expression itself returns a thenable. --- .../__tests__/scripting-transport.test.ts | 19 ++++++++++++- extension/services/TabManager.ts | 27 ++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/extension/__tests__/scripting-transport.test.ts b/extension/__tests__/scripting-transport.test.ts index 4844c45..069d227 100644 --- a/extension/__tests__/scripting-transport.test.ts +++ b/extension/__tests__/scripting-transport.test.ts @@ -147,7 +147,24 @@ describe("debugger-free transport", () => { const injection = execute.mock.calls[0][0]; expect(injection.world).toBe("MAIN"); expect(injection.target).toEqual({ tabId: 7 }); - expect(injection.js[0].code).toContain("await(\ndocument.title\n)"); + expect(injection.js[0].code).toContain("const v=(\ndocument.title\n)"); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("falls back to the async wrapper only when the expression returns a promise", async () => { + const info = await registerTab(); + executeScript.mockResolvedValue(blocked); + const execute = vi + .fn() + .mockResolvedValueOnce([{ result: { pending: true } }]) + .mockResolvedValueOnce([{ result: { ok: true, value: "async-ok" } }]); + installChrome({ userScripts: { execute } }); + const out = (await router.handleCommand( + command("Runtime.evaluate", { expression: "fetch('/x')" }, info.sessionId) + )) as { result: { value: string } }; + expect(out.result.value).toBe("async-ok"); + expect(execute).toHaveBeenCalledTimes(2); + expect(execute.mock.calls[1][0].js[0].code).toContain("await(\nfetch('/x')\n)"); }); it("says how to unblock when userScripts is unavailable (no dead-end debugger advice)", async () => { diff --git a/extension/services/TabManager.ts b/extension/services/TabManager.ts index b4f9303..6542d7d 100644 --- a/extension/services/TabManager.ts +++ b/extension/services/TabManager.ts @@ -678,14 +678,35 @@ export class TabManager { }; } // A newline after the expression keeps a trailing // comment from eating the wrapper. - const code = `(async()=>{try{const v=await(\n${expression}\n);let s=v;if(v!==undefined){try{s=JSON.parse(JSON.stringify(v))}catch(e){s=String(v)}}return{ok:true,value:s}}catch(e){return{ok:false,error:e&&e.message?String(e.message):String(e),name:e&&e.name}}})()`; + // Try a synchronous wrapper first: some Chromium forks (e.g. the macOS Search + // browser) do not await a promise returned from userScripts.execute, so the + // async wrapper comes back with no result. Only fall back to the async + // wrapper when the expression itself produced a thenable. + const syncCode = `(()=>{try{const v=(\n${expression}\n);if(v!=null&&typeof v.then==="function"){return{pending:true}}let s=v;if(v!==undefined){try{s=JSON.parse(JSON.stringify(v))}catch(e){s=String(v)}}return{ok:true,value:s}}catch(e){return{ok:false,error:e&&e.message?String(e.message):String(e),name:e&&e.name}}})()`; + const asyncCode = `(async()=>{try{const v=await(\n${expression}\n);let s=v;if(v!==undefined){try{s=JSON.parse(JSON.stringify(v))}catch(e){s=String(v)}}return{ok:true,value:s}}catch(e){return{ok:false,error:e&&e.message?String(e.message):String(e),name:e&&e.name}}})()`; try { + const [syncFirst] = await api.execute({ + js: [{ code: syncCode }], + target: { tabId }, + world: "MAIN", + }); + const syncResult = syncFirst?.result as (EvalOutcome | { pending?: boolean }) | undefined; + if (syncResult && typeof syncResult === "object" && !("pending" in syncResult && syncResult.pending)) { + return syncResult as EvalOutcome; + } const [first] = await api.execute({ - js: [{ code }], + js: [{ code: asyncCode }], target: { tabId }, world: "MAIN", }); - return first?.result ?? { ok: false, error: "userScripts.execute returned no result" }; + return ( + first?.result ?? { + ok: false, + error: syncResult + ? "userScripts.execute returned no result for an async expression (this browser may not await promises from user scripts)" + : "userScripts.execute returned no result", + } + ); } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; }