Skip to content

Commit 2d6db97

Browse files
committed
fix(browser): retain upload input identity through dispatch
1 parent 74f6e48 commit 2d6db97

7 files changed

Lines changed: 981 additions & 184 deletions

File tree

‎apps/desktop/e2e/browser-tools.spec.ts‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,34 @@ const CAPABILITIES_FIXTURE = `<!doctype html><title>Capabilities fixture</title>
6262
const POPUP_FIXTURE = `<!doctype html><title>Authorize fixture</title>
6363
<button onclick="window.opener.postMessage('granted', '*'); window.close()">Allow</button>`
6464

65+
const UPLOAD_TARGET_FIXTURE = `<!doctype html><title>Upload target fixture</title>
66+
<div id="surface"></div>
67+
<script>
68+
const params = new URLSearchParams(location.search);
69+
const surface = document.getElementById('surface');
70+
const root = params.get('shadow') === '1' ? surface.attachShadow({ mode: 'open' }) : surface;
71+
root.innerHTML = '<div role="button" aria-label="Upload original">Attach file<input id="original" type="file" hidden></div><input id="decoy" type="file" hidden>';
72+
const original = root.querySelector('#original');
73+
const decoy = root.querySelector('#decoy');
74+
window.uploadTestState = async () => ({
75+
original: await Promise.all(Array.from(original.files, async (file) => ({ name: file.name, text: await file.text() }))),
76+
decoy: await Promise.all(Array.from(decoy.files, async (file) => ({ name: file.name, text: await file.text() }))),
77+
currentCount: root.querySelector('#original').files.length,
78+
});
79+
window.mutateUploadTarget = (mutation) => {
80+
if (mutation === 'replace') original.replaceWith(original.cloneNode(true));
81+
if (mutation === 'disable') original.disabled = true;
82+
};
83+
if (params.get('steal') === '1') {
84+
new MutationObserver(() => {
85+
const marker = original.getAttribute('data-sim-agent-upload');
86+
if (!marker) return;
87+
original.removeAttribute('data-sim-agent-upload');
88+
decoy.setAttribute('data-sim-agent-upload', marker);
89+
}).observe(root, { subtree: true, attributes: true, attributeFilter: ['data-sim-agent-upload'] });
90+
}
91+
</script>`
92+
6593
test.describe('browser tools', () => {
6694
const calls = new Map<
6795
string,
@@ -76,6 +104,7 @@ test.describe('browser tools', () => {
76104
let app: ElectronApplication
77105
let window: Page
78106
let callCount = 0
107+
let beforeUploadResponse: (() => Promise<void>) | undefined
79108

80109
test.beforeAll(async () => {
81110
server = createServer(async (request, response) => {
@@ -129,13 +158,33 @@ test.describe('browser tools', () => {
129158
const { toolCallId, index } = JSON.parse(body)
130159
const reference = claimed.get(toolCallId)?.args.paths
131160
const found = Array.isArray(reference) && reference[index] === 'files/receipt.txt'
161+
const beforeResponse = beforeUploadResponse
162+
beforeUploadResponse = undefined
163+
try {
164+
await beforeResponse?.()
165+
} catch (error) {
166+
response.writeHead(500, { 'Content-Type': 'text/plain' })
167+
response.end(String(error))
168+
return
169+
}
132170
response.writeHead(found ? 200 : 404, {
133171
'Content-Type': found ? 'application/octet-stream' : 'application/json',
134172
...(found ? { 'Content-Disposition': 'attachment; filename="receipt.txt"' } : {}),
135173
})
136174
response.end(found ? 'receipt-bytes' : JSON.stringify({ error: 'File not found' }))
137175
return
138176
}
177+
if (path === '/upload-target' || path === '/upload-host') {
178+
const params = new URL(request.url ?? '/', 'http://127.0.0.1').searchParams
179+
const frameOrigin = params.get('kind') === 'oopif' ? origin : site
180+
response.writeHead(200, { 'Content-Type': 'text/html' })
181+
response.end(
182+
path === '/upload-target'
183+
? UPLOAD_TARGET_FIXTURE
184+
: `<!doctype html><title>Upload frame fixture</title><iframe src="${frameOrigin}/upload-target" title="Upload frame"></iframe>`
185+
)
186+
return
187+
}
139188
if (path === '/doc.pdf') {
140189
response.writeHead(200, { 'Content-Type': 'application/pdf' })
141190
response.end(
@@ -226,6 +275,7 @@ test.describe('browser tools', () => {
226275
})
227276

228277
test.afterEach(async () => {
278+
beforeUploadResponse = undefined
229279
await app?.close()
230280
calls.clear()
231281
claimed.clear()
@@ -330,6 +380,136 @@ test.describe('browser tools', () => {
330380
await expect.poll(dataset).toMatchObject({ upload: 'receipt.txt:receipt-bytes' })
331381
})
332382

383+
async function openUploadTarget(
384+
kind: 'root' | 'same-origin' | 'oopif' | 'shadow',
385+
steal = false
386+
) {
387+
const framed = kind === 'same-origin' || kind === 'oopif'
388+
const url = framed
389+
? `${site}/upload-host?kind=${kind}`
390+
: `${site}/upload-target?shadow=${kind === 'shadow' ? '1' : '0'}&steal=${steal ? '1' : '0'}`
391+
const response = await execute('browser_open_url', { url })
392+
expect(response.ok, response.error).toBe(true)
393+
const evaluate = (expression: string, inTopFrame = false) =>
394+
app.evaluate(
395+
({ webContents }, { url, framed, expression }) => {
396+
const contents = webContents
397+
.getAllWebContents()
398+
.find((contents) => contents.getURL() === url)
399+
const frame = framed ? contents?.mainFrame.frames[0] : contents?.mainFrame
400+
if (!frame) throw new Error('Missing upload fixture frame')
401+
return frame.executeJavaScript(expression)
402+
},
403+
{ url, framed: framed && !inTopFrame, expression }
404+
)
405+
await expect.poll(() => evaluate('typeof window.uploadTestState')).toBe('function')
406+
if (kind === 'oopif') {
407+
expect(
408+
await app.evaluate(({ webContents }, url) => {
409+
const contents = webContents
410+
.getAllWebContents()
411+
.find((contents) => contents.getURL() === url)
412+
const child = contents?.mainFrame.frames[0]
413+
return child && child.processId !== contents?.mainFrame.processId
414+
}, url)
415+
).toBe(true)
416+
}
417+
const snapshot = await execute('browser_snapshot', {})
418+
expect(snapshot.ok, snapshot.error).toBe(true)
419+
const outline = (snapshot.result as { outline: string }).outline
420+
const match = outline
421+
.split('\n')
422+
.find((line) => line.includes('"Upload original"'))
423+
?.match(/\[ref=(\d+)\]/)
424+
expect(match, outline).toBeTruthy()
425+
return { elementId: Number(match?.[1]), evaluate }
426+
}
427+
428+
test('pins uploads to the original input when page code steals a DOM marker', async () => {
429+
const { elementId, evaluate } = await openUploadTarget('root', true)
430+
const upload = await execute('browser_upload_file', { elementId, paths: ['files/receipt.txt'] })
431+
432+
expect(upload.ok, upload.error).toBe(true)
433+
expect(await evaluate('window.uploadTestState()')).toEqual({
434+
original: [{ name: 'receipt.txt', text: 'receipt-bytes' }],
435+
decoy: [],
436+
currentCount: 1,
437+
})
438+
})
439+
440+
for (const mutation of ['replace', 'disable', 'navigate-frame'] as const) {
441+
test(`refuses uploads when the target changes during staging: ${mutation}`, async () => {
442+
const { elementId, evaluate } = await openUploadTarget(
443+
mutation === 'navigate-frame' ? 'same-origin' : 'root'
444+
)
445+
let mutated = false
446+
beforeUploadResponse = async () => {
447+
if (mutation === 'navigate-frame') {
448+
await evaluate('parent.previousUploadInput = document.querySelector("#original")')
449+
await evaluate('location.replace("/upload-target?after=1")')
450+
await expect.poll(() => evaluate('location.search')).toBe('?after=1')
451+
await expect.poll(() => evaluate('typeof window.uploadTestState')).toBe('function')
452+
} else {
453+
await evaluate(`window.mutateUploadTarget(${JSON.stringify(mutation)})`)
454+
}
455+
mutated = true
456+
}
457+
const upload = await execute('browser_upload_file', {
458+
elementId,
459+
paths: ['files/receipt.txt'],
460+
})
461+
462+
expect(mutated).toBe(true)
463+
expect(upload.ok, JSON.stringify(upload)).toBe(false)
464+
expect(await evaluate('window.uploadTestState()')).toEqual({
465+
original: [],
466+
decoy: [],
467+
currentCount: 0,
468+
})
469+
if (mutation === 'navigate-frame') {
470+
expect(await evaluate('parent.previousUploadInput.files.length')).toBe(0)
471+
}
472+
})
473+
}
474+
475+
test('refuses uploads after their same-origin frame is removed during staging', async () => {
476+
const { elementId, evaluate } = await openUploadTarget('same-origin')
477+
let removed = false
478+
beforeUploadResponse = async () => {
479+
await evaluate(
480+
'window.removedUploadInput = document.querySelector("iframe").contentDocument.querySelector("#original"); document.querySelector("iframe").remove()',
481+
true
482+
)
483+
removed = true
484+
}
485+
const upload = await execute('browser_upload_file', {
486+
elementId,
487+
paths: ['files/receipt.txt'],
488+
})
489+
490+
expect(removed).toBe(true)
491+
expect(upload.ok, JSON.stringify(upload)).toBe(false)
492+
expect(await evaluate('window.removedUploadInput.files.length', true)).toBe(0)
493+
})
494+
495+
for (const kind of ['same-origin', 'oopif', 'shadow'] as const) {
496+
test(`uploads through a pinned input in a ${kind} context`, async () => {
497+
const { elementId, evaluate } = await openUploadTarget(kind)
498+
const upload = await execute('browser_upload_file', {
499+
elementId,
500+
paths: ['files/receipt.txt'],
501+
})
502+
503+
expect(upload.ok, upload.error).toBe(true)
504+
expect(upload.result).toMatchObject({ uploaded: [{ name: 'receipt.txt', size: 13 }] })
505+
expect(await evaluate('window.uploadTestState()')).toEqual({
506+
original: [{ name: 'receipt.txt', text: 'receipt-bytes' }],
507+
decoy: [],
508+
currentCount: 1,
509+
})
510+
})
511+
}
512+
333513
test('keeps window.opener for page popups and returns to the opener when they close', async () => {
334514
const { ref, dataset } = await openCapabilities()
335515

0 commit comments

Comments
 (0)