Skip to content

Commit a824794

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/prism-provider
# Conflicts: # apps/sim/providers/models.test.ts
2 parents 412f05f + 360d653 commit a824794

408 files changed

Lines changed: 74324 additions & 3680 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/CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ If you prefer not to use Docker. **All commands run from the repository root unl
254254
cd packages/db && bun run db:migrate && cd ../..
255255
```
256256

257-
For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for both local and CI/CD setups.
257+
For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for staging and production. `db:push` reconciles directly to the current schema without running versioned migration guards. For disposable local/dev databases, `bun run db:push --force` accepts Drizzle's data-loss prompts, including column drops.
258258

259259
4. **Run the Development Servers:**
260260

.github/workflows/migrations.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ jobs:
7171
7272
if [ "${ENVIRONMENT}" = "dev" ]; then
7373
echo "Dev environment — pushing schema directly (db:push)"
74+
# Dev deliberately forces direct schema reconciliation; staging and
75+
# production use guarded versioned migrations in the other branch.
7476
# drizzle-kit push needs a TTY to resolve ambiguous renames (--force only
7577
# covers data-loss). In CI it throws "Interactive prompts require a TTY
7678
# terminal" but still exits 0, so the job goes green without applying the
@@ -81,7 +83,6 @@ jobs:
8183
echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2
8284
exit 1
8385
fi
84-
bun run ./scripts/apply-dev-workspace-file-size-cutover.ts
8586
else
8687
echo "Applying versioned migrations (db:migrate)"
8788
bun run ./scripts/migrate.ts

.github/workflows/test-build.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,17 @@ jobs:
8585
working-directory: packages/db
8686
run: bun run db:migrate
8787

88+
- name: Verify retired-column contract migration in PostgreSQL
89+
working-directory: packages/db
90+
env:
91+
RETIRED_COLUMNS_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
92+
run: bunx vitest run scripts/retired-columns.postgres.test.ts
93+
8894
- name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL
8995
working-directory: apps/sim
96+
# These suites share a schema and install triggers; parallel files can deadlock DDL against cleanup.
9097
run: >-
91-
bunx vitest run
98+
bunx vitest run --no-file-parallelism
9299
lib/auth/oauth-token-family.postgres.test.ts
93100
lib/auth/oauth-provider-lifecycle.postgres.test.ts
94101
app/api/auth/oauth2/token/route.postgres.test.ts

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

Lines changed: 267 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ const SCOPE = 'browser-tools-e2e'
1818
const FORM = `<!doctype html><html><head><title>Form fixture</title></head><body>
1919
<label>Name <input id="name" autocomplete="off"></label>
2020
<label>Plan <select id="plan" aria-label="Plan"><option value="basic">Basic</option><option value="pro">Pro</option></select></label>
21+
<label>Regions <select id="regions" aria-label="Regions" multiple><option value="a">A</option><option value="b">B</option><option value="c" disabled>C</option></select></label>
2122
<label>Updates <input id="updates" type="checkbox"></label>
23+
<label>Date <input id="date" type="date" oninput="this.dataset.events = Number(this.dataset.events || 0) + 1"></label>
24+
<label>Time <input id="time" type="time"></label>
25+
<label>Appointment <input id="appointment" type="datetime-local"></label>
26+
<label>Month <input id="month" type="month"></label>
27+
<label>Week <input id="week" type="week"></label>
28+
<label>Color <input id="color" type="color"></label>
29+
<label>Range <input id="range" type="range"></label>
2230
<label>Password <input id="password" type="password"></label>
2331
<label>Route <input id="route" oninput="history.pushState({}, '', '/form?changed=1')"></label>
2432
<a href="/redirect">Other website</a>
@@ -27,6 +35,23 @@ const FORM = `<!doctype html><html><head><title>Form fixture</title></head><body
2735
</div>
2836
</body></html>`
2937

