Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion extension/__tests__/scripting-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
27 changes: 24 additions & 3 deletions extension/services/TabManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EvalOutcome | { pending: true }>({
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<EvalOutcome>({
js: [{ code }],
js: [{ code: asyncCode }],
Comment on lines 697 to +698

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Await the original promise instead of executing the expression again

On CSP-strict pages using the user-script fallback, any promise-returning expression now executes twice: syncCode starts the operation and discards its promise, then this fallback evaluates the same expression again. This affects the async fill/type helpers in cli-js/src/tool.js as well as user evaluations containing mutating requests, potentially duplicating input or server-side changes. Running the two generated wrappers with an async counter increment confirms two increments for one evaluation. Preserve the original promise and retrieve its outcome rather than rerunning the expression; the new test mocks both results without executing the injected code, so it misses this regression.

Useful? React with 👍 / 👎.

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) };
}
Expand Down
Loading