diff --git a/packages/page-agent/experiments/chrome-on-device/index.html b/packages/page-agent/experiments/chrome-on-device/index.html new file mode 100644 index 0000000..7b8c26a --- /dev/null +++ b/packages/page-agent/experiments/chrome-on-device/index.html @@ -0,0 +1,438 @@ + + + + + + Page Agent · Chrome on-device lab + + + +
+
+
+

Chrome on-device experiment

+

Structured action lab.

+
+

+ Runs Page Agent's real macro-tool schema through Chrome's Prompt API. No request leaves + this browser. +

+
+ +
+
+

Model readiness

+
+
+ Prompt API + Checking… +
+
+ Availability + Unknown +
+
+ Base session + Cold +
+
+ +

No download started.

+
+ + +
+
+ +
+

Controlled run

+ + +

+ The lab UI is excluded from Page Agent's DOM view. Press Escape to stop. +

+
+ + +
+
+ +
+

Measurements

+
No requests yet.
+
+ +
+

Trace

+
+ Agent result +
No run yet.
+
+
+ Latest AgentOutput JSON Schema +
Captured on first request.
+
+
+ Errors +
No errors.
+
+
+
+
+ +
+
+

Page Agent is running

+

Controlled fixture

+

Profile editor

+

Create a small team profile and save it.

