Files
aiturk-hermes-ide/apps/chrome/tests/agent.test.mjs
T
yilsemandClaude Opus 5.5 86a6621936
Server CI and Windows package / verify (push) Successful in 3m33s
Server CI and Windows package / windows-package (push) Successful in 7m36s
feat(chrome): let the IDE agent use the user's Chrome and beta.42
The AITURK Chrome extension now registers the user's Chrome as the desktop
backend's browser controller, the way the ChatGPT extension lets its agent
work in the user's browser. The agent's existing browser tools (navigate,
snapshot, click, type, scroll, back, press, get_images, vision) run in its
own background tabs grouped as "AITURK", or a tab the user hands over.

Backend: a local desktop lane in the browser-control broker. One controller
registered from the loopback desktop transport is offered to every desktop
session as a lane template; each session still gets its own exact scope on
first use. The lane has its own switch (browser.extension_control.local_desktop,
on by default); with no extension registered, or after it disconnects, the
normal browser backend runs unchanged. browser_vision turns the extension's
capture into the usual native/auxiliary vision result.

Bridge: forwards controller commands only to a connection that registered,
refuses unknown or oversized commands, relays results.

Extension: service-worker controller with its own native connection,
trusted chrome.debugger input (press/release only: a hover move waits 5 s on
background tabs), refusal of password and payment-card fields and of
browser, extension, file and Web Store pages, panel switch, Stop, tab
hand-over, recent actions, Alt+Shift+A and right-click "send to AITURK".
Cancelling Chrome's debugging bar stops the agent. Existing panel flows and
privacy rules are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:52 +03:00

