Skip to content

Commit c714d45

Browse files
Merge remote-tracking branch 'origin/staging' into codex/research-sim-dashboards
# Conflicts: # packages/db/script-migrations-paused-billing-attribution.test.ts # packages/db/script-migrations/0016_backfill_search_vectors.integration.ts # packages/db/script-migrations/index.ts
2 parents 1f2c38d + 0edb79f commit c714d45

149 files changed

Lines changed: 4484 additions & 1228 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ on:
1010
- '.github/workflows/desktop-e2e.yml'
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
13+
- 'apps/sim/app/_shell/desktop-update-*.tsx'
14+
- 'apps/sim/app/layout.tsx'
15+
- 'apps/sim/hooks/use-desktop-update-state.ts'
16+
- 'apps/sim/lib/desktop/**'
1317
- 'apps/sim/app/workspace/**/browser-session/**'
1418
- 'apps/sim/app/_styles/**'
1519
- 'apps/sim/lib/postcss/**'
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { dirname, join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import {
7+
type ElectronApplication,
8+
_electron as electron,
9+
expect,
10+
type Page,
11+
test,
12+
} from '@playwright/test'
13+
import type { BrowserToolName } from '@sim/browser-protocol'
14+
import type { SimDesktopApi } from '@sim/desktop-bridge'
15+
import { getErrorMessage } from '@sim/utils/errors'
16+
17+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
18+
const SCOPE = 'browser-focus-e2e'
19+
const PRIMARY = process.platform === 'darwin' ? 'meta' : 'control'
20+
const SHELL_FIXTURE =
21+
'<!doctype html><title>Sim fixture</title><h1>Browser focus fixture</h1><textarea id="composer" aria-label="Composer"></textarea>'
22+
const page = (name: string) =>
23+
`<!doctype html><title>${name}</title><a href="/popup" target="_blank">Open popup</a><input aria-label="Field">`
24+
25+
type Bridge = typeof globalThis & { simDesktop: SimDesktopApi; shellMarker?: string }
26+
27+
/**
28+
* The browser shares one window with Sim, so focus and shortcuts decide whether
29+
* a keystroke lands in chat, in the page, or reloads all of Sim. These checks
30+
* drive the real shell and native tab views.
31+
*/
32+
test('browser focus and shortcuts stay with the surface the user is using', async () => {
33+
const reportPath =
34+
process.env.DESKTOP_BROWSER_FOCUS_REPORT_PATH ?? test.info().outputPath('browser-focus.json')
35+
const checks: {
36+
name: string
37+
status: 'passed' | 'failed'
38+
durationMs: number
39+
error?: string
40+
}[] = []
41+
const check = async (name: string, run: () => Promise<void>) => {
42+
const started = Date.now()
43+
try {
44+
await test.step(name, run)
45+
checks.push({ name, status: 'passed', durationMs: Date.now() - started })
46+
} catch (error) {
47+
checks.push({
48+
name,
49+
status: 'failed',
50+
durationMs: Date.now() - started,
51+
error: getErrorMessage(error),
52+
})
53+
throw error
54+
}
55+
}
56+
const calls = new Map<string, { chatId: string; toolName: BrowserToolName; args: unknown }>()
57+
const server: Server = createServer(async (request, response) => {
58+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
59+
if (path === '/api/desktop/tool/authorize') {
60+
let body = ''
61+
for await (const chunk of request) body += chunk.toString()
62+
const authorization = calls.get(JSON.parse(body).toolCallId)
63+
response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
64+
response.end(JSON.stringify(authorization ?? {}))
65+
return
66+
}
67+
// The app origin serves the shell; the same server on localhost is the web.
68+
const isSite = request.headers.host?.startsWith('localhost') === true
69+
response.writeHead(200, {
70+
'Content-Type': 'text/html',
71+
...(isSite ? {} : { 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/' }),
72+
})
73+
response.end(isSite ? page(path.slice(1)) : SHELL_FIXTURE)
74+
})
75+
const userData = mkdtempSync(join(tmpdir(), 'sim-browser-focus-e2e-'))
76+
let app: ElectronApplication | undefined
77+
let passed = false
78+
try {
79+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
80+
const address = server.address()
81+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
82+
const origin = `http://127.0.0.1:${address.port}`
83+
/** Pages outside the app origin browse in the agent partition, like any third-party site. */
84+
const site = origin.replace('127.0.0.1', 'localhost')
85+
const shellApp = await electron.launch({
86+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
87+
cwd: DESKTOP_DIR,
88+
env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
89+
})
90+
app = shellApp
91+
const shell: Page = await shellApp.firstWindow()
92+
await shellApp.evaluate(({ app, BrowserWindow }) => {
93+
const host = BrowserWindow.getAllWindows()[0]
94+
host.webContents.setBackgroundThrottling(false)
95+
app.focus({ steal: true })
96+
host.focus()
97+
})
98+
await expect(shell.getByRole('heading')).toHaveText('Browser focus fixture')
99+
await shell.evaluate(async (scope) => {
100+
const api = (globalThis as Bridge).simDesktop.browserAgent
101+
await api.activateScope(scope)
102+
const updateBounds = () =>
103+
api.setPanelBounds(
104+
{ x: 0, y: 120, width: innerWidth, height: innerHeight - 120 },
105+
null,
106+
scope
107+
)
108+
updateBounds()
109+
;(globalThis as Bridge & { boundsTimer?: number }).boundsTimer = window.setInterval(
110+
updateBounds,
111+
200
112+
)
113+
;(globalThis as Bridge).shellMarker = 'alive'
114+
}, SCOPE)
115+
116+
let callCount = 0
117+
const execute = async (tool: BrowserToolName, args: Record<string, unknown>) => {
118+
const callId = `browser-focus-${++callCount}`
119+
calls.set(callId, { chatId: SCOPE, toolName: tool, args })
120+
const result = await shell.evaluate(
121+
({ callId, tool, args, scope }) =>
122+
(globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
123+
{ callId, tool, args, scope: SCOPE }
124+
)
125+
expect(result.ok).toBe(true)
126+
return result.ok ? result.result : undefined
127+
}
128+
const panelAction = (action: Record<string, unknown>) =>
129+
shell.evaluate(
130+
({ action, scope }) =>
131+
(globalThis as Bridge).simDesktop.browserAgent.panelAction(
132+
action as unknown as Parameters<SimDesktopApi['browserAgent']['panelAction']>[0],
133+
scope
134+
),
135+
{ action, scope: SCOPE }
136+
)
137+
const setPanelFocused = (focused: boolean) =>
138+
shell.evaluate(
139+
({ focused, scope }) =>
140+
(globalThis as Bridge).simDesktop.browserAgent.setPanelFocused(focused, scope),
141+
{ focused, scope: SCOPE }
142+
)
143+
const tabCount = () =>
144+
shell.evaluate(
145+
async (scope) =>
146+
(await (globalThis as Bridge).simDesktop.browserAgent.activateScope(scope)).tabs.length,
147+
SCOPE
148+
)
149+
const shellAlive = () =>
150+
shell.evaluate(() => (globalThis as Bridge).shellMarker === 'alive').catch(() => false)
151+
const composerFocused = () =>
152+
shell.evaluate(() => document.hasFocus() && document.activeElement?.id === 'composer')
153+
const focusedPageUrls = () =>
154+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) =>
155+
BrowserWindow.getAllWindows()[0]
156+
.contentView.children.filter(
157+
(view) => view instanceof WebContentsView && view.webContents.isFocused()
158+
)
159+
.map((view) => (view as Electron.WebContentsView).webContents.getURL())
160+
)
161+
const visiblePageUrl = () =>
162+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
163+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
164+
(child) => child instanceof WebContentsView && child.getVisible()
165+
) as Electron.WebContentsView | undefined
166+
return view?.webContents.getURL() ?? null
167+
})
168+
/** Counts main-frame loads of the visible page from now on. */
169+
const countVisiblePageLoads = () =>
170+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
171+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
172+
(child) => child instanceof WebContentsView && child.getVisible()
173+
) as Electron.WebContentsView
174+
const counter = globalThis as typeof globalThis & { pageLoads?: number; counted?: boolean }
175+
counter.pageLoads = 0
176+
if (!counter.counted) {
177+
counter.counted = true
178+
view.webContents.on('did-finish-load', () => {
179+
counter.pageLoads = (counter.pageLoads ?? 0) + 1
180+
})
181+
}
182+
})
183+
const pageLoads = () =>
184+
shellApp.evaluate(() => (globalThis as typeof globalThis & { pageLoads?: number }).pageLoads)
185+
const focusVisiblePage = () =>
186+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
187+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
188+
(child) => child instanceof WebContentsView && child.getVisible()
189+
) as Electron.WebContentsView
190+
view.webContents.focus()
191+
})
192+
const pressInPage = (keyCode: string, modifiers: string[]) =>
193+
shellApp.evaluate(
194+
({ BrowserWindow, WebContentsView }, { keyCode, modifiers }) => {
195+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
196+
(child) => child instanceof WebContentsView && child.getVisible()
197+
) as Electron.WebContentsView
198+
const input = { keyCode, modifiers } as Electron.KeyboardInputEvent
199+
view.webContents.sendInputEvent({ ...input, type: 'keyDown' })
200+
view.webContents.sendInputEvent({ ...input, type: 'keyUp' })
201+
},
202+
{ keyCode, modifiers }
203+
)
204+
/** Clicks an application-menu item the way its accelerator would. */
205+
const clickMenu = (label: string) =>
206+
shellApp.evaluate(({ BrowserWindow, Menu }, label) => {
207+
const win = BrowserWindow.getAllWindows()[0]
208+
const find = (items: Electron.MenuItem[]): Electron.MenuItem | null => {
209+
for (const item of items) {
210+
if (item.label === label) return item
211+
const found = item.submenu ? find(item.submenu.items) : null
212+
if (found) return found
213+
}
214+
return null
215+
}
216+
const item = find(Menu.getApplicationMenu()?.items ?? [])
217+
if (!item) throw new Error(`No menu item ${label}`)
218+
item.click(undefined, win, win.webContents)
219+
}, label)
220+
221+
await check('the agent working never takes the caret from the composer', async () => {
222+
await shell.locator('#composer').click()
223+
await shell.keyboard.type('draft')
224+
for (const [tool, args] of [
225+
['browser_open_url', { url: `${site}/one` }],
226+
['browser_navigate', { url: `${site}/two` }],
227+
['browser_open_tab', { url: `${site}/three` }],
228+
] as const) {
229+
await execute(tool, args)
230+
await expect.poll(visiblePageUrl).not.toBeNull()
231+
expect(await composerFocused()).toBe(true)
232+
expect(await focusedPageUrls()).toEqual([])
233+
}
234+
// A link without an opener is focused by Chromium itself as it is created.
235+
const snapshot = await execute('browser_snapshot', {})
236+
const ref = /"Open popup" \[ref=(\d+)\]/.exec(
237+
String((snapshot as { outline?: string }).outline)
238+
)?.[1]
239+
expect(ref, 'snapshot lists the popup link').toBeTruthy()
240+
await execute('browser_click', { elementId: Number(ref) })
241+
await expect.poll(tabCount).toBe(3)
242+
await expect.poll(focusedPageUrls).toEqual([])
243+
expect(await composerFocused()).toBe(true)
244+
await shell.keyboard.type(' continues')
245+
await expect(shell.locator('#composer')).toHaveValue('draft continues')
246+
})
247+
248+
await check('an omnibox navigation hands focus to the page it loads', async () => {
249+
await panelAction({ action: 'switch-tab', tabId: '1' })
250+
await panelAction({ action: 'navigate', url: `${site}/four` })
251+
await expect.poll(focusedPageUrls).toEqual([`${site}/four`])
252+
})
253+
254+
await check('a popup the agent opens hands focus back to the page the user is in', async () => {
255+
const before = await tabCount()
256+
const snapshot = await execute('browser_snapshot', {})
257+
const ref = /"Open popup" \[ref=(\d+)\]/.exec(
258+
String((snapshot as { outline?: string }).outline)
259+
)?.[1]
260+
expect(ref, 'snapshot lists the popup link').toBeTruthy()
261+
await execute('browser_click', { elementId: Number(ref) })
262+
await expect.poll(tabCount).toBe(before + 1)
263+
await expect.poll(focusedPageUrls).toEqual([`${site}/four`])
264+
})
265+
266+
await check('reload keys typed in the page reload only that page', async () => {
267+
for (const [keyCode, modifiers] of [
268+
['R', [PRIMARY]],
269+
['R', [PRIMARY, 'shift']],
270+
['F5', []],
271+
] as const) {
272+
await countVisiblePageLoads()
273+
await focusVisiblePage()
274+
await pressInPage(keyCode, [...modifiers])
275+
await expect.poll(pageLoads).toBe(1)
276+
}
277+
})
278+
279+
await check('the page keeps its shortcut claim after the chrome reports blur', async () => {
280+
await setPanelFocused(true)
281+
await focusVisiblePage()
282+
await setPanelFocused(false)
283+
await countVisiblePageLoads()
284+
await clickMenu('Reload')
285+
await expect.poll(pageLoads).toBe(1)
286+
expect(await shellAlive()).toBe(true)
287+
})
288+
289+
await check('Back and Forward move through the focused page history', async () => {
290+
await panelAction({ action: 'navigate', url: `${site}/five` })
291+
await expect.poll(visiblePageUrl).toBe(`${site}/five`)
292+
await focusVisiblePage()
293+
await clickMenu('Back')
294+
await expect.poll(visiblePageUrl).toBe(`${site}/four`)
295+
await focusVisiblePage()
296+
await clickMenu('Forward')
297+
await expect.poll(visiblePageUrl).toBe(`${site}/five`)
298+
})
299+
300+
await check('browser shortcuts work while renderer chrome hides the page', async () => {
301+
const before = await tabCount()
302+
await shell.evaluate((scope) => {
303+
const bridge = globalThis as Bridge & { boundsTimer?: number }
304+
window.clearInterval(bridge.boundsTimer)
305+
bridge.simDesktop.browserAgent.setPanelBounds(null, null, scope)
306+
}, SCOPE)
307+
await setPanelFocused(true)
308+
await clickMenu('New Tab')
309+
await expect.poll(tabCount).toBe(before + 1)
310+
})
311+
passed = true
312+
} finally {
313+
mkdirSync(dirname(reportPath), { recursive: true })
314+
writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
315+
await app?.close()
316+
await new Promise<void>((resolve) => server.close(() => resolve()))
317+
rmSync(userData, { recursive: true, force: true })
318+
}
319+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type ComponentType, StrictMode, useState } from 'react'
2+
import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn'
3+
import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'
4+
import { createRoot } from 'react-dom/client'
5+
6+
interface UpdateNotificationFixtureProps {
7+
Notification: ComponentType
8+
}
9+
10+
function UpdateNotificationFixture({ Notification }: UpdateNotificationFixtureProps) {
11+
const [pathname, setPathname] = useState('/workspace/first')
12+
return (
13+
<PathnameContext.Provider value={pathname}>
14+
<ToastProvider>
15+
<Notification />
16+
<main className='flex flex-col gap-[16px] p-[48px]'>
17+
<ChipInput aria-label='Work in progress' placeholder='Keep working' />
18+
<Chip onClick={() => setPathname('/workspace/second')}>Switch workspace</Chip>
19+
<Chip
20+
onClick={() => {
21+
for (let index = 0; index < 3; index++) {
22+
toast({ message: `Background task ${index + 1}`, duration: 0 })
23+
}
24+
}}
25+
>
26+
Fill notification stack
27+
</Chip>
28+
<Chip onClick={() => toast.dismissAll()}>Clear notifications</Chip>
29+
<output aria-label='Current route'>{pathname}</output>
30+
</main>
31+
</ToastProvider>
32+
</PathnameContext.Provider>
33+
)
34+
}
35+
36+
/** Supplies the production notification through Sim's module aliases. */
37+
export function mountUpdateNotificationFixture(Notification: ComponentType) {
38+
const root = document.getElementById('root')
39+
if (!root) throw new Error('Missing fixture root')
40+
createRoot(root).render(
41+
<StrictMode>
42+
<UpdateNotificationFixture Notification={Notification} />
43+
</StrictMode>
44+
)
45+
}

‎apps/desktop/e2e/password-autofill.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ test.describe('saved password autofill', () => {
311311
await expect
312312
.poll(() => pageScript('document.getElementById("user").value'))
313313
.toBe('account9@example.test')
314+
await expect
315+
.poll(() => app.windows().some((page) => page.url().includes('credential-picker.html')))
316+
.toBe(false)
314317
await clickField()
315318
await picker()
316319
await pickerKey('Escape')

0 commit comments

Comments
 (0)