From cf54805d60f81ae475f0f7982422af1c8f59a8dc Mon Sep 17 00:00:00 2001 From: CoderLambert Date: Tue, 15 Sep 2026 14:26:01 +0800 Subject: [PATCH] fix(provider): confirm prompt submission --- .../content/providers/core.js | 106 +++++++-- apps/browser-extension/workspace/workspace.js | 4 + tests/provider-core.test.js | 204 ++++++++++++++++++ 3 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 tests/provider-core.test.js diff --git a/apps/browser-extension/content/providers/core.js b/apps/browser-extension/content/providers/core.js index 667324b..543bb83 100644 --- a/apps/browser-extension/content/providers/core.js +++ b/apps/browser-extension/content/providers/core.js @@ -142,11 +142,11 @@ } function findGenericSendButton(editor) { - const scopes = [editor.closest("form"), editor.parentElement, editor.closest("main"), document.body].filter(Boolean); + const scopes = editorScopes(editor); const hints = ["send", "发送", "提交", "submit"]; for (const scope of scopes) { const buttons = [...scope.querySelectorAll("button")].filter((button) => { - if (!isVisible(button) || button.disabled) return false; + if (!isEnabledControl(button)) return false; const text = normalizedText(button).toLowerCase(); return button.getAttribute("type") === "submit" || hints.some((hint) => text.includes(hint)); }); @@ -155,17 +155,60 @@ return null; } - async function submit(adapter, editor) { - const button = await waitFor(() => ( - queryFirstVisible(adapter.sendSelectors, (element) => isVisible(element) && !element.disabled) - || findGenericSendButton(editor) - ), adapter.sendReadyTimeoutMs ?? 2500, 100); + function isEnabledControl(element) { + return isVisible(element) + && !element.disabled + && element.getAttribute("aria-disabled") !== "true" + && element.getAttribute("aria-busy") !== "true"; + } - if (button) { - button.click(); - return; + function editorScopes(editor) { + const scopes = []; + const add = (scope) => { + if (scope && !scopes.includes(scope)) scopes.push(scope); + }; + + let current = editor.parentElement; + for (let depth = 0; current && depth < 5; depth += 1) { + add(current); + current = current.parentElement; } + add(editor.closest("form")); + add(editor.closest("[role='form']")); + add(editor.closest("main")); + return scopes; + } + function queryFirstVisibleInEditorScope(selectors, editor) { + for (const scope of editorScopes(editor)) { + const match = queryFirstVisible(selectors, isEnabledControl, scope); + if (match) return match; + } + return null; + } + + function findSendButton(adapter, editor) { + return queryFirstVisibleInEditorScope(adapter.sendSelectors, editor) || findGenericSendButton(editor); + } + + function responseCount(adapter) { + return queryVisible( + adapter.responseSelectors, + (element) => isVisible(element) && !element.closest("[aria-hidden='true']") + ).length; + } + + function submissionConfirmed(adapter, editor, prompt, initialButton, initialResponseCount) { + const currentEditor = queryFirstVisible(adapter.editorSelectors, isUsableEditor); + if (!currentEditor || !editorContainsPrompt(currentEditor, prompt)) return true; + if (initialButton && (!initialButton.isConnected + || initialButton.disabled + || initialButton.getAttribute("aria-disabled") === "true" + || initialButton.getAttribute("aria-busy") === "true")) return true; + return responseCount(adapter) > initialResponseCount; + } + + function dispatchEnter(editor) { editor.focus(); for (const type of ["keydown", "keypress", "keyup"]) { editor.dispatchEvent(new KeyboardEvent(type, { @@ -179,6 +222,46 @@ } } + function requestFormSubmit(editor) { + const form = editor.closest("form"); + if (typeof form?.requestSubmit !== "function") return false; + form.requestSubmit(); + return true; + } + + async function submit(adapter, editor, prompt) { + const immediateButton = findSendButton(adapter, editor); + const button = immediateButton || (adapter.sendSelectors?.length + ? await waitFor( + () => findSendButton(adapter, editor), + adapter.sendReadyTimeoutMs ?? 2500, + 100 + ) + : null); + const initialResponseCount = responseCount(adapter); + const attempts = button + ? [ + () => button.click(), + () => requestFormSubmit(editor) || dispatchEnter(editor) + ] + : [ + () => requestFormSubmit(editor) || dispatchEnter(editor), + () => dispatchEnter(editor) + ]; + + for (const attempt of attempts) { + try { attempt(); } catch { /* Try the next bounded submission path. */ } + const confirmed = await waitFor( + () => submissionConfirmed(adapter, editor, prompt, button, initialResponseCount), + adapter.submitConfirmationTimeoutMs ?? 3000, + 100 + ); + if (confirmed) return true; + } + + throw new Error("无法确认 Prompt 已发送;请在模型窗口中重试"); + } + async function sendPrompt(adapter, prompt) { const editor = await waitFor( () => queryFirstVisible(adapter.editorSelectors, isUsableEditor), @@ -191,8 +274,7 @@ if (!editorContainsPrompt(editor, prompt)) await fillEditor(adapter, editor, prompt); if (!editorContainsPrompt(editor, prompt)) throw new Error("无法可靠写入 Prompt"); - await submit(adapter, editor); - return true; + return submit(adapter, editor, prompt); } function collectResponse(adapter, rootDocument = document) { diff --git a/apps/browser-extension/workspace/workspace.js b/apps/browser-extension/workspace/workspace.js index 7521b92..8f63175 100644 --- a/apps/browser-extension/workspace/workspace.js +++ b/apps/browser-extension/workspace/workspace.js @@ -55,6 +55,7 @@ let selected = new Set(); let currentLayout = "auto"; let runtimeUpgradeWarning = ""; let pendingLaunchRunning = false; +let dispatchInFlight = false; function providerById(id) { return PROVIDERS.find((provider) => provider.id === id); @@ -553,10 +554,12 @@ function collectResponseFromProvider(providerId) { } async function dispatchPrompt() { + if (dispatchInFlight) return; const prompt = promptInput.value.trim(); if (!prompt) return showError("请输入 Prompt"); if (!selected.size) return showError("至少选择一个模型"); + dispatchInFlight = true; responseBundles.clear(); renderResponses(); showError(runtimeUpgradeWarning); @@ -589,6 +592,7 @@ async function dispatchPrompt() { showError(error instanceof Error ? error.message : String(error)); dispatchStatus.textContent = "发送失败"; } finally { + dispatchInFlight = false; updateMeta(); } } diff --git a/tests/provider-core.test.js b/tests/provider-core.test.js new file mode 100644 index 0000000..cd4b7df --- /dev/null +++ b/tests/provider-core.test.js @@ -0,0 +1,204 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const corePath = path.join(__dirname, "..", "apps", "browser-extension", "content", "providers", "core.js"); + +class FakeElement { + constructor({ parent = null, onClick = null } = {}) { + this.parentElement = parent; + this.onClick = onClick; + this.disabled = false; + this.readOnly = false; + this.attributes = {}; + this.style = {}; + this.connected = true; + this.clickCount = 0; + this.events = []; + this.queryMap = new Map(); + } + + get isConnected() { + return this.connected; + } + + get textContent() { + return this._textContent || ""; + } + + set textContent(value) { + this._textContent = String(value); + } + + getAttribute(name) { + return this.attributes[name] ?? null; + } + + closest(selector) { + if (selector === "form") return this.form || null; + return null; + } + + querySelectorAll(selector) { + return this.queryMap.get(selector) || []; + } + + getBoundingClientRect() { + return { width: 100, height: 20 }; + } + + compareDocumentPosition() { + return 0; + } + + focus() {} + + click() { + this.clickCount += 1; + this.onClick?.(); + } + + dispatchEvent(event) { + this.events.push(event.type); + return true; + } +} + +class FakeTextArea extends FakeElement { + constructor(options) { + super(options); + this._value = ""; + } + + get value() { + return this._value; + } + + set value(value) { + this._value = String(value); + } +} + +function loadCore({ withButton = true, confirmOn = "button" } = {}) { + const body = new FakeElement(); + const form = new FakeElement(); + const editor = new FakeTextArea({ parent: form }); + editor.form = form; + form.form = form; + const button = withButton ? new FakeElement({ parent: form }) : null; + if (button) { + button.attributes.type = "submit"; + button.onClick = () => { + if (confirmOn === "button") { + editor.value = ""; + button.disabled = true; + } + }; + } + let requestSubmitCount = 0; + form.requestSubmit = () => { + requestSubmitCount += 1; + if (confirmOn === "form") { + editor.value = ""; + if (button) button.disabled = true; + } + }; + form.queryMap.set("button", button ? [button] : []); + form.queryMap.set("button[type='submit']", button ? [button] : []); + body.queryMap.set("button[type='submit']", [new FakeElement({ parent: body })]); + + const document = { + body, + querySelectorAll(selector) { + if (selector === "textarea") return [editor]; + if (selector === "button[type='submit']") return body.querySelectorAll(selector); + return []; + }, + createRange() { + return { selectNodeContents() {} }; + } + }; + const context = { + console, + document, + Element: FakeElement, + HTMLTextAreaElement: FakeTextArea, + HTMLInputElement: class extends FakeElement {}, + InputEvent: class { constructor(type) { this.type = type; } }, + Event: class { constructor(type) { this.type = type; } }, + KeyboardEvent: class { constructor(type) { this.type = type; } }, + getComputedStyle: () => ({ display: "block", visibility: "visible", opacity: "1" }), + setTimeout, + clearTimeout + }; + context.globalThis = context; + vm.createContext(context); + vm.runInContext(fs.readFileSync(corePath, "utf8"), context, { filename: corePath }); + + return { + core: context.AIParallelProviderCore, + editor, + button, + form, + get requestSubmitCount() { return requestSubmitCount; } + }; +} + +test("provider submission scopes the send button and confirms the UI transition", async () => { + const fixture = loadCore({ withButton: true, confirmOn: "button" }); + const adapter = fixture.core.createProviderAdapter({ + id: "fixture", + hosts: ["fixture.test"], + editorSelectors: ["textarea"], + sendSelectors: ["button[type='submit']"], + responseSelectors: [], + submitConfirmationTimeoutMs: 50 + }); + + const result = await adapter.sendPrompt("hello provider"); + + assert.equal(result, true); + assert.equal(fixture.button.clickCount, 1); + assert.equal(fixture.form.querySelectorAll("button[type='submit']").length, 1); + assert.equal(fixture.editor.value, ""); +}); + +test("provider submission uses native form submission before synthetic Enter", async () => { + const fixture = loadCore({ withButton: false, confirmOn: "form" }); + const adapter = fixture.core.createProviderAdapter({ + id: "fixture", + hosts: ["fixture.test"], + editorSelectors: ["textarea"], + sendSelectors: [], + responseSelectors: [], + submitConfirmationTimeoutMs: 50 + }); + + await adapter.sendPrompt("submit through form"); + + assert.equal(fixture.requestSubmitCount, 1); + assert.equal(fixture.editor.value, ""); +}); + +test("provider submission reports deterministic failure when no UI confirmation occurs", async () => { + const fixture = loadCore({ withButton: true, confirmOn: "never" }); + const adapter = fixture.core.createProviderAdapter({ + id: "fixture", + hosts: ["fixture.test"], + editorSelectors: ["textarea"], + sendSelectors: ["button[type='submit']"], + responseSelectors: [], + sendReadyTimeoutMs: 50, + submitConfirmationTimeoutMs: 20 + }); + + await assert.rejects( + adapter.sendPrompt("never submitted"), + /无法确认 Prompt 已发送/ + ); + assert.equal(fixture.button.clickCount, 1); + assert.equal(fixture.requestSubmitCount, 1); + assert.equal(fixture.editor.value, "never submitted"); +});