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
39 changes: 39 additions & 0 deletions packages/extension/src/action/aiPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { findAiService } from "@/services/aiPrompt"
import { Storage } from "@/services/storage"
import {
APP_ID,
OPEN_MODE,
PAGE_ACTION_EVENT,
PAGE_ACTION_CONDITION_ACTION,
Expand Down Expand Up @@ -58,7 +59,7 @@
faviconUrl: "",
inputSelectors: [".ql-editor"],
submitSelectors: ["button.send"],
selectorType: "css" as any,

Check warning on line 62 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
...overrides,
})

Expand All @@ -69,7 +70,7 @@
faviconUrl: "",
inputSelectors: ["#prompt-textarea"],
submitSelectors: ["button#submit"],
selectorType: "css" as any,

Check warning on line 73 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
queryUrl: "https://chatgpt.com/?prompt=%s",
autoSubmit: false,
...overrides,
Expand All @@ -82,7 +83,7 @@
faviconUrl: "",
inputSelectors: ["div#ask-input"],
submitSelectors: ["button[aria-label='Submit']"],
selectorType: "css" as any,

Check warning on line 86 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
queryUrl: "https://www.perplexity.ai/search/new?q=%s",
autoSubmit: true,
urlToMarkdown: true,
Expand Down Expand Up @@ -124,7 +125,7 @@
...baseCommand.aiPromptOption,
serviceId: "gemini",
},
} as any,

Check warning on line 128 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
position: { x: 100, y: 100 },
})

Expand Down Expand Up @@ -154,13 +155,13 @@
...baseCommand.aiPromptOption,
serviceId: "gemini",
},
} as any,

Check warning on line 158 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
position: { x: 100, y: 100 },
})

const sentArgs = vi.mocked(Ipc.send).mock.calls[0][1] as any

Check warning on line 162 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
const clickStep = sentArgs.steps.find(
(s: any) => s.param.type === PAGE_ACTION_EVENT.click,

Check warning on line 164 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
)
expect(clickStep.param.condition).toEqual({
actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil,
Expand All @@ -183,7 +184,7 @@
serviceId: "gemini",
prompt: `${toInsertTemplate(INSERT.PAGE_HTML)} and again ${toInsertTemplate(INSERT.PAGE_HTML)}`,
},
} as any,

Check warning on line 187 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
position: { x: 100, y: 100 },
})

Expand Down Expand Up @@ -216,7 +217,7 @@
serviceId: "gemini",
prompt: `${toInsertTemplate(INSERT.PAGE_HTML)} ${toInsertTemplate(INSERT.SELECTION_HTML)}`,
},
} as any,

Check warning on line 220 in packages/extension/src/action/aiPrompt.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
position: { x: 100, y: 100 },
})

Expand Down Expand Up @@ -248,6 +249,44 @@
expect(payload.selectionHtml).toBeUndefined()
})

it("AP-01c-2: pageHtml should exclude the extension's own injected UI containers", async () => {
vi.mocked(findAiService).mockResolvedValue(makeDomService())

const rootDom = document.createElement("div")
rootDom.id = APP_ID
document.body.appendChild(rootDom)

const hubDom = document.createElement("div")
hubDom.id = `${APP_ID}-hub`
document.body.appendChild(hubDom)

try {
await AiPrompt.execute({
selectionText: "hello world",
command: {
...baseCommand,
aiPromptOption: {
...baseCommand.aiPromptOption,
serviceId: "gemini",
prompt: toInsertTemplate(INSERT.PAGE_HTML),
},
} as any,
position: { x: 100, y: 100 },
})

const call = vi
.mocked(Ipc.send)
.mock.calls.find(([cmd]) => cmd === BgCommand.openAndRunPageAction)
const payload = call?.[1] as any

expect(payload.pageHtml).not.toContain(`id="${APP_ID}"`)
expect(payload.pageHtml).not.toContain(`id="${APP_ID}-hub"`)
} finally {
rootDom.remove()
hubDom.remove()
}
})

