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>
157 lines
9.3 KiB
JavaScript
157 lines
9.3 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { readFile } from 'node:fs/promises'
|
||
import { createRequire } from 'node:module'
|
||
import { test } from 'node:test'
|
||
import { capturePageContext } from '../extension/page-context.js'
|
||
|
||
const { JSDOM } = createRequire(import.meta.url)('jsdom')
|
||
const eventHub = () => ({ listeners: [], addListener(callback) { this.listeners.push(callback) }, emit(value) { this.listeners.forEach(callback => callback(value)) } })
|
||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||
async function settle(condition) {
|
||
for (let count = 0; count < 100; count++) { if (condition()) return; await tick() }
|
||
assert.ok(condition(), 'UI did not settle')
|
||
}
|
||
|
||
const CONTROL_READY = { enabled: true, connected: true, registered: true, running: 0, tabs: [], shared: null,
|
||
log: [{ status: 'ok', text: 'Sayfa açılıyor: example.test' }, { status: 'failed', text: '<b>Yazılıyor</b>', note: 'Şifre alanına ajan yazamaz.' }] }
|
||
|
||
async function panel(t, respond, { controlState = CONTROL_READY, session = {} } = {}) {
|
||
const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8')
|
||
const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' })
|
||
const calls = [], controlCalls = []
|
||
const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) {
|
||
calls.push(message)
|
||
queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port))
|
||
} }
|
||
const runtimeMessages = eventHub()
|
||
const sessionStore = { ...session }
|
||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option,
|
||
chrome: {
|
||
runtime: { connectNative: () => port, onMessage: runtimeMessages,
|
||
sendMessage: async message => { controlCalls.push(message); return message.type === 'control.set' ? { ...controlState, enabled: message.enabled } : controlState } },
|
||
storage: { local: { get: async () => ({}), set: async () => {} }, onChanged: eventHub(),
|
||
session: { get: async keys => Object.fromEntries([keys].flat().filter(key => key in sessionStore).map(key => [key, sessionStore[key]])),
|
||
remove: async key => { delete sessionStore[key] }, set: async value => Object.assign(sessionStore, value) } },
|
||
tabs: { get: async id => ({ id, title: 'Kaynak sayfa', url: 'https://example.test/yazi?token=gizli#x' }) },
|
||
} })
|
||
await import(`../extension/panel.js?test=${Math.random()}`)
|
||
t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] })
|
||
const get = id => dom.window.document.getElementById(id)
|
||
await settle(() => !get('sessions').disabled)
|
||
return { dom, calls, controlCalls, runtimeMessages, port, get, async select(id) {
|
||
get('sessions').value = id
|
||
get('sessions').dispatchEvent(new dom.window.Event('change'))
|
||
await settle(() => !get('sessions').disabled)
|
||
} }
|
||
}
|
||
|
||
function fixtureResponse(message, emit) {
|
||
const result = message.action === 'status' ? { profile: 'default' }
|
||
: message.action === 'sessions.list' ? { profile: 'default', sessions: [{ id: 'one', title: 'Birinci iş' }, { id: 'two', title: 'İkinci iş' }] }
|
||
: message.action === 'session.open' ? { messages: [{ role: 'user', text: '<img src=x onerror=alert(1)> önceden yazıldı' }], model: 'IDE modeli' }
|
||
: { messages: [] }
|
||
emit({ id: message.id, result })
|
||
}
|
||
|
||
test('panel safely renders real history and puts the submitted user before early streaming tokens', async t => {
|
||
const p = await panel(t, (message, emit) => {
|
||
if (message.action !== 'message.send') return fixtureResponse(message, emit)
|
||
emit({ event: 'message.start', sessionId: 'one' })
|
||
emit({ event: 'message.delta', sessionId: 'one', text: 'İlk yanıt' })
|
||
emit({ id: message.id, result: { accepted: true } })
|
||
})
|
||
await p.select('one')
|
||
assert.equal(p.get('transcript').querySelector('img'), null)
|
||
assert.ok(p.get('transcript').textContent.includes('<img'))
|
||
p.get('message').value = 'Buradan devam edelim'
|
||
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
|
||
await settle(() => p.get('message').value === '')
|
||
const messages = [...p.get('transcript').querySelectorAll('.text')].map(node => node.textContent)
|
||
assert.deepEqual(messages.slice(-2), ['Buradan devam edelim', 'İlk yanıt'])
|
||
assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1)
|
||
assert.equal(p.calls.find(call => call.action === 'message.send').params.sessionId, 'one')
|
||
})
|
||
|
||
test('task switching drops old events; disconnect keeps draft and does not retry a mutation', async t => {
|
||
const p = await panel(t, (message, emit, port) => {
|
||
if (message.action === 'message.send') { port.onDisconnect.emit(); return }
|
||
fixtureResponse(message, emit)
|
||
})
|
||
await p.select('one')
|
||
await p.select('two')
|
||
p.port.onMessage.emit({ event: 'message.delta', sessionId: 'one', text: 'YANLIŞ KONUŞMA' })
|
||
assert.ok(!p.get('transcript').textContent.includes('YANLIŞ'))
|
||
p.get('message').value = 'Kaybolmasın'
|
||
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
|
||
await settle(() => p.get('notice').textContent.includes('Taslağınız korundu'))
|
||
assert.equal(p.get('message').value, 'Kaybolmasın')
|
||
assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1)
|
||
assert.equal(p.get('send').disabled, true)
|
||
})
|
||
|
||
test('events arriving while history opens are not silently lost', async t => {
|
||
const p = await panel(t, (message, emit) => {
|
||
if (message.action === 'session.open') {
|
||
emit({ event: 'message.start', sessionId: message.params.sessionId })
|
||
emit({ event: 'message.delta', sessionId: message.params.sessionId, text: 'Açılırken gelen yanıt' })
|
||
}
|
||
fixtureResponse(message, emit)
|
||
})
|
||
await p.select('one')
|
||
assert.ok(p.get('transcript').textContent.includes('Açılırken gelen yanıt'))
|
||
})
|
||
|
||
test('explicit page capture excludes form values and strips URL credentials in query and fragment', t => {
|
||
const dom = new JSDOM('<body><p>Görünür içerik</p><input value="secret"><textarea>private draft</textarea><div hidden>hidden secret</div><script>private code</script></body>',
|
||
{ url: 'https://example.test/page?token=secret#private' })
|
||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, location: dom.window.location,
|
||
NodeFilter: dom.window.NodeFilter, getComputedStyle: dom.window.getComputedStyle })
|
||
dom.window.HTMLElement.prototype.getClientRects = () => [{ width: 100 }]
|
||
t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'location', 'NodeFilter', 'getComputedStyle']) delete globalThis[key] })
|
||
const context = capturePageContext()
|
||
assert.equal(context.text, 'Görünür içerik')
|
||
assert.equal(context.url, 'https://example.test/page')
|
||
assert.equal(context.selection, false)
|
||
})
|
||
|
||
test('agent control section shows the live state, never renders log text as HTML, and drives the background', async t => {
|
||
const p = await panel(t, fixtureResponse)
|
||
await settle(() => p.get('control-status').textContent.startsWith('Hazır'))
|
||
assert.equal(p.get('control-enabled').checked, true)
|
||
assert.equal(p.get('control-stop').hidden, true)
|
||
assert.equal(p.get('control-log').querySelector('b'), null)
|
||
assert.ok(p.get('control-log').textContent.includes('<b>Yazılıyor</b>'))
|
||
assert.ok(p.get('control-log').textContent.includes('Şifre alanına ajan yazamaz.'))
|
||
|
||
p.runtimeMessages.emit({ type: 'control.state', state: { ...CONTROL_READY, running: 1 } })
|
||
assert.equal(p.get('control-stop').hidden, false)
|
||
assert.ok(p.get('control-status').textContent.includes('çalışıyor'))
|
||
|
||
await p.select('one')
|
||
p.get('control-share').dispatchEvent(new p.dom.window.Event('click'))
|
||
p.get('control-stop').dispatchEvent(new p.dom.window.Event('click'))
|
||
p.get('control-enabled').checked = false
|
||
p.get('control-enabled').dispatchEvent(new p.dom.window.Event('change'))
|
||
await settle(() => p.controlCalls.some(call => call.type === 'control.set'))
|
||
assert.deepEqual(p.controlCalls.find(call => call.type === 'control.share'), { type: 'control.share', sessionId: 'one' })
|
||
assert.ok(p.controlCalls.some(call => call.type === 'control.stop'))
|
||
assert.deepEqual(p.controlCalls.find(call => call.type === 'control.set'), { type: 'control.set', enabled: false })
|
||
await settle(() => p.get('control-status').textContent.startsWith('Kapalı'))
|
||
})
|
||
|
||
test('a selection sent from the right-click menu waits for the first conversation and drops URL secrets', async t => {
|
||
const p = await panel(t, fixtureResponse, { session: { pendingContext: { kind: 'selection', tabId: 7, text: 'Seçilen paragraf', at: Date.now() } } })
|
||
await settle(() => !p.get('attachment').hidden)
|
||
assert.ok(p.get('attachment-label').textContent.includes('Seçim: Kaynak sayfa'))
|
||
assert.ok(p.get('notice').textContent.includes('konuşması seçin'))
|
||
await p.select('one')
|
||
assert.equal(p.get('attachment').hidden, false, 'the first choice keeps the pending attachment')
|
||
p.get('message').value = 'Bunu özetle'
|
||
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
|
||
await settle(() => p.calls.some(call => call.action === 'message.send'))
|
||
const sent = p.calls.find(call => call.action === 'message.send').params.text
|
||
assert.ok(sent.includes('Seçilen paragraf'))
|
||
assert.ok(sent.includes('https://example.test/yazi'))
|
||
assert.ok(!sent.includes('token=gizli'))
|
||
})
|