38+
const CLICK_FIXTURE = `<!doctype html><title>Click fixture</title>
39+
<style>body{margin:0;height:2400px}button{position:absolute;left:100px;top:calc(100vh - 100px);width:200px;height:40px}</style>
40+
<button id="target" role="option" onclick="document.body.dataset.clicks = Number(document.body.dataset.clicks) + 1">Choose option</button>
41+
<script>
42+
document.body.dataset.clicks = '0'; document.body.dataset.scrolls = '0';
43+
const mode = new URLSearchParams(location.search).get('mode');
44+
if (mode === 'sticky') {
45+
document.getElementById('target').style.top = '1010px';
46+
document.body.insertAdjacentHTML('beforeend', '<div style="position:fixed;inset:0 0 auto;height:80px;background:white;z-index:2">Sticky header</div>');
47+
scrollTo(0,1000);
48+
}
49+
addEventListener('scroll', () => {
50+
document.body.dataset.scrolls = Number(document.body.dataset.scrolls) + 1;
51+
if (mode === 'menu' && document.body.dataset.armed === 'true') document.getElementById('target')?.remove();
52+
});
53+
</script>`
54+
3055
test.describe('browser tools', () => {
3156
const calls = new Map<
3257
string,
@@ -56,9 +81,11 @@ test.describe('browser tools', () => {
5681
}
5782
response.writeHead(200, { 'Content-Type': 'text/html' })
5883
response.end(
59-
path === '/form'
60-
? FORM
61-
: '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
84+
path === '/click'
85+
? CLICK_FIXTURE
86+
: path === '/form'
87+
? FORM
88+
: '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
6289
)
6390
})
6491
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
@@ -78,15 +105,21 @@ test.describe('browser tools', () => {
78105
},
79106
})
80107
window = await app.firstWindow()
108+
await app.evaluate(({ BrowserWindow }) =>
109+
BrowserWindow.getAllWindows()[0].webContents.setBackgroundThrottling(false)
110+
)
81111
await expect(window.getByRole('heading')).toHaveText('Browser tools fixture')
82112
await window.evaluate(async (scope) => {
83113
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
84114
await api.browserAgent.activateScope(scope)
85-
api.browserAgent.setPanelBounds(
86-
{ x: 0, y: 80, width: innerWidth, height: innerHeight - 80 },
87-
null,
88-
scope
89-
)
115+
const updateBounds = () =>
116+
api.browserAgent.setPanelBounds(
117+
{ x: 0, y: 80, width: innerWidth, height: innerHeight - 80 },
118+
null,
119+
scope
120+
)
121+
updateBounds()
122+
setInterval(updateBounds, 200)
90123
}, SCOPE)
91124
})
92125