it("AP-01d: should still attach SELECTION_HTML when the prompt contains only SELECTION_HTML", async () => {
vi.mocked(findAiService).mockResolvedValue(makeDomService())

Expand Down
19 changes: 6 additions & 13 deletions packages/extension/src/action/aiPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,10 @@ import type { OpenAndRunProps } from "@/services/pageAction/background"
import type { OpenSidePanelProps } from "@/services/chrome"
import { findAiService } from "@/services/aiPrompt"
import { isAiPromptType } from "@/types/schema"
import {
INSERT,
InsertSymbol,
toInsertTemplate,
PAGE_HTML_MAX_CHARS,
} from "@/services/pageAction"
import { INSERT, InsertSymbol, toInsertTemplate } from "@/services/pageAction"
import { Storage, SESSION_STORAGE_KEY } from "@/services/storage"
import { getUILanguage } from "@/services/i18n"
import { getSelectionHtml } from "@/services/dom"
import { getSelectionHtml, getPageHtml } from "@/services/dom"

/**
* Convert bare URLs in text to Markdown link format [URL](URL).
Expand Down Expand Up @@ -104,9 +99,7 @@ export const AiPrompt = {
const needPageHtml = aiPromptOption.prompt.includes(
toInsertTemplate(INSERT.PAGE_HTML),
)
const pageHtml = needPageHtml
? document.documentElement.outerHTML.slice(0, PAGE_HTML_MAX_CHARS)
: undefined
const pageHtml = needPageHtml ? getPageHtml() : undefined

// Only one HTML attachment is allowed per execution; PAGE_HTML takes
// priority over SELECTION_HTML when the prompt contains both.
Expand Down Expand Up @@ -297,9 +290,9 @@ export const AiPrompt = {
// Remove the HTML placeholders from the prompt value.
const promptValue = needFilePaste
? aiPromptOption.prompt
.replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "")
.replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "")
.trim()
.replaceAll(toInsertTemplate(INSERT.PAGE_HTML), "")
.replaceAll(toInsertTemplate(INSERT.SELECTION_HTML), "")
.trim()
: aiPromptOption.prompt

steps = [
Expand Down
6 changes: 2 additions & 4 deletions packages/extension/src/command_hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const cloneCss = (from: ShadowRoot, to: ShadowRoot, selector: string) => {

function setupCommandHub() {
const rootDom = document.createElement("div")
rootDom.id = `${APP_ID}-command-hub`
rootDom.id = `${APP_ID}-hub`
document.body.insertAdjacentElement("afterend", rootDom)
const shadow = rootDom.attachShadow({ mode })
const root = createRoot(shadow)
Expand All @@ -45,9 +45,7 @@ function renderMyCommands() {
container.style.display = "block"

if (isDebug) {
const from = document.getElementById(
"selection-command-command-hub",
)?.shadowRoot
const from = document.getElementById(`${APP_ID}-hub`)?.shadowRoot
from && cloneCss(from, shadow, "style")
} else {
insertCss(shadow)
Expand Down
83 changes: 42 additions & 41 deletions packages/extension/src/components/pageAction/InputPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const useVisibleDelay = (props: useDelayProps) => {
return () => {
clearTimeout(delayTimer)
}
}, [visible])
}, [visible, updater])
}

export function InputPopup(): JSX.Element {
Expand All @@ -173,16 +173,17 @@ export function InputPopup(): JSX.Element {
setTargetElm(getFocusNode(e))
}

const onFocusIn = (e: any) => {
const onFocusIn = (e: FocusEvent) => {
if (!e.target) return
if (!isTargetEditable(e.target)) return
if (isPopup(e.target)) return
if (isPopup(e.target as Element)) return
setMenuVisible(true)
updateTarget(e)
}

const onFocusOut = (e: any) => {
const onFocusOut = (e: FocusEvent) => {
if (!isTargetEditable(e.target)) return
if (isPopup(e.relatedTarget)) return
if (isPopup(e.relatedTarget as Element)) return
setMenuVisible(false)
setTargetElm(null)
}
Expand All @@ -195,7 +196,7 @@ export function InputPopup(): JSX.Element {
if (isHtmlElement(e.target)) {
setDisabled(
e.target.children.length > 0 &&
e.target.innerText.trim().length !== 0,
e.target.innerText.trim().length !== 0,
)
}
}
Expand Down Expand Up @@ -287,7 +288,7 @@ export function InputMenu(props: MenuProps): JSX.Element {

return (
<Menubar
className={cn(showFileAttachMenu && "pr-1", props.className)}
className={props.className}
value={selectedMenu}
onValueChange={onSelectedMenuChange}
>
Expand Down Expand Up @@ -332,40 +333,40 @@ export function InputMenu(props: MenuProps): JSX.Element {
</InputMenuItem>
</MenubarContent>
</MenubarMenu>
{!props.hideFilePaste && (
<MenubarMenu value={MENU.FILE_PASTE}>
<MenubarTrigger
className={cn(
"p-1 pl-1.5 text-sm font-normal font-sans text-gray-700 cursor-pointer",
props.fileAttachDisabled &&
{showFileAttachMenu && (
<>
<div className="shrink-0 self-stretch mx-px my-[2px] bg-border w-px" />
<MenubarMenu value={MENU.FILE_PASTE}>
<MenubarTrigger
className={cn(
"p-1 px-1.5 text-sm font-normal font-sans text-gray-700 cursor-pointer",
props.fileAttachDisabled &&
"opacity-60 bg-muted cursor-not-allowed",
)}
disabled={props.fileAttachDisabled}
onMouseEnter={() => onSelectedMenuChange(MENU.FILE_PASTE)}
>
{t("PageAction_InputMenu_fileAttach")}
{!showFileAttachMenu &&
(selectedMenu === MENU.FILE_PASTE ? (
<ChevronUp size={14} className="ml-1" />
) : (
<ChevronDown size={14} className="ml-1" />
))}
</MenubarTrigger>
<MenubarContent
className="border"
onMouseLeave={() => setSelectedMenu("")}
sideOffset={2}
>
<InputMenuItem onClick={onClickItem} value={INSERT.PAGE_HTML}>
<FileCode size={16} className="mr-2 stroke-gray-600" />
{t("PageAction_InputMenu_pageHtml")}
</InputMenuItem>
<InputMenuItem onClick={onClickItem} value={INSERT.SELECTION_HTML}>
<Code size={16} className="mr-2 stroke-gray-600" />
{t("PageAction_InputMenu_selectionHtml")}
</InputMenuItem>
</MenubarContent>
</MenubarMenu>
)}
disabled={props.fileAttachDisabled}
onMouseEnter={() => onSelectedMenuChange(MENU.FILE_PASTE)}
>
{t("PageAction_InputMenu_fileAttach")}
</MenubarTrigger>
<MenubarContent
className="border"
onMouseLeave={() => setSelectedMenu("")}
sideOffset={2}
>
<InputMenuItem onClick={onClickItem} value={INSERT.PAGE_HTML}>
<FileCode size={16} className="mr-2 stroke-gray-600" />
{t("PageAction_InputMenu_pageHtml")}
</InputMenuItem>
<InputMenuItem
onClick={onClickItem}
value={INSERT.SELECTION_HTML}
>
<Code size={16} className="mr-2 stroke-gray-600" />
{t("PageAction_InputMenu_selectionHtml")}
</InputMenuItem>
</MenubarContent>
</MenubarMenu>
</>
)}
</Menubar>
)
Expand Down Expand Up @@ -438,7 +439,7 @@ function FocusOutline(props: FocusOutlineProps): JSX.Element {
requestAnimationFrame(() => {
setRect(container?.getBoundingClientRect())
})
}, [shouldRender])
}, [container, shouldRender])

if (elm == null || rect == null || !visible) return <></>

Expand Down
65 changes: 65 additions & 0 deletions packages/extension/src/services/dom/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { APP_ID } from "@/const"
import {
getSelectionText,
getInputSelectionEndPoint,
getEditableSelectionEndPoint,
getPageHtml,
isInputOrTextarea,
isEditable,
} from "./index"
Expand Down Expand Up @@ -242,6 +244,69 @@ describe("getEditableSelectionEndPoint", () => {
})
})

describe("getPageHtml", () => {
afterEach(() => {
document.body.innerHTML = ""
})

it("GPH-01: excludes the content script's injected container", () => {
const marker = document.createElement("p")
marker.textContent = "page content"
document.body.appendChild(marker)

const rootDom = document.createElement("div")
rootDom.id = APP_ID
document.body.appendChild(rootDom)

const html = getPageHtml()
expect(html).not.toContain(`id="${APP_ID}"`)
expect(html).toContain("page content")
})

it("GPH-02: excludes the Command Hub's injected container", () => {
const marker = document.createElement("p")
marker.textContent = "page content"
document.body.appendChild(marker)

const hubDom = document.createElement("div")
hubDom.id = `${APP_ID}-hub`
document.body.appendChild(hubDom)

const html = getPageHtml()
expect(html).not.toContain(`id="${APP_ID}-hub"`)
expect(html).toContain("page content")
})

it("GPH-03: excludes both injected containers when present together", () => {
const marker = document.createElement("p")
marker.textContent = "page content"
document.body.appendChild(marker)

const rootDom = document.createElement("div")
rootDom.id = APP_ID
document.body.appendChild(rootDom)

const hubDom = document.createElement("div")
hubDom.id = `${APP_ID}-hub`
document.body.appendChild(hubDom)

const html = getPageHtml()
expect(html).not.toContain(`id="${APP_ID}"`)
expect(html).not.toContain(`id="${APP_ID}-hub"`)
expect(html).toContain("page content")
})

it("GPH-04: returns page HTML unchanged when no injected containers exist", () => {
const marker = document.createElement("p")
marker.textContent = "page content"
document.body.appendChild(marker)

const html = getPageHtml()
expect(html).toContain("page content")
expect(html).toContain("<html")
})
})

describe("isInputOrTextarea", () => {
it("IIOT-01: returns true for text input", () => {
const input = document.createElement("input")
Expand Down
14 changes: 13 additions & 1 deletion packages/extension/src/services/dom/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Point } from "@/types"

import { isEmpty } from "@/lib/utils"
import { PAGE_HTML_MAX_CHARS } from "@/const"
import { APP_ID, PAGE_HTML_MAX_CHARS } from "@/const"

export function toDataURL(src: string, outputFormat?: string): Promise<string> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -104,6 +104,18 @@ export function getSelectionHtml(): string {
return div.innerHTML.slice(0, PAGE_HTML_MAX_CHARS)
}

/**
* Get the page's outer HTML, excluding the extension's own injected UI
* containers (content script root and Command Hub root) so they aren't
* sent along as page content.
*/
export function getPageHtml(): string {
const clone = document.documentElement.cloneNode(true) as HTMLElement
clone.querySelector(`#${APP_ID}`)?.remove()
clone.querySelector(`#${APP_ID}-hub`)?.remove()
return clone.outerHTML.slice(0, PAGE_HTML_MAX_CHARS)
}

/**
* Find the anchor element from the specified element.
*
Expand Down
Loading
Loading