+
+
+ + +
+
+ + +
+ +
+ No profile saved. +
+
+ + + + diff --git a/packages/page-agent/experiments/chrome-on-device/main.ts b/packages/page-agent/experiments/chrome-on-device/main.ts new file mode 100644 index 0000000..5c302d0 --- /dev/null +++ b/packages/page-agent/experiments/chrome-on-device/main.ts @@ -0,0 +1,613 @@ +import { type AgentStatus, type ExecutionResult, PageAgent } from '../../src/PageAgent' + +type Availability = 'unavailable' | 'downloadable' | 'downloading' | 'available' +interface TextMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +interface PromptOptions { + responseConstraint: Record +} + +interface LanguageModelSession { + readonly contextUsage?: number + readonly contextWindow?: number + readonly inputUsage?: number + readonly inputQuota?: number + clone(options?: { signal?: AbortSignal }): Promise + destroy(): void + measureContextUsage?(input: TextMessage[], options: PromptOptions): Promise + measureInputUsage?(input: TextMessage[], options: PromptOptions): Promise + prompt(input: TextMessage[], options: PromptOptions & { signal?: AbortSignal }): Promise +} + +interface LanguageModelCreateOptions { + expectedInputs: { type: 'text'; languages: string[] }[] + expectedOutputs: { type: 'text'; languages: string[] }[] + initialPrompts?: TextMessage[] + monitor?: (monitor: EventTarget) => void + signal?: AbortSignal +} + +interface LanguageModelFactory { + availability(options: LanguageModelCreateOptions): Promise + create(options: LanguageModelCreateOptions): Promise +} + +interface ToolDefinition { + type: 'function' + function: { + name: string + parameters: Record + } +} + +interface RequestMeasurement { + id: number + actionVariants?: number + cloneMs?: number + contextUsageAfter?: number + contextUsageBefore?: number + contextWindow?: number + error?: string + jsonOutput?: unknown + measureContextUsageMs?: number + measuredContextUsage?: number + measurementError?: string + messageBytes?: number + promptMs?: number + promptBytes?: number + schemaBytes?: number + systemBaseCreateMs?: number + systemBytes?: number + totalMs?: number +} + +interface LabState { + agentStatus: AgentStatus | 'not-created' + askUserRequests: { question: string; answer: string }[] + availability: Availability | 'unknown' + downloadProgress: number + downloadCompletedAt?: number + errors: { at: string; scope: string; message: string }[] + latestSchema?: Record + postDownloadLoadMs?: number + prepareMs?: number + preparing: boolean + promptApiSupported: boolean + requests: RequestMeasurement[] + result?: ExecutionResult + running: boolean + userAgent: string + warmSession: boolean + warmSessionContextUsage?: number + warmSessionContextWindow?: number +} + +interface ChromeOnDeviceLabApi { + checkAvailability(): Promise + getState(): LabState + prepare(): Promise + resetFixture(): void + run(task?: string): Promise + stop(): Promise +} + +declare global { + interface Window { + chromeOnDeviceLab: ChromeOnDeviceLabApi + } +} + +const textEncoder = new TextEncoder() +const modelOptions: LanguageModelCreateOptions = { + expectedInputs: [{ type: 'text', languages: ['en'] }], + expectedOutputs: [{ type: 'text', languages: ['en'] }], +} + +const labElement = getElement('lab') +const apiStatusOutput = getElement('api-status') +const availabilityOutput = getElement('availability-status') +const sessionOutput = getElement('session-status') +const progressElement = getElement('download-progress') +const progressLabel = getElement('progress-label') +const checkButton = getElement('check-button') +const prepareButton = getElement('prepare-button') +const runButton = getElement('run-button') +const resetButton = getElement('reset-button') +const taskInput = getElement('task-input') +const metricsOutput = getElement('metrics-output') +const resultOutput = getElement('result-output') +const schemaOutput = getElement('schema-output') +const errorsOutput = getElement('errors-output') +const profileForm = getElement('profile-form') +const profileNameInput = getElement('profile-name') +const profileRoleSelect = getElement('profile-role') +const fixtureResult = getElement('fixture-result') + +const state: LabState = { + agentStatus: 'not-created', + askUserRequests: [], + availability: 'unknown', + downloadProgress: 0, + errors: [], + preparing: false, + promptApiSupported: getPromptApi() !== undefined, + requests: [], + running: false, + userAgent: navigator.userAgent, + warmSession: false, +} + +let baseSession: LanguageModelSession | undefined +let baseSystemPrompt: string | undefined +let agent: PageAgent | undefined + +function getElement(id: string): T { + const element = document.getElementById(id) + if (!element) throw new Error(`Missing #${id}`) + return element as T +} + +function getPromptApi(): LanguageModelFactory | undefined { + return (globalThis as typeof globalThis & { LanguageModel?: LanguageModelFactory }).LanguageModel +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function formatError(error: unknown): string { + if (error instanceof DOMException || error instanceof Error) { + return `${error.name}: ${error.message}` + } + return String(error) +} + +function isAbortError(error: unknown): boolean { + return isRecord(error) && error.name === 'AbortError' +} + +function round(value: number): number { + return Math.round(value * 10) / 10 +} + +function byteLength(value: unknown): number { + return textEncoder.encode(typeof value === 'string' ? value : JSON.stringify(value)).byteLength +} + +function readContextUsage(session: LanguageModelSession): number | undefined { + return session.contextUsage ?? session.inputUsage +} + +function readContextWindow(session: LanguageModelSession): number | undefined { + return session.contextWindow ?? session.inputQuota +} + +function snapshot(): LabState { + return JSON.parse(JSON.stringify(state)) as LabState +} + +function recordError(scope: string, error: unknown): void { + state.errors.push({ at: new Date().toISOString(), scope, message: formatError(error) }) + render() +} + +function render(): void { + apiStatusOutput.value = state.promptApiSupported ? 'Present' : 'Missing' + availabilityOutput.value = state.availability + sessionOutput.value = state.preparing ? 'Preparing…' : state.warmSession ? 'Warm' : 'Cold' + progressElement.value = state.downloadProgress + + if (state.preparing && state.downloadProgress >= 1) { + progressLabel.textContent = 'Download complete. Chrome is loading the model.' + } else if (state.preparing && state.downloadProgress > 0) { + progressLabel.textContent = `Downloading model: ${Math.round(state.downloadProgress * 100)}%` + } else if (state.warmSession) { + progressLabel.textContent = `Ready in ${state.prepareMs ?? 0} ms. Context window: ${state.warmSessionContextWindow ?? 'unknown'}.` + } else if (!state.promptApiSupported) { + progressLabel.textContent = 'LanguageModel is not exposed in this page.' + } else { + progressLabel.textContent = 'No download started.' + } + + const busy = state.preparing || state.running + checkButton.disabled = busy || !state.promptApiSupported + prepareButton.disabled = + busy || !state.promptApiSupported || state.availability === 'unavailable' || state.warmSession + runButton.disabled = busy || !state.warmSession + resetButton.disabled = busy + taskInput.disabled = busy + + metricsOutput.textContent = JSON.stringify( + { + environment: { userAgent: state.userAgent }, + warmup: { + availability: state.availability, + prepareMs: state.prepareMs, + postDownloadLoadMs: state.postDownloadLoadMs, + contextUsage: state.warmSessionContextUsage, + contextWindow: state.warmSessionContextWindow, + }, + requests: state.requests, + askUserRequests: state.askUserRequests, + }, + null, + 2 + ) + resultOutput.textContent = state.result ? JSON.stringify(state.result, null, 2) : 'No run yet.' + schemaOutput.textContent = state.latestSchema + ? JSON.stringify(state.latestSchema, null, 2) + : 'Captured on first request.' + errorsOutput.textContent = state.errors.length + ? state.errors.map((entry) => `[${entry.at}] ${entry.scope}\n${entry.message}`).join('\n\n') + : 'No errors.' +} + +async function checkAvailability(): Promise { + const promptApi = getPromptApi() + state.promptApiSupported = promptApi !== undefined + if (!promptApi) { + state.availability = 'unknown' + render() + return state.availability + } + + try { + state.availability = await promptApi.availability(modelOptions) + return state.availability + } catch (error) { + recordError('availability', error) + state.availability = 'unknown' + return state.availability + } finally { + render() + } +} + +async function prepareModel(): Promise { + if (baseSession) return + const promptApi = getPromptApi() + if (!promptApi) throw new Error('LanguageModel is not available in this page.') + + state.preparing = true + state.downloadProgress = 0 + state.downloadCompletedAt = undefined + state.postDownloadLoadMs = undefined + render() + + const startedAt = performance.now() + const sessionPromise = promptApi.create({ + ...modelOptions, + monitor(monitor) { + monitor.addEventListener('downloadprogress', (event) => { + const loaded = (event as Event & { loaded?: number }).loaded ?? 0 + state.downloadProgress = Math.min(1, Math.max(0, loaded)) + if (state.downloadProgress >= 1 && state.downloadCompletedAt === undefined) { + state.downloadCompletedAt = performance.now() + } + render() + }) + }, + }) + + try { + baseSession = await sessionPromise + const preparedAt = performance.now() + state.prepareMs = round(preparedAt - startedAt) + state.postDownloadLoadMs = state.downloadCompletedAt + ? round(preparedAt - state.downloadCompletedAt) + : undefined + state.warmSession = true + state.warmSessionContextUsage = readContextUsage(baseSession) + state.warmSessionContextWindow = readContextWindow(baseSession) + state.downloadProgress = 1 + void checkAvailability() + } catch (error) { + recordError('prepare', error) + throw error + } finally { + state.preparing = false + render() + } +} + +function parseOpenAIRequest(init?: RequestInit): { + messages: TextMessage[] + promptMessages: TextMessage[] + schema: Record + systemPrompt: string + tool: ToolDefinition +} { + if (typeof init?.body !== 'string') throw new TypeError('Expected a JSON string request body.') + const body = JSON.parse(init.body) as unknown + if (!isRecord(body)) throw new TypeError('Expected an OpenAI request object.') + + if (!Array.isArray(body.tools) || body.tools.length !== 1) { + throw new TypeError('Expected exactly one OpenAI function tool.') + } + const toolValue = body.tools[0] + if (!isRecord(toolValue) || toolValue.type !== 'function' || !isRecord(toolValue.function)) { + throw new TypeError('Expected an OpenAI function tool definition.') + } + if (toolValue.function.name !== 'AgentOutput' || !isRecord(toolValue.function.parameters)) { + throw new TypeError('Expected the AgentOutput tool and its JSON Schema.') + } + const tool = toolValue as unknown as ToolDefinition + + if (!Array.isArray(body.messages) || body.messages.length === 0) { + throw new TypeError('Expected at least one prompt message.') + } + const messages = body.messages.map((value): TextMessage => { + if (!isRecord(value) || typeof value.content !== 'string') { + throw new TypeError('Only text prompt messages are supported in this experiment.') + } + if (value.role !== 'system' && value.role !== 'user' && value.role !== 'assistant') { + throw new TypeError(`Unsupported prompt role: ${String(value.role)}`) + } + return { role: value.role, content: value.content } + }) + const [systemMessage, ...promptMessages] = messages + if ( + systemMessage.role !== 'system' || + promptMessages.some((message) => message.role === 'system') + ) { + throw new TypeError('Expected one leading system message.') + } + if (promptMessages.length === 0) throw new TypeError('Expected at least one user message.') + + return { + messages, + promptMessages, + schema: tool.function.parameters, + systemPrompt: systemMessage.content, + tool, + } +} + +async function ensureSystemBase( + systemPrompt: string, + signal: AbortSignal | undefined, + measurement: RequestMeasurement +): Promise { + if (baseSession && baseSystemPrompt === systemPrompt) return baseSession + const promptApi = getPromptApi() + if (!promptApi) throw new Error('LanguageModel is not available in this page.') + + const startedAt = performance.now() + const replacement = await promptApi.create({ + ...modelOptions, + initialPrompts: [{ role: 'system', content: systemPrompt }], + signal, + }) + measurement.systemBaseCreateMs = round(performance.now() - startedAt) + + const previous = baseSession + baseSession = replacement + baseSystemPrompt = systemPrompt + state.warmSessionContextUsage = readContextUsage(replacement) + state.warmSessionContextWindow = readContextWindow(replacement) + previous?.destroy() + return replacement +} + +function countActionVariants(schema: Record): number | undefined { + const properties = schema.properties + if (!isRecord(properties) || !isRecord(properties.action)) return undefined + const variants = properties.action.anyOf + return Array.isArray(variants) ? variants.length : undefined +} + +function errorResponse(error: unknown): Response { + const errorName = error instanceof DOMException || error instanceof Error ? error.name : 'Error' + return Response.json( + { + error: { + code: errorName, + message: formatError(error), + type: 'chrome_on_device_error', + }, + }, + { status: 422, statusText: 'Chrome on-device inference failed' } + ) +} + +const chromeOnDeviceFetch: typeof globalThis.fetch = async (_input, init) => { + const startedAt = performance.now() + const measurement: RequestMeasurement = { id: state.requests.length + 1 } + state.requests.push(measurement) + let requestSession: LanguageModelSession | undefined + + try { + init?.signal?.throwIfAborted() + if (!baseSession) throw new Error('Prepare the Chrome model before running Page Agent.') + + const { messages, promptMessages, schema, systemPrompt, tool } = parseOpenAIRequest(init) + state.latestSchema = schema + measurement.schemaBytes = byteLength(schema) + measurement.messageBytes = byteLength(messages) + measurement.promptBytes = byteLength(promptMessages) + measurement.systemBytes = byteLength(systemPrompt) + measurement.actionVariants = countActionVariants(schema) + + const systemBase = await ensureSystemBase(systemPrompt, init?.signal ?? undefined, measurement) + const cloneStartedAt = performance.now() + requestSession = await systemBase.clone({ signal: init?.signal ?? undefined }) + measurement.cloneMs = round(performance.now() - cloneStartedAt) + measurement.contextUsageBefore = readContextUsage(requestSession) + measurement.contextWindow = readContextWindow(requestSession) + + const measure = + requestSession.measureContextUsage?.bind(requestSession) ?? + requestSession.measureInputUsage?.bind(requestSession) + if (measure) { + const measureStartedAt = performance.now() + try { + measurement.measuredContextUsage = await measure(promptMessages, { + responseConstraint: schema, + }) + } catch (error) { + measurement.measurementError = formatError(error) + recordError(`request ${measurement.id} measureContextUsage`, error) + } finally { + measurement.measureContextUsageMs = round(performance.now() - measureStartedAt) + } + } + + const promptStartedAt = performance.now() + const rawOutput = await requestSession.prompt(promptMessages, { + responseConstraint: schema, + signal: init?.signal ?? undefined, + }) + measurement.promptMs = round(performance.now() - promptStartedAt) + measurement.contextUsageAfter = readContextUsage(requestSession) + measurement.jsonOutput = JSON.parse(rawOutput) as unknown + measurement.totalMs = round(performance.now() - startedAt) + render() + + return Response.json({ + choices: [ + { + finish_reason: 'tool_calls', + index: 0, + message: { + content: null, + role: 'assistant', + tool_calls: [ + { + function: { arguments: rawOutput, name: tool.function.name }, + id: `chrome-on-device-${measurement.id}`, + type: 'function', + }, + ], + }, + }, + ], + chrome_on_device: measurement, + usage: { completion_tokens: 0, prompt_tokens: 0, total_tokens: 0 }, + }) + } catch (error) { + measurement.error = formatError(error) + measurement.totalMs = round(performance.now() - startedAt) + recordError(`request ${measurement.id}`, error) + if (isAbortError(error)) throw error + return errorResponse(error) + } finally { + requestSession?.destroy() + render() + } +} + +function createAgent(): PageAgent { + const pageAgent = new PageAgent({ + baseURL: 'https://chrome-on-device.invalid/v1', + customFetch: chromeOnDeviceFetch, + enableMask: false, + language: 'en-US', + maxRetries: 0, + maxSteps: 8, + model: 'chrome-on-device', + promptForNextTask: false, + stepDelay: 0, + }) + + pageAgent.onAskUser = async (question, options) => { + options?.signal.throwIfAborted() + const answer = 'The task is fully specified. Continue using the visible page.' + state.askUserRequests.push({ question, answer }) + render() + return answer + } + pageAgent.addEventListener('statuschange', () => { + state.agentStatus = pageAgent.status + render() + }) + state.agentStatus = pageAgent.status + return pageAgent +} + +function resetFixture(): void { + profileForm.reset() + fixtureResult.value = 'No profile saved.' + fixtureResult.dataset.saved = 'false' +} + +async function runAgent(task = taskInput.value.trim()): Promise { + if (!baseSession) throw new Error('Prepare the Chrome model before running Page Agent.') + if (!task) throw new Error('Enter a task before running Page Agent.') + if (state.running) throw new Error('Page Agent is already running.') + + resetFixture() + state.result = undefined + state.running = true + labElement.hidden = true + document.body.dataset.agentRunning = 'true' + render() + + try { + if (!agent || agent.disposed) agent = createAgent() + state.result = await agent.execute(task) + return state.result + } catch (error) { + recordError('agent', error) + throw error + } finally { + state.running = false + labElement.hidden = false + delete document.body.dataset.agentRunning + render() + } +} + +async function stopAgent(): Promise { + await agent?.stop() +} + +profileForm.addEventListener('submit', (event) => { + event.preventDefault() + const name = profileNameInput.value.trim() + const role = profileRoleSelect.value + if (!name || !role) { + fixtureResult.value = 'Complete both profile fields before saving.' + fixtureResult.dataset.saved = 'false' + return + } + fixtureResult.value = `Profile saved: ${name} — ${role}.` + fixtureResult.dataset.saved = 'true' +}) + +checkButton.addEventListener('click', () => { + void checkAvailability() +}) + +prepareButton.addEventListener('click', () => { + void prepareModel().catch(() => undefined) +}) + +runButton.addEventListener('click', () => { + void runAgent().catch(() => undefined) +}) + +resetButton.addEventListener('click', resetFixture) + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && state.running) void stopAgent() +}) + +window.addEventListener('pagehide', () => { + agent?.dispose() + baseSession?.destroy() +}) + +window.chromeOnDeviceLab = { + checkAvailability, + getState: snapshot, + prepare: prepareModel, + resetFixture, + run: runAgent, + stop: stopAgent, +} + +render() +void checkAvailability() diff --git a/packages/page-agent/package.json b/packages/page-agent/package.json index fdb3ba8..058b8f8 100644 --- a/packages/page-agent/package.json +++ b/packages/page-agent/package.json @@ -49,6 +49,7 @@ "scripts": { "build": "vite build && npm run build:demo", "build:demo": "vite build --config vite.iife.config.js", + "dev:chrome-on-device": "vite --host 127.0.0.1 --port 5175", "dev:demo": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"", "prepublishOnly": "node ../../scripts/pre-publish.js", "postpublish": "node ../../scripts/post-publish.js" diff --git a/packages/page-agent/tsconfig.json b/packages/page-agent/tsconfig.json index d45abe9..0e585db 100644 --- a/packages/page-agent/tsconfig.json +++ b/packages/page-agent/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "experiments/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index ad93528..66f6a09 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -18,7 +18,7 @@ import * as dom from './dom' import type { FlatDomTree, InteractiveElementDomNode } from './dom/dom_tree/type' import { getPageInfo } from './dom/getPageInfo' import { patchReact } from './patches/react' -import { isAnchorElement } from './utils' +import { isAnchorElement, isInputElement, isSelectElement } from './utils' /** * Configuration for PageController @@ -246,6 +246,12 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) + if (isSelectElement(element)) { + return { + success: false, + message: `⚠️ Element ${index} is a . Use select_dropdown_option({ index: ${index}, text: "" }).`, + } + } + if (isInputElement(element) && ['checkbox', 'radio'].includes(element.type)) { + return { + success: false, + message: `❌ Element ${index} is input[type="${element.type}"]. Use click_element_by_index({ index: ${index} }) to toggle it.`, + } + } + if (isInputElement(element) && element.type === 'file') { + return { + success: false, + message: `❌ Element ${index} is input[type="file"], which input_text cannot operate. Ask the user to choose a file.`, + } + } await inputTextElement(element, text) return { diff --git a/packages/page-controller/src/actions.test.ts b/packages/page-controller/src/actions.test.ts new file mode 100644 index 0000000..bf3c978 --- /dev/null +++ b/packages/page-controller/src/actions.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +import { clickElement, inputTextElement, selectOptionElement } from './actions' + +describe('element actions', () => { + it('rejects clicking a native select with actionable feedback', async () => { + const select = document.createElement('select') + + await expect(clickElement(select)).rejects.toThrow('Use select_dropdown_option') + }) + + it('rejects typing into a native select with actionable feedback', async () => { + const select = document.createElement('select') + + await expect(inputTextElement(select, 'Researcher')).rejects.toThrow( + 'Use select_dropdown_option' + ) + }) + + it('reports when an input value does not persist', async () => { + const input = document.createElement('input') + document.body.append(input) + input.addEventListener('input', () => { + input.value = '' + }) + + await expect(inputTextElement(input, 'Ramen')).rejects.toThrow('Input did not persist') + }) + + it('selects an option and dispatches input and change events', async () => { + const select = document.createElement('select') + select.innerHTML = ` + + + ` + const onInput = vi.fn() + const onChange = vi.fn() + select.addEventListener('input', onInput) + select.addEventListener('change', onChange) + + await selectOptionElement(select, 'Researcher') + + expect(select.value).toBe('researcher') + expect(select.selectedOptions.item(0)?.textContent).toBe('Researcher') + expect(onInput).toHaveBeenCalledOnce() + expect(onChange).toHaveBeenCalledOnce() + }) + + it('reports when a selected value does not persist', async () => { + const select = document.createElement('select') + select.innerHTML = ` + + + ` + select.addEventListener('change', () => { + select.value = '' + }) + + await expect(selectOptionElement(select, 'Researcher')).rejects.toThrow( + 'Selection did not persist' + ) + }) +}) diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index e90326e..e1116c2 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -62,6 +62,12 @@ function blurLastClickedElement() { * @private Internal method, subject to change at any time. */ export async function clickElement(element: HTMLElement) { + if (isSelectElement(element)) { + throw new Error( + 'Native does not accept text input. Use select_dropdown_option with this element index and the visible option text.' + ) + } + throw new Error('Element does not accept text input') } await clickElement(element) @@ -226,9 +237,18 @@ export async function inputTextElement(element: HTMLElement, text: string) { element.dispatchEvent(new Event('input', { bubbles: true })) } + blurLastClickedElement() await waitFor(0.1) - blurLastClickedElement() + const actualText = isContentEditable + ? element.innerText.trim() + : (element as HTMLInputElement | HTMLTextAreaElement).value + const expectedText = isContentEditable ? text.trim() : text + if (actualText !== expectedText) { + throw new Error( + `Input did not persist. Expected value ${JSON.stringify(expectedText)}, but the element contains ${JSON.stringify(actualText)}. Inspect the current page state before retrying.` + ) + } } /** @@ -247,10 +267,21 @@ export async function selectOptionElement(selectElement: HTMLSelectElement, opti throw new Error(`Option with text "${optionText}" not found in select element`) } - selectElement.value = option.value + const expectedText = option.textContent?.trim() ?? '' + const expectedValue = option.value + selectElement.selectedIndex = option.index + selectElement.dispatchEvent(new Event('input', { bubbles: true })) selectElement.dispatchEvent(new Event('change', { bubbles: true })) await waitFor(0.1) // Wait to ensure change event processing completes + + const selectedOption = selectElement.selectedOptions.item(0) + const actualText = selectedOption?.textContent?.trim() ?? '' + if (!selectedOption || selectElement.value !== expectedValue || actualText !== expectedText) { + throw new Error( + `Selection did not persist. Expected option ${JSON.stringify(expectedText)} with value ${JSON.stringify(expectedValue)}, but the current option is ${JSON.stringify(actualText)} with value ${JSON.stringify(selectElement.value)}. Inspect the current page state before retrying.` + ) + } } interface ScrollableElement extends Element {