Skip to content

Commit 2959e37

Browse files
committed
improvement(ci): advance extension access polling in acceptance tests
1 parent af2ef93 commit 2959e37

1 file changed

Lines changed: 150 additions & 114 deletions

File tree

‎apps/sim/scripts/test-browser-extension-e2e.ts‎

Lines changed: 150 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -269,70 +269,76 @@ try {
269269
// Only the synthetic page is pregranted; native permission prompts are a separate acceptance check.
270270
manifest.host_permissions.push(`${fixtureOrigin}/*`)
271271
await writeFile(resolve(extensionPath, 'manifest.json'), JSON.stringify(manifest))
272-
context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), {
273-
channel: 'chromium',
274-
headless: process.env.EXTENSION_APP_E2E_HEADED !== '1',
275-
viewport: { width: 390, height: 844 },
276-
args: [
277-
`--disable-extensions-except=${extensionPath}`,
278-
`--load-extension=${extensionPath}`,
279-
'--use-fake-device-for-media-stream',
280-
],
281-
})
282-
const browserContext = context
272+
const launchBrowserContext = () =>
273+
chromium.launchPersistentContext(resolve(temporary, 'profile'), {
274+
channel: 'chromium',
275+
headless: process.env.EXTENSION_APP_E2E_HEADED !== '1',
276+
viewport: { width: 390, height: 844 },
277+
args: [
278+
`--disable-extensions-except=${extensionPath}`,
279+
`--load-extension=${extensionPath}`,
280+
'--use-fake-device-for-media-stream',
281+
],
282+
})
283+
context = await launchBrowserContext()
284+
let browserContext = context
283285
context.setDefaultTimeout(30_000)
284286
context.setDefaultNavigationTimeout(120_000)
285-
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker'))
287+
let worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker'))
286288
const extensionId = worker.url().split('/')[2]
287-
panel = await context.newPage()
288-
const shell = panel
289-
await shell.route('**/api/settings/voice', (route) =>
290-
route.fulfill({ json: { sttAvailable: true } })
291-
)
292-
panel.on('pageerror', (error) => browserErrors.push(error.message))
293-
panel.on('request', (request) => pendingRequests.add(request))
294-
panel.on('requestfinished', (request) => pendingRequests.delete(request))
295-
panel.on('requestfailed', (request) => {
296-
pendingRequests.delete(request)
297-
failedRequests.push({
298-
path: new URL(request.url()).pathname,
299-
error: request.failure()?.errorText ?? null,
289+
const openPanel = async () => {
290+
const page = await browserContext.newPage()
291+
await page.route('**/api/settings/voice', (route) =>
292+
route.fulfill({ json: { sttAvailable: true } })
293+
)
294+
page.on('pageerror', (error) => browserErrors.push(error.message))
295+
page.on('request', (request) => pendingRequests.add(request))
296+
page.on('requestfinished', (request) => pendingRequests.delete(request))
297+
page.on('requestfailed', (request) => {
298+
pendingRequests.delete(request)
299+
failedRequests.push({
300+
path: new URL(request.url()).pathname,
301+
error: request.failure()?.errorText ?? null,
302+
})
300303
})
301-
})
302-
panel.on('response', (response) => {
303-
const url = new URL(response.url())
304-
if (url.origin === base.origin && url.pathname === '/api/mothership/chat') {
305-
const entry: (typeof chatResponses)[number] = {
306-
status: response.status(),
307-
chatId: response.headers()[MOTHERSHIP_CHAT_ID_HEADER.toLowerCase()] ?? null,
308-
events: [],
304+
page.on('response', (response) => {
305+
const url = new URL(response.url())
306+
if (url.origin === base.origin && url.pathname === '/api/mothership/chat') {
307+
const entry: (typeof chatResponses)[number] = {
308+
status: response.status(),
309+
chatId: response.headers()[MOTHERSHIP_CHAT_ID_HEADER.toLowerCase()] ?? null,
310+
events: [],
311+
}
312+
chatResponses.push(entry)
313+
responseDiagnostics.push(
314+
response
315+
.text()
316+
.then((body) => {
317+
for (const line of body.split('\n')) {
318+
if (!line.startsWith('data: ')) continue
319+
const event = toRecord(JSON.parse(line.slice(6)))
320+
entry.events.push({
321+
type: toStringOrNull(event.type),
322+
seq: toNumberOrNull(event.seq),
323+
})
324+
}
325+
})
326+
.catch((error) => {
327+
entry.error = getErrorMessage(error)
328+
})
329+
)
309330
}
310-
chatResponses.push(entry)
311-
responseDiagnostics.push(
312-
response
313-
.text()
314-
.then((body) => {
315-
for (const line of body.split('\n')) {
316-
if (!line.startsWith('data: ')) continue
317-
const event = toRecord(JSON.parse(line.slice(6)))
318-
entry.events.push({
319-
type: toStringOrNull(event.type),
320-
seq: toNumberOrNull(event.seq),
321-
})
322-
}
323-
})
324-
.catch((error) => {
325-
entry.error = getErrorMessage(error)
326-
})
327-
)
328-
}
329-
if (url.origin === base.origin && response.status() >= 400)
330-
httpErrors.push({
331-
path: url.pathname,
332-
status: response.status(),
333-
injected: injectedRequests.has(response.request()),
334-
})
335-
})
331+
if (url.origin === base.origin && response.status() >= 400)
332+
httpErrors.push({
333+
path: url.pathname,
334+
status: response.status(),
335+
injected: injectedRequests.has(response.request()),
336+
})
337+
})
338+
return page
339+
}
340+
panel = await openPanel()
341+
let shell = panel
336342
await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`)
337343
const frame = () => {
338344
const app = shell.frames().find((candidate) => candidate.url().startsWith(base.origin))
@@ -390,7 +396,7 @@ try {
390396
timeout: 120_000,
391397
})
392398
})
393-
const source = await context.newPage()
399+
let source = await context.newPage()
394400
await source.goto(`${fixtureOrigin}/page?private=QUERY_SECRET#FRAGMENT_SECRET`)
395401
const observeCaptureReceipts = () =>
396402
shell.evaluate((origin) => {
@@ -1143,6 +1149,81 @@ try {
11431149
await expect(attachments()).toHaveCount(0)
11441150
await screenshot('shared-workspace-organization-navigation')
11451151
})
1152+
await check(
1153+
'A first-open toolbar capture survives authenticated organization landing',
1154+
async () => {
1155+
await source.bringToFront()
1156+
await shell.goto('about:blank')
1157+
const intent = await worker.evaluate(async () => {
1158+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
1159+
if (tab.id === undefined || !tab.url) throw new Error('Missing source tab')
1160+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(tab.url))
1161+
const [result] = await chrome.scripting.executeScript({
1162+
target: { tabId: tab.id, frameIds: [0] },
1163+
func: () => null,
1164+
})
1165+
return {
1166+
tabId: tab.id,
1167+
windowId: tab.windowId,
1168+
kind: 'page',
1169+
createdAt: Date.now(),
1170+
documentId: result.documentId,
1171+
urlDigest: Array.from(new Uint8Array(digest), (byte) =>
1172+
byte.toString(16).padStart(2, '0')
1173+
).join(''),
1174+
}
1175+
})
1176+
await worker.evaluate(async (pending) => {
1177+
await chrome.storage.session.set({
1178+
[`captureIntent:${pending.windowId}:first-open`]: pending,
1179+
})
1180+
}, intent)
1181+
await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`)
1182+
await observeCaptureReceipts()
1183+
await expect
1184+
.poll(() => new URL(frame().url()).pathname)
1185+
.toBe(`/extension/o/${organizationId}`)
1186+
await expect(attachments()).toHaveCount(1)
1187+
await screenshot('organization-first-open-capture')
1188+
}
1189+
)
1190+
if (liveChat) {
1191+
await check(
1192+
'Organization live admission preserves queued pages, files, and the admitted conversation mode',
1193+
() =>
1194+
verifyLiveChat({
1195+
name: 'organization',
1196+
url: `${base.origin}/extension/o/${organizationId}`,
1197+
inputLabel: 'Ask Sim',
1198+
draftKey: `extension:${userId}:organization:${organizationId}:home`,
1199+
organizationId,
1200+
})
1201+
)
1202+
}
1203+
if (!liveChat) {
1204+
// Install timers before app code in a fresh context; live provider checks keep native timing.
1205+
await browserContext.close()
1206+
context = await launchBrowserContext()
1207+
browserContext = context
1208+
browserContext.setDefaultTimeout(30_000)
1209+
browserContext.setDefaultNavigationTimeout(120_000)
1210+
worker =
1211+
browserContext.serviceWorkers()[0] ?? (await browserContext.waitForEvent('serviceworker'))
1212+
assert.equal(worker.url().split('/')[2], extensionId)
1213+
panel = await openPanel()
1214+
shell = panel
1215+
await shell.clock.install()
1216+
source = await browserContext.newPage()
1217+
await source.goto(`${fixtureOrigin}/page?private=QUERY_SECRET#FRAGMENT_SECRET`)
1218+
await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`)
1219+
await observeCaptureReceipts()
1220+
}
1221+
const advanceAccessRevalidation = async () => {
1222+
if (liveChat) return
1223+
await shell.clock.fastForward(ACCESS_REVALIDATION_TIMEOUT_MS)
1224+
// Capture timestamps come from Chromium's isolated world, outside Playwright's clock.
1225+
await shell.clock.setSystemTime(Date.now())
1226+
}
11461227
for (const allowRequests of [false, true]) {
11471228
await check(
11481229
`Workspace Chat policy revocation and regrant work with access requests ${allowRequests ? 'enabled' : 'disabled'}`,
@@ -1173,11 +1254,13 @@ try {
11731254
)
11741255
assert.equal(policy.status(), 200)
11751256
assert.equal(toRecord(toRecord(await policy.json()).config).hideCopilot, true)
1257+
await advanceAccessRevalidation()
11761258
await expect(input).toHaveCount(0, { timeout: ACCESS_REVALIDATION_TIMEOUT_MS })
11771259
await expect(attachments()).toHaveCount(0)
11781260
await expect(frame().getByText('Access required', { exact: true })).toBeVisible()
11791261
await screenshot(`workspace-chat-revoked-requests-${allowRequests}`)
11801262
await sql`delete from permission_group where id=${permissionGroupId}`
1263+
await advanceAccessRevalidation()
11811264
await expect(input).toBeVisible({ timeout: ACCESS_REVALIDATION_TIMEOUT_MS })
11821265
await expect(input).toHaveValue(
11831266
'Keep text, but discard the page when Chat access is revoked.'
@@ -1205,57 +1288,6 @@ try {
12051288
}
12061289
)
12071290
}
1208-
await check(
1209-
'A first-open toolbar capture survives authenticated organization landing',
1210-
async () => {
1211-
await source.bringToFront()
1212-
await shell.goto('about:blank')
1213-
const intent = await worker.evaluate(async () => {
1214-
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
1215-
if (tab.id === undefined || !tab.url) throw new Error('Missing source tab')
1216-
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(tab.url))
1217-
const [result] = await chrome.scripting.executeScript({
1218-
target: { tabId: tab.id, frameIds: [0] },
1219-
func: () => null,
1220-
})
1221-
return {
1222-
tabId: tab.id,
1223-
windowId: tab.windowId,
1224-
kind: 'page',
1225-
createdAt: Date.now(),
1226-
documentId: result.documentId,
1227-
urlDigest: Array.from(new Uint8Array(digest), (byte) =>
1228-
byte.toString(16).padStart(2, '0')
1229-
).join(''),
1230-
}
1231-
})
1232-
await worker.evaluate(async (pending) => {
1233-
await chrome.storage.session.set({
1234-
[`captureIntent:${pending.windowId}:first-open`]: pending,
1235-
})
1236-
}, intent)
1237-
await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`)
1238-
await observeCaptureReceipts()
1239-
await expect
1240-
.poll(() => new URL(frame().url()).pathname)
1241-
.toBe(`/extension/o/${organizationId}`)
1242-
await expect(attachments()).toHaveCount(1)
1243-
await screenshot('organization-first-open-capture')
1244-
}
1245-
)
1246-
if (liveChat) {
1247-
await check(
1248-
'Organization live admission preserves queued pages, files, and the admitted conversation mode',
1249-
() =>
1250-
verifyLiveChat({
1251-
name: 'organization',
1252-
url: `${base.origin}/extension/o/${organizationId}`,
1253-
inputLabel: 'Ask Sim',
1254-
draftKey: `extension:${userId}:organization:${organizationId}:home`,
1255-
organizationId,
1256-
})
1257-
)
1258-
}
12591291
await check(
12601292
'Organization permission changes switch a new chat to Assistant without losing its draft',
12611293
async () => {
@@ -1271,6 +1303,7 @@ try {
12711303
await sql`insert into permission_group (id, organization_id, name, config, created_by, is_default)
12721304
values (${generateId()}, ${organizationId}, 'Search only',
12731305
${sql.json({ disableWorkspaceCreation: true })}, ${userId}, true)`
1306+
await advanceAccessRevalidation()
12741307
await expect(frame().getByRole('button', { name: 'Attach image', exact: true })).toBeVisible({
12751308
timeout: ACCESS_REVALIDATION_TIMEOUT_MS,
12761309
})
@@ -1348,7 +1381,9 @@ try {
13481381
[401, 403, 404].includes(response.status()),
13491382
{ timeout: ACCESS_REVALIDATION_TIMEOUT_MS }
13501383
),
1351-
sql`delete from member where organization_id=${organizationId} and user_id=${userId}`,
1384+
sql`delete from member where organization_id=${organizationId} and user_id=${userId}`.then(
1385+
advanceAccessRevalidation
1386+
),
13521387
])
13531388
await expect(frame().getByText('This organization is unavailable.')).toBeVisible()
13541389
await expect(frame().getByRole('textbox', { name: 'Ask Sim', exact: true })).toHaveCount(0)
@@ -1373,7 +1408,9 @@ try {
13731408
response.status() === 403,
13741409
{ timeout: ACCESS_REVALIDATION_TIMEOUT_MS }
13751410
),
1376-
sql`delete from permissions where user_id=${userId} and entity_type='workspace' and entity_id=${alphaId}`,
1411+
sql`delete from permissions where user_id=${userId} and entity_type='workspace' and entity_id=${alphaId}`.then(
1412+
advanceAccessRevalidation
1413+
),
13771414
])
13781415
await expect(frame().getByText('This workspace is unavailable.')).toBeVisible()
13791416
await expect(frame().getByRole('textbox', { name: 'Message', exact: true })).toHaveCount(0)
@@ -1386,7 +1423,6 @@ try {
13861423
await expect(frame().getByRole('textbox', { name: 'Message', exact: true })).toBeVisible()
13871424
}
13881425
)
1389-
// Clock APIs replace animation timing, so native streaming checks must run first.
13901426
const reconnect = async () => {
13911427
await frame().evaluate(() => {
13921428
window.dispatchEvent(new Event('offline'))

0 commit comments

Comments
 (0)