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>
340 lines
18 KiB
JavaScript
340 lines
18 KiB
JavaScript
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)
|
||
})
|