@@ -119,7 +152,9 @@ test.describe('browser tools', () => {
119152
const result = response.result as { snapshot: { outline: string } }
120153
expect(result.snapshot.outline).toContain('Name')
121154
return (name: string) => {
122-
const line = result.snapshot.outline.split('\n').find((line) => line.includes(`"${name}"`))
155+
const line = result.snapshot.outline
156+
.split('\n')
157+
.find((line) => line.includes(`"${name}"`) && /\[ref=\d+\]/.test(line))
123158
const match = line?.match(/\[ref=(\d+)\]/)
124159
if (!match) throw new Error(`No reference for ${name}: ${result.snapshot.outline}`)
125160
return Number(match[1])
@@ -143,6 +178,229 @@ test.describe('browser tools', () => {
143178
}, origin)
144179
}
145180

181+
test('sets and clears multiple selections without partial writes for invalid options', async () => {
182+
const ref = await openForm()
183+
const selected = await execute('browser_select_option', {
184+
elementId: ref('Regions'),
185+
values: ['A', 'B'],
186+
})
187+
expect(selected.ok, selected.error).toBe(true)
188+
expect(selected.result).toMatchObject({
189+
values: ['a', 'b'],
190+
effectObserved: true,
191+
readback: { values: ['a', 'b'] },
192+
})
193+
const invalid = await execute('browser_select_option', {
194+
elementId: ref('Regions'),
195+
values: ['B', 'C'],
196+
})
197+
expect(invalid.ok).toBe(false)
198+
const values = await app.evaluate(async ({ webContents }, origin) => {
199+
const contents = webContents
200+
.getAllWebContents()
201+
.find((wc) => wc.getURL() === `${origin}/form`)
202+
if (!contents) throw new Error('Missing form fixture')
203+
return contents.executeJavaScript(
204+
'Array.from(document.getElementById("regions").selectedOptions, option => option.value)'
205+
)
206+
}, origin)
207+
expect(values).toEqual(['a', 'b'])
208+
const cleared = await execute('browser_select_option', {
209+
elementId: ref('Regions'),
210+
values: [],
211+
})
212+
expect(cleared.result).toMatchObject({
213+
values: [],
214+
effectObserved: true,
215+
readback: { values: [] },
216+
})
217+
})
218+
219+
test('fills structured native fields and leaves invalid dates unchanged', async () => {
220+
const ref = await openForm()
221+
for (const [name, text] of [
222+
['Date', '2026-09-15'],
223+
['Time', '15:48'],
224+
['Appointment', '2026-09-15T15:48:00'],
225+
['Month', '2026-09'],
226+
['Week', '2026-W38'],
227+
['Color', '#AABBCC'],
228+
['Range', '75'],
229+
]) {
230+
const response = await execute('browser_type', { elementId: ref(name), text })
231+
expect(response.ok, response.error).toBe(true)
232+
expect(response.result).toMatchObject({
233+
trusted: false,
234+
dispatched: true,
235+
effectObserved: true,
236+
})
237+
}
238+
const invalid = await execute('browser_type', { elementId: ref('Date'), text: '2026-02-30' })
239+
expect(invalid.ok).toBe(false)
240+
expect(invalid.error).toContain('Invalid value')
241+
const state = await app.evaluate(async ({ webContents }, origin) => {
242+
const contents = webContents
243+
.getAllWebContents()
244+
.find((wc) => wc.getURL() === `${origin}/form`)
245+
if (!contents) throw new Error('Missing form fixture')
246+
return contents.executeJavaScript(
247+
'({date:document.getElementById("date").value,time:document.getElementById("time").value,appointment:document.getElementById("appointment").value,month:document.getElementById("month").value,week:document.getElementById("week").value,color:document.getElementById("color").value,range:document.getElementById("range").value,events:document.getElementById("date").dataset.events})'
248+
)
249+
}, origin)
250+
expect(state).toEqual({
251+
date: '2026-09-15',
252+
time: '15:48',
253+
appointment: '2026-09-15T15:48',
254+
month: '2026-09',
255+
week: '2026-W38',
256+
color: '#aabbcc',
257+
range: '75',
258+
events: '1',
259+
})
260+
})
261+
262+
for (const mode of ['menu', 'sticky']) {
263+
test(`clicks a ${mode} target without losing its identity`, async () => {
264+
const opened = await execute('browser_open_url', { url: `${origin}/click?mode=${mode}` })
265+
expect(opened.ok, opened.error).toBe(true)
266+
await app.evaluate(
267+
async ({ webContents }, { origin, mode }) => {
268+
const contents = webContents
269+
.getAllWebContents()
270+
.find((wc) => wc.getURL().startsWith(`${origin}/click`))
271+
if (!contents) throw new Error('Missing click fixture')
272+
await contents.executeJavaScript(`
273+
history.scrollRestoration = 'manual';
274+
document.getElementById('target').style.top = ${mode === 'sticky' ? '1010' : 'innerHeight - 100'} + 'px';
275+
scrollTo(0, ${mode === 'sticky' ? '1000' : '0'});
276+
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
277+
document.body.dataset.scrolls = '0'; document.body.dataset.armed = 'true'; resolve();
278+
})))
279+
`)
280+
},
281+
{ origin, mode }
282+
)
283+
const snapshot = await execute('browser_snapshot', {})
284+
expect(snapshot.ok, snapshot.error).toBe(true)
285+
const outline = (snapshot.result as { outline: string }).outline
286+
const line = outline.split('\n').find((line) => line.includes('"Choose option"'))
287+
const match = line?.match(/\[ref=(\d+)\]/)
288+
if (!match) throw new Error(`Missing target: ${outline}`)
289+
const result = await execute('browser_click', { elementId: Number(match[1]) })
290+
expect(result.ok, result.error).toBe(true)
291+
const state = await app.evaluate(async ({ webContents }, origin) => {
292+
const contents = webContents
293+
.getAllWebContents()
294+
.find((wc) => wc.getURL().startsWith(`${origin}/click`))
295+
if (!contents) throw new Error('Missing click fixture')
296+
return contents.executeJavaScript(
297+
'({clicks:document.body.dataset.clicks,scrolls:document.body.dataset.scrolls,scrollY})'
298+
)
299+
}, origin)
300+
expect(state.clicks).toBe('1')
301+
if (mode === 'menu') expect(state).toMatchObject({ scrolls: '0', scrollY: 0 })
302+
else expect(state.scrollY).toBeLessThan(1000)
303+
})
304+
}
305+
306+
for (const mode of ['visible', 'hidden', 'minimized']) {
307+
test(`captures a ${mode} window without changing its state`, async () => {
308+
test.skip(
309+
mode === 'minimized' && process.platform !== 'darwin',
310+
'Requires a window manager with minimize events'
311+
)
312+
await openForm()
313+
await app.evaluate(async ({ BrowserWindow }, mode) => {
314+
const win = BrowserWindow.getAllWindows()[0]
315+
win.blur()
316+
if (mode === 'hidden') win.hide()
317+
if (mode === 'minimized') {
318+
const minimized = new Promise<void>((resolve) => win.once('minimize', () => resolve()))
319+
win.minimize()
320+
await minimized
321+
}
322+
}, mode)
323+
const state = () =>
324+
app.evaluate(async ({ BrowserWindow, webContents }, origin) => {
325+
const win = BrowserWindow.getAllWindows()[0]
326+
const contents = webContents
327+
.getAllWebContents()
328+
.find((wc) => wc.getURL() === `${origin}/form`)
329+
if (!contents) throw new Error('Missing screenshot fixture')
330+
return {
331+
visible: win.isVisible(),
332+
minimized: win.isMinimized(),
333+
bounds: win.getBounds(),
334+
focused: BrowserWindow.getFocusedWindow()?.id ?? null,
335+
page: await contents.executeJavaScript(
336+
'({width:innerWidth,height:innerHeight,scrollX,scrollY,html:document.body.innerHTML,focus:document.activeElement?.id})'
337+
),
338+
}
339+
}, origin)
340+
const before = await state()
341+
for (let i = 0; i < 3; i++) {
342+
const response = await execute('browser_screenshot', {})
343+
expect(response.ok, response.error).toBe(true)
344+
const shot = response.result as {
345+
dataUrl: string
346+
scale: number
347+
viewport: { width: number; height: number }
348+
}
349+
expect(shot.dataUrl.length).toBeGreaterThan(1000)
350+
expect(shot.viewport.width).toBeGreaterThan(0)
351+
expect(shot.viewport.height).toBeGreaterThan(0)
352+
const image = await app.evaluate(({ nativeImage }, dataUrl) => {
353+
const image = nativeImage.createFromDataURL(dataUrl)
354+
return { empty: image.isEmpty(), ...image.getSize() }
355+
}, shot.dataUrl)
356+
expect(image).toEqual({
357+
empty: false,
358+
width: Math.round(shot.viewport.width * shot.scale),
359+
height: Math.round(shot.viewport.height * shot.scale),
360+
})
361+
expect(await state()).toEqual(before)
362+
}
363+
})
364+
}
365+
366+
test('captures fresh pixels after resizing and repainting the viewport', async () => {
367+
await openForm()
368+
const viewportWidth = () =>
369+
app.evaluate(async ({ webContents }, origin) => {
370+
const contents = webContents
371+
.getAllWebContents()
372+
.find((wc) => wc.getURL() === `${origin}/form`)
373+
return contents?.executeJavaScript('innerWidth')
374+
}, origin)
375+
const beforeWidth = await viewportWidth()
376+
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1280, 900))
377+
await expect.poll(viewportWidth).not.toBe(beforeWidth)
378+
for (const color of ['red', 'blue']) {
379+
await app.evaluate(
380+
async ({ webContents }, { origin, color }) => {
381+
const contents = webContents
382+
.getAllWebContents()
383+
.find((wc) => wc.getURL() === `${origin}/form`)
384+
if (!contents) throw new Error('Missing screenshot fixture')
385+
await contents.executeJavaScript(
386+
`document.body.style.background = ${JSON.stringify(color)}; void 0`
387+
)
388+
},
389+
{ origin, color }
390+
)
391+
const response = await execute('browser_screenshot', {})
392+
expect(response.ok, response.error).toBe(true)
393+
const shot = response.result as { dataUrl: string }
394+
const pixel = await app.evaluate(({ nativeImage }, dataUrl) => {
395+
const image = nativeImage.createFromDataURL(dataUrl)
396+
return Array.from(image.toBitmap().subarray(0, 4))
397+
}, shot.dataUrl)
398+
const dominant = pixel[color === 'red' ? 2 : 0]
399+
const other = pixel[color === 'red' ? 0 : 2]
400+
expect(dominant - other, `${color}: ${pixel}`).toBeGreaterThan(150)
401+
}
402+
})
403+
146404
test('opens with references, fills in order, and scrolls a horizontal pane', async () => {
147405
const ref = await openForm()
148406
const fill = await execute('browser_fill_form', {

0 commit comments

Comments
 (0)