340 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { test } from 'node:test'
import * as page from '../extension/page-agent.js'
import { CAPABILITIES, checkUrl, createController, parseKey, ToolError } from '../extension/controller.js'
const { JSDOM } = createRequire(import.meta.url)('jsdom')
const GLOBALS = ['window', 'document', 'location', 'getComputedStyle', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'innerHeight', 'innerWidth']
function pageFixture(t, html) {
const dom = new JSDOM(`<!doctype html><html><head><title>Sipariş formu</title></head><body>${html}</body></html>`,
{ url: 'https://shop.example.test/checkout?session=gizli' })
const { window } = dom
window.HTMLElement.prototype.getBoundingClientRect = function () {
if (this.dataset.size === '0') return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
return { left: 10, top: 20, right: 110, bottom: 40, width: 100, height: 20 }
}
window.HTMLElement.prototype.scrollIntoView = () => {}
window.document.elementFromPoint = () => null
window.scrollBy = () => {}
for (const key of GLOBALS) globalThis[key] = key === 'innerHeight' ? 800 : key === 'innerWidth' ? 1200 : window[key]
t.after(() => { delete globalThis.__aiturkRefs; window.close(); for (const key of GLOBALS) delete globalThis[key] })
return window
}
const FORM = `
<h1>Ödeme</h1>
<nav aria-hidden="true"><a href="/gizli">Gizli menü</a></nav>
<a href="/docs">Belgeler</a>
<label for="mail">E-posta</label><input id="mail" type="email" value="ali@example.test">
<input type="password" aria-label="Şifre" value="cok-gizli">
<input autocomplete="cc-number" aria-label="Kart numarası">
<button style="display:none">Görünmez</button>
<button data-size="0">Sıfır boyut</button>
<select aria-label="Şehir"><option>Ankara</option><option selected>İzmir</option></select>
<label><input type="checkbox" checked> Koşulları kabul ediyorum</label>
<p>Toplam tutar: 120 TL</p>
<button>Siparişi tamamla</button>
<img src="data:image/png;base64,${'A'.repeat(200)}" alt="Ürün">`
test('snapshot lists visible controls with refs, masks passwords and skips hidden parts', t => {
pageFixture(t, FORM)
const result = page.snapshotPage({ full: false })
const lines = result.snapshot.split('\n')
assert.equal(lines[0], 'Sayfa: Sipariş formu')
assert.ok(lines.includes('- heading "Ödeme" [level=1]'))
assert.ok(lines.includes('- link "Belgeler" [ref=e1]'))
assert.ok(lines.includes('- textbox "E-posta" [ref=e2] (değer: "ali@example.test")'))
const password = lines.find(line => line.includes('"Şifre"'))
assert.ok(password.includes('(değer: "••••")') && password.includes('şifre alanı: ajan yazamaz'))
assert.ok(!result.snapshot.includes('cok-gizli'))
assert.ok(!result.snapshot.includes('Gizli menü'))
assert.ok(!result.snapshot.includes('Görünmez') && !result.snapshot.includes('Sıfır boyut'))
assert.ok(lines.some(line => line.startsWith('- combobox "Şehir"') && line.includes('(değer: "İzmir")')))
assert.ok(lines.some(line => line.startsWith('- checkbox "Koşulları kabul ediyorum"') && line.includes('[checked]')))
assert.ok(!result.snapshot.includes('Toplam tutar'), 'compact view leaves plain text out')
assert.equal(result.element_count, 7)
assert.equal(globalThis.__aiturkRefs.size, 7)
const full = page.snapshotPage({ full: true })
assert.ok(full.snapshot.includes('- text: Toplam tutar: 120 TL'))
assert.ok(full.snapshot.includes('→ https://shop.example.test/docs'))
assert.ok(full.snapshot.includes('- option "Ankara"'))
const short = page.snapshotPage({ limit: 2000, full: true })
assert.ok(short.snapshot.length <= 2300)
})
test('a long page is cut with an explicit note instead of silently', t => {
pageFixture(t, Array.from({ length: 400 }, (_, index) => `<button>Düğme numarası ${index}</button>`).join(''))
const result = page.snapshotPage({ limit: 2000 })
assert.equal(result.truncated, true)
assert.match(result.snapshot, /satır daha var; kısaltıldı/)
})
test('refs resolve to coordinates; stale refs fail with a recoverable message', t => {
const window = pageFixture(t, FORM)
page.snapshotPage({})
const located = page.locateRef('@e2')
assert.deepEqual([located.x, located.y, located.tag, located.password], [60, 30, 'input', false])
window.document.getElementById('mail').remove()
assert.match(page.locateRef('@e2').error, /browser_snapshot/)
assert.match(page.locateRef('e99').error, /bulunamadı/)
})
test('typing refuses password and card fields, focuses text fields and fires framework events', t => {
const window = pageFixture(t, FORM)
page.snapshotPage({})
const refs = [...globalThis.__aiturkRefs.keys()]
const refOf = predicate => `@${refs.find(ref => predicate(globalThis.__aiturkRefs.get(ref).deref()))}`
const passwordRef = refOf(element => element.type === 'password')
const cardRef = refOf(element => element.getAttribute('autocomplete') === 'cc-number')
assert.equal(page.prepareTyping(passwordRef).handoff, true)
assert.match(page.prepareTyping(cardRef).error, /Kart/)
assert.match(page.prepareTyping('@e1').error, /yazı alanı değil/)
const mail = window.document.getElementById('mail')
let inputs = 0
mail.addEventListener('input', () => inputs++)
assert.equal(page.prepareTyping('@e2').focused, true)
assert.deepEqual(page.typeWithEvents('@e2', 'veli@example.test'), { value: 'veli@example.test' })
assert.equal(inputs, 1)
assert.deepEqual(page.readValue('@e2'), { value: 'veli@example.test' })
const selectRef = refOf(element => element.tagName === 'SELECT')
assert.deepEqual(page.prepareTyping(selectRef), { select: true })
assert.deepEqual(page.typeWithEvents(selectRef, 'ankara'), { value: 'Ankara' })
assert.match(page.typeWithEvents(selectRef, 'Paris').error, /seçeneği yok/)
})
test('images are listed with data URLs shortened; annotation labels come and go', t => {
const window = pageFixture(t, FORM)
const images = page.listImages()
assert.equal(images.count, 1)
assert.ok(images.images[0].src.length < 70 && images.images[0].alt === 'Ürün')
page.snapshotPage({})
const labels = page.annotateRefs(true)
assert.equal(labels.annotations.length, 7)
assert.ok(window.document.getElementById('__aiturk_labels'))
page.annotateRefs(false)
assert.equal(window.document.getElementById('__aiturk_labels'), null)
})
test('keys and addresses are validated before anything touches the page', () => {
assert.deepEqual(parseKey('Enter'), { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', modifiers: 0 })
assert.deepEqual(parseKey('ctrl+a'), { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65, text: undefined, modifiers: 2 })
assert.equal(parseKey('Shift+Tab').modifiers, 8)
assert.equal(parseKey('esc').key, 'Escape')
assert.throws(() => parseKey('Hyper+x'), ToolError)
assert.throws(() => parseKey('NotAKey'), ToolError)
assert.equal(checkUrl('example.test/a?b=1'), 'https://example.test/a?b=1')
assert.equal(checkUrl('http://localhost:5173/'), 'http://localhost:5173/')
for (const blocked of ['chrome://settings', 'file:///C:/Windows/win.ini', 'javascript:alert(1)', 'chrome-extension://abc/panel.html',
'https://chromewebstore.google.com/detail/x', 'https://user:pass@example.test/']) {
assert.throws(() => checkUrl(blocked), ToolError, blocked)
}
})
// ── controller with a fake Chrome ──────────────────────────────────────
const hub = () => ({ listeners: new Set(), addListener(fn) { this.listeners.add(fn) }, removeListener(fn) { this.listeners.delete(fn) },
emit(...args) { for (const fn of [...this.listeners]) fn(...args) } })
const pick = (store, keys) => Object.fromEntries([keys].flat().filter(key => key in store).map(key => [key, store[key]]))
const until = async (condition, label) => {
for (let index = 0; index < 400; index++) { if (condition()) return; await new Promise(resolve => setTimeout(resolve, 5)) }
assert.fail(`zaman aşımı: ${label}`)
}
function fakeChrome({ pageScript = {}, tabs: initialTabs = [] } = {}) {
const local = {}, session = {}, native = [], debuggerCalls = [], notifications = [], groups = new Map()
const tabs = new Map(initialTabs.map(tab => [tab.id, { status: 'complete', windowId: 1, active: true, ...tab }]))
let nextTab = 100, nextGroup = 1, port = null
const onUpdated = hub()
const respond = message => {
const result = message.action === 'status' ? { profile: 'default', local: true }
: message.action === 'browser.register' ? { registered: message.params.enabled }
: message.action === 'sessions.list' ? { sessions: [{ id: 'durable-1', title: 'Rapor işi' }] }
: { accepted: true }
port.onMessage.emit({ id: message.id, result })
}
const chrome = {
runtime: {
connectNative: () => {
port = { onMessage: hub(), onDisconnect: hub(), disconnected: false, disconnect() { this.disconnected = true },
postMessage: message => { native.push(message); queueMicrotask(() => respond(message)) } }
return port
},
sendMessage: async () => {},
},
storage: {
local: { get: async keys => pick(local, keys), set: async value => Object.assign(local, value) },
session: { get: async keys => structuredClone(pick(session, keys)), set: async value => Object.assign(session, structuredClone(value)) },
},
windows: { getLastFocused: async () => ({ id: 1 }) },
tabs: {
onUpdated,
create: async ({ url, active, windowId }) => {
const tab = { id: nextTab++, url, active, windowId, status: 'complete', title: '' }
tabs.set(tab.id, tab)
return { ...tab }
},
get: async id => { if (!tabs.has(id)) throw new Error(`No tab with id: ${id}`); return { ...tabs.get(id) } },
update: async (id, { url }) => {
const tab = tabs.get(id)
Object.assign(tab, { pendingUrl: url, status: 'loading' })
setTimeout(() => { Object.assign(tab, { url, pendingUrl: undefined, status: 'complete', title: 'Açılan sayfa' }); onUpdated.emit(id, { status: 'complete' }) }, 20)
return { ...tab }
},
goBack: async () => {},
query: async () => [...tabs.values()].filter(tab => tab.active).map(tab => ({ ...tab })),
group: async ({ tabIds, groupId }) => { const id = groupId ?? nextGroup++; groups.set(id, { windowId: tabs.get(tabIds[0]).windowId }); return id },
captureVisibleTab: async () => 'data:image/jpeg;base64,QUJD',
},
tabGroups: { get: async id => ({ id, ...groups.get(id) }), update: async (id, value) => Object.assign(groups.get(id), value) },
scripting: {
executeScript: async ({ target, func, args }) => {
const handler = pageScript[func.name]
if (!handler) throw new Error(`script not expected: ${func.name}`)
return [{ result: await handler(...args, target.tabId) }]
},
},
debugger: {
attach: async () => {}, detach: async () => {},
sendCommand: async (target, method, params) => { debuggerCalls.push([method, params]); return method === 'Page.captureScreenshot' ? { data: 'QUJD' } : {} },
},
notifications: { create: async (...args) => { notifications.push(args) } },
alarms: { create() {}, clear() {} },
action: { setBadgeText: async () => {} },
}
return { chrome, native, debuggerCalls, notifications, tabs, groups, local, session, get port() { return port } }
}
const SNAPSHOT = { snapshot: 'Sayfa: Açılan sayfa\n- button "Gönder" [ref=e1]', element_count: 1, url: 'https://example.test/', title: 'Açılan sayfa' }
async function connected(fake) {
const controller = createController({ chrome: fake.chrome, random: () => 'instance-1' })
await controller.connect()
await until(() => controller._state().registered, 'registration')
return controller
}
function command(fake, action, args, commandId = 'ab'.repeat(16)) {
fake.port.onMessage.emit({ event: 'browser.command', commandId, action, arguments: args, sessionId: 'durable-1' })
}
const resultFor = (fake, commandId = 'ab'.repeat(16)) => fake.native.find(message => message.action === 'browser.result' && message.params.commandId === commandId)
test('controller registers this Chrome for the IDE agent with a stable identity', async () => {
const fake = fakeChrome()
await connected(fake)
const register = fake.native.find(message => message.action === 'browser.register')
assert.deepEqual(register.params, { enabled: true, capabilities: [...CAPABILITIES], controllerId: 'aiturk-chrome-instance-1', browserProfileId: 'instance-1' })
assert.equal(fake.local.controllerInstance, 'instance-1')
})
test('navigate opens the agent its own background tab in an AITURK group and returns a snapshot', async () => {
const fake = fakeChrome({ pageScript: { snapshotPage: () => SNAPSHOT } })
await connected(fake)
command(fake, 'browser_navigate', { url: 'example.test' })
await until(() => resultFor(fake), 'navigate result')
const { params } = resultFor(fake)
assert.equal(params.ok, true)
assert.equal(params.result.success, true)
assert.equal(params.result.snapshot, SNAPSHOT.snapshot)
const tab = fake.tabs.get(100)
assert.equal(tab.active, false)
assert.equal(tab.url, 'https://example.test/')
assert.equal(fake.groups.get(1).title, 'AITURK · Rapor işi')
assert.equal(fake.groups.get(1).color, 'cyan')
})
test('blocked pages and missing tabs come back as tool errors the agent can read', async () => {
const fake = fakeChrome()
await connected(fake)
command(fake, 'browser_navigate', { url: 'chrome://settings/passwords' }, '01'.repeat(16))
command(fake, 'browser_click', { ref: '@e1' }, '02'.repeat(16))
await until(() => resultFor(fake, '01'.repeat(16)) && resultFor(fake, '02'.repeat(16)), 'refusals')
assert.deepEqual(resultFor(fake, '01'.repeat(16)).params.result.success, false)
assert.match(resultFor(fake, '01'.repeat(16)).params.result.error, /çalışamaz/)
assert.match(resultFor(fake, '02'.repeat(16)).params.result.error, /browser_navigate/)
assert.equal(fake.tabs.size, 0, 'a refused navigation never opens a tab')
})
test('click is a real mouse click at the element; a covering element is reported', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/', title: 'Örnek' }],
pageScript: { locateRef: () => ({ x: 60, y: 30, name: 'Gönder', covered: 'Çerez bildirimi' }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_click', { ref: '@e1' })
await until(() => resultFor(fake), 'click result')
const { result } = resultFor(fake).params
assert.equal(result.success, true)
assert.match(result.warning, /Çerez bildirimi/)
const mouse = fake.debuggerCalls.filter(([method]) => method === 'Input.dispatchMouseEvent').map(([, params]) => [params.type, params.x, params.y])
assert.deepEqual(mouse, [['mousePressed', 60, 30], ['mouseReleased', 60, 30]])
})
test('password fields are handed to the user: nothing is typed and a notification is shown', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/login' }],
pageScript: { prepareTyping: () => ({ error: 'Şifre alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_type', { ref: '@e2', text: 'parola' })
await until(() => resultFor(fake), 'type result')
assert.equal(resultFor(fake).params.result.success, false)
assert.ok(!fake.debuggerCalls.some(([method]) => method === 'Input.insertText'))
assert.equal(fake.notifications.length, 1)
})
test('typing inserts trusted text and reports what the field now holds', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
pageScript: { prepareTyping: () => ({ focused: true }), readValue: () => ({ value: 'İstanbul' }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_type', { ref: '@e3', text: 'İstanbul' })
await until(() => resultFor(fake), 'type result')
assert.deepEqual(resultFor(fake).params.result, { success: true, typed: '@e3', value: 'İstanbul' })
assert.deepEqual(fake.debuggerCalls.find(([method]) => method === 'Input.insertText')[1], { text: 'İstanbul' })
})
test('vision returns a JPEG capture of the agent tab', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/', title: 'Örnek' }] })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_vision', { question: 'Ne var?' })
await until(() => resultFor(fake), 'vision result')
assert.deepEqual(resultFor(fake).params.result, { success: true, screenshot: 'QUJD', mime: 'image/jpeg', url: 'https://example.test/', title: 'Örnek' })
})
test('a cancelled command never answers late; stopping turns control off at the IDE', async () => {
let release
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
pageScript: { snapshotPage: () => new Promise(resolve => { release = () => resolve(SNAPSHOT) }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_snapshot', {})
await until(() => release, 'snapshot started')
fake.port.onMessage.emit({ event: 'browser.cancel', commandId: 'ab'.repeat(16) })
release()
await new Promise(resolve => setTimeout(resolve, 50))
assert.equal(resultFor(fake), undefined)
const port = fake.port
const state = await controller.onMessage({ type: 'control.stop' })
assert.equal(state.enabled, false)
assert.deepEqual(fake.native.filter(message => message.action === 'browser.register').at(-1).params, { enabled: false })
assert.equal(port.disconnected, true)
assert.equal(fake.local.controlEnabled, false)
})
test('cancelling Chrome’s debugging bar is treated as the user stopping the agent', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
pageScript: { locateRef: () => ({ x: 1, y: 1, name: 'x' }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_click', { ref: '@e1' })
await until(() => resultFor(fake), 'click result')
controller.onDebuggerDetach({ tabId: 7 }, 'canceled_by_user')
await until(() => fake.local.controlEnabled === false, 'disabled')
assert.equal(fake.notifications.length, 1)
})