|
1 | 1 | import { mkdtempSync } from 'node:fs' |
2 | | -import { createServer, type Server } from 'node:http' |
| 2 | +import { createServer, type IncomingMessage, type Server } from 'node:http' |
3 | 3 | import { tmpdir } from 'node:os' |
4 | 4 | import { join } from 'node:path' |
5 | 5 | import { fileURLToPath } from 'node:url' |
@@ -62,6 +62,36 @@ const CAPABILITIES_FIXTURE = `<!doctype html><title>Capabilities fixture</title> |
62 | 62 | const POPUP_FIXTURE = `<!doctype html><title>Authorize fixture</title> |
63 | 63 | <button onclick="window.opener.postMessage('granted', '*'); window.close()">Allow</button>` |
64 | 64 |
|
| 65 | +/** Sign-in and payment popup patterns: blank-then-navigate, and form posts into a new tab. */ |
| 66 | +const popupsFixture = ( |
| 67 | + appOrigin: string, |
| 68 | + popupOrigin: string |
| 69 | +) => `<!doctype html><title>Popups fixture</title> |
| 70 | +<form id="same-session" method="post" action="${popupOrigin}/echo" target="_blank"><input name="note" value="hello"></form> |
| 71 | +<form id="cross-session" method="post" action="${appOrigin}/echo" target="_blank"><input name="note" value="hello"></form> |
| 72 | +<script> |
| 73 | + let popup = null; |
| 74 | + function signIn() { |
| 75 | + popup = window.open('', 'signin', 'width=400,height=400'); |
| 76 | + document.body.dataset.handle = String(popup !== null); |
| 77 | + popup?.document.write('<script>opener.postMessage("ready", "*")<\\/script>'); |
| 78 | + } |
| 79 | + addEventListener('message', (event) => { |
| 80 | + if (event.data !== 'ready') return void (document.body.dataset.connected = event.data); |
| 81 | + document.body.dataset.blank = 'ready'; |
| 82 | + popup.location.href = new URLSearchParams(location.search).get('next') || '${popupOrigin}/authorize'; |
| 83 | + }); |
| 84 | +</script>` |
| 85 | + |
| 86 | +async function readBody(request: IncomingMessage): Promise<string> { |
| 87 | + let body = '' |
| 88 | + for await (const chunk of request) body += chunk.toString() |
| 89 | + return body |
| 90 | +} |
| 91 | + |
| 92 | +const echoPage = (method: string | undefined, body: string) => |
| 93 | + `<!doctype html><title>${method} ${body}</title>` |
| 94 | + |
65 | 95 | const UPLOAD_TARGET_FIXTURE = `<!doctype html><title>Upload target fixture</title> |
66 | 96 | <div id="surface"></div> |
67 | 97 | <script> |
@@ -105,6 +135,7 @@ test.describe('browser tools', () => { |
105 | 135 | let window: Page |
106 | 136 | let callCount = 0 |
107 | 137 | let beforeUploadResponse: (() => Promise<void>) | undefined |
| 138 | + const appEchoes: string[] = [] |
108 | 139 |
|
109 | 140 | test.beforeAll(async () => { |
110 | 141 | server = createServer(async (request, response) => { |
@@ -153,8 +184,7 @@ test.describe('browser tools', () => { |
153 | 184 | return |
154 | 185 | } |
155 | 186 | if (path === '/api/desktop/tool/file') { |
156 | | - let body = '' |
157 | | - for await (const chunk of request) body += chunk.toString() |
| 187 | + const body = await readBody(request) |
158 | 188 | const { toolCallId, index } = JSON.parse(body) |
159 | 189 | const reference = claimed.get(toolCallId)?.args.paths |
160 | 190 | const found = Array.isArray(reference) && reference[index] === 'files/receipt.txt' |
@@ -192,9 +222,25 @@ test.describe('browser tools', () => { |
192 | 222 | ) |
193 | 223 | return |
194 | 224 | } |
| 225 | + if (path === '/echo') { |
| 226 | + const body = await readBody(request) |
| 227 | + appEchoes.push(`${request.method} ${body}`) |
| 228 | + response.writeHead(200, { 'Content-Type': 'text/html' }) |
| 229 | + response.end(echoPage(request.method, body)) |
| 230 | + return |
| 231 | + } |
| 232 | + if (path === '/authorize') { |
| 233 | + response.writeHead(200, { 'Content-Type': 'text/html' }) |
| 234 | + response.end(POPUP_FIXTURE) |
| 235 | + return |
| 236 | + } |
| 237 | + if (path === '/popups') { |
| 238 | + response.writeHead(200, { 'Content-Type': 'text/html' }) |
| 239 | + response.end(popupsFixture(origin, popupOrigin)) |
| 240 | + return |
| 241 | + } |
195 | 242 | if (path === '/api/desktop/tool/authorize') { |
196 | | - let body = '' |
197 | | - for await (const chunk of request) body += chunk.toString() |
| 243 | + const body = await readBody(request) |
198 | 244 | const authorization = calls.get(JSON.parse(body).toolCallId) |
199 | 245 | calls.delete(JSON.parse(body).toolCallId) |
200 | 246 | if (authorization) claimed.set(JSON.parse(body).toolCallId, authorization) |
@@ -224,9 +270,11 @@ test.describe('browser tools', () => { |
224 | 270 | origin = `http://127.0.0.1:${address.port}` |
225 | 271 | /** Pages outside the app origin browse in the agent partition, like any third-party site. */ |
226 | 272 | site = origin.replace('127.0.0.1', 'localhost') |
227 | | - popupServer = createServer((_request, response) => { |
| 273 | + popupServer = createServer(async (request, response) => { |
228 | 274 | response.writeHead(200, { 'Content-Type': 'text/html' }) |
229 | | - response.end(POPUP_FIXTURE) |
| 275 | + response.end( |
| 276 | + request.url === '/echo' ? echoPage(request.method, await readBody(request)) : POPUP_FIXTURE |
| 277 | + ) |
230 | 278 | }) |
231 | 279 | await new Promise<void>((resolve) => popupServer.listen(0, '127.0.0.1', resolve)) |
232 | 280 | const popupAddress = popupServer.address() |
@@ -276,6 +324,7 @@ test.describe('browser tools', () => { |
276 | 324 |
|
277 | 325 | test.afterEach(async () => { |
278 | 326 | beforeUploadResponse = undefined |
| 327 | + appEchoes.length = 0 |
279 | 328 | await app?.close() |
280 | 329 | calls.clear() |
281 | 330 | claimed.clear() |
@@ -626,6 +675,111 @@ test.describe('browser tools', () => { |
626 | 675 | .toHaveLength(1) |
627 | 676 | }) |
628 | 677 |
|
| 678 | + const tabsState = () => |
| 679 | + window.evaluate( |
| 680 | + (scope) => |
| 681 | + ( |
| 682 | + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } |
| 683 | + ).simDesktop.browserAgent.activateScope(scope), |
| 684 | + SCOPE |
| 685 | + ) |
| 686 | + const tabUrls = async () => (await tabsState()).tabs.map((tab) => tab.url) |
| 687 | + /** Runs script in a page with a user gesture, as the user's own click would. */ |
| 688 | + const inPage = (url: string, code: string) => |
| 689 | + app.evaluate( |
| 690 | + ({ webContents }, { url, code }) => |
| 691 | + webContents |
| 692 | + .getAllWebContents() |
| 693 | + .find((contents) => contents.getURL() === url) |
| 694 | + ?.executeJavaScript(code, true), |
| 695 | + { url, code } |
| 696 | + ) |
| 697 | + const pageDataset = (url: string) => inPage(url, '({ ...document.body.dataset })') |
| 698 | + |
| 699 | + test('adopts a blank popup that keeps its opener and returns there when it closes', async () => { |
| 700 | + const openerUrl = `${site}/popups` |
| 701 | + expect((await execute('browser_open_url', { url: openerUrl })).ok).toBe(true) |
| 702 | + // A second tab makes the strip neighbour differ from the opener. |
| 703 | + expect((await execute('browser_open_tab', { url: `${site}/other` })).ok).toBe(true) |
| 704 | + const openerTabId = (await tabsState()).tabs.find((tab) => tab.url === openerUrl)?.tabId |
| 705 | + expect(openerTabId).toBeTruthy() |
| 706 | + |
| 707 | + await inPage(openerUrl, 'signIn()') |
| 708 | + await expect |
| 709 | + .poll(() => pageDataset(openerUrl)) |
| 710 | + .toMatchObject({ handle: 'true', blank: 'ready' }) |
| 711 | + await expect.poll(tabUrls).toContain(`${popupOrigin}/authorize`) |
| 712 | + const popupTab = (await tabsState()).tabs.find((tab) => tab.url === `${popupOrigin}/authorize`) |
| 713 | + expect(popupTab?.active).toBe(true) |
| 714 | + |
| 715 | + await inPage(`${popupOrigin}/authorize`, "document.querySelector('button').click()") |
| 716 | + await expect.poll(() => pageDataset(openerUrl)).toMatchObject({ connected: 'granted' }) |
| 717 | + await expect.poll(async () => (await tabsState()).tabs.length).toBe(2) |
| 718 | + expect((await tabsState()).activeTabId).toBe(openerTabId) |
| 719 | + }) |
| 720 | + |
| 721 | + test('forgets the opener once the user switches tabs, closing the popup to its neighbour', async () => { |
| 722 | + const openerUrl = `${site}/popups` |
| 723 | + expect((await execute('browser_open_url', { url: openerUrl })).ok).toBe(true) |
| 724 | + expect((await execute('browser_open_tab', { url: `${site}/other` })).ok).toBe(true) |
| 725 | + const otherTabId = (await tabsState()).tabs.find((tab) => tab.url === `${site}/other`)?.tabId |
| 726 | + await inPage(openerUrl, 'signIn()') |
| 727 | + await expect.poll(tabUrls).toContain(`${popupOrigin}/authorize`) |
| 728 | + const popupTabId = (await tabsState()).tabs.find( |
| 729 | + (tab) => tab.url === `${popupOrigin}/authorize` |
| 730 | + )?.tabId |
| 731 | + for (const tabId of [otherTabId, popupTabId]) { |
| 732 | + await window.evaluate( |
| 733 | + ({ tabId, scope }) => |
| 734 | + ( |
| 735 | + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } |
| 736 | + ).simDesktop.browserAgent.panelAction({ action: 'switch-tab', tabId }, scope), |
| 737 | + { tabId, scope: SCOPE } |
| 738 | + ) |
| 739 | + await expect.poll(async () => (await tabsState()).activeTabId).toBe(tabId) |
| 740 | + } |
| 741 | + |
| 742 | + await inPage(`${popupOrigin}/authorize`, "document.querySelector('button').click()") |
| 743 | + await expect.poll(() => pageDataset(openerUrl)).toMatchObject({ connected: 'granted' }) |
| 744 | + await expect.poll(async () => (await tabsState()).tabs.length).toBe(2) |
| 745 | + expect((await tabsState()).activeTabId).toBe(otherTabId) |
| 746 | + }) |
| 747 | + |
| 748 | + test('keeps data: popups denied', async () => { |
| 749 | + const openerUrl = `${site}/popups` |
| 750 | + expect((await execute('browser_open_url', { url: openerUrl })).ok).toBe(true) |
| 751 | + expect(await inPage(openerUrl, "String(window.open('data:text/html,x') !== null)")).toBe( |
| 752 | + 'false' |
| 753 | + ) |
| 754 | + expect((await tabsState()).tabs).toHaveLength(1) |
| 755 | + }) |
| 756 | + |
| 757 | + test('posts a same-session form popup and refuses to reissue a cross-session one', async () => { |
| 758 | + const openerUrl = `${site}/popups` |
| 759 | + expect((await execute('browser_open_url', { url: openerUrl })).ok).toBe(true) |
| 760 | + await inPage(openerUrl, "document.getElementById('same-session').requestSubmit()") |
| 761 | + await expect.poll(() => inPage(`${popupOrigin}/echo`, 'document.title')).toBe('POST note=hello') |
| 762 | + |
| 763 | + const before = (await tabsState()).tabs.length |
| 764 | + await inPage(openerUrl, "document.getElementById('cross-session').requestSubmit()") |
| 765 | + // The handler decides popups in order, so once this one is listed the post was decided. |
| 766 | + await inPage(openerUrl, `void window.open('${site}/sentinel')`) |
| 767 | + await expect.poll(tabUrls).toContain(`${site}/sentinel`) |
| 768 | + expect((await tabsState()).tabs).toHaveLength(before + 1) |
| 769 | + expect(appEchoes).toEqual([]) |
| 770 | + }) |
| 771 | + |
| 772 | + test('keeps the opener of a Sim page blank popup that navigates within Sim', async () => { |
| 773 | + const openerUrl = `${origin}/popups?next=${encodeURIComponent(`${origin}/authorize`)}` |
| 774 | + expect((await execute('browser_open_url', { url: openerUrl })).ok).toBe(true) |
| 775 | + await inPage(openerUrl, 'signIn()') |
| 776 | + await expect.poll(tabUrls).toContain(`${origin}/authorize`) |
| 777 | + await inPage(`${origin}/authorize`, "document.querySelector('button').click()") |
| 778 | + await expect |
| 779 | + .poll(() => pageDataset(openerUrl)) |
| 780 | + .toMatchObject({ handle: 'true', connected: 'granted' }) |
| 781 | + }) |
| 782 | + |
629 | 783 | test('renders PDFs in the built-in viewer', async () => { |
630 | 784 | const response = await execute('browser_open_url', { url: `${site}/doc.pdf` }) |
631 | 785 | expect(response.ok, response.error).toBe(true) |
|
0 commit comments