diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 3d949981a69..cb63a401c40 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -5,11 +5,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { createElement } from 'react' import { type BrowserContext, chromium, expect, type Frame, type Page } from '@playwright/test' import { pageSnapshotSchema } from '@sim/browser-extension-protocol' +import { SecretReveal } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { renderToStaticMarkup } from 'react-dom/server' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const logger = createLogger('BrowserExtensionE2E') @@ -18,6 +21,9 @@ if (!reportPath) throw new Error('Set EXTENSION_REPORT_PATH to the output JSON r const output = resolve(root, reportPath) const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] +const secretReveal = renderToStaticMarkup( + createElement(SecretReveal, { value: 'SECRET_REVEAL_SENTINEL' }) +) const fixture = Bun.serve({ hostname: '127.0.0.1', port: 0, @@ -55,6 +61,7 @@ const fixture = Bun.serve({
EDITABLE_SENTINEL
CSS_HIDDEN_SENTINEL
+

Credential instructions

${secretReveal}visible-code-control
`, { headers: { 'content-type': 'text/html' } } @@ -129,12 +136,17 @@ try { await signIn.close() const panel: Page = await context.newPage() // Observe dispatch before the bridge so background metadata queries stay outside each action. - await panel.addInitScript(() => { + const observeDispatch = () => { window.addEventListener('message', () => { document.documentElement.dataset.queryCountAtDispatch = document.documentElement.dataset.browserQueryCount ?? '0' }) - }) + } + const panelBundle = resolve(extensionPath, 'sidepanel.js') + await writeFile( + panelBundle, + `(${observeDispatch.toString()})();\n${await readFile(panelBundle, 'utf8')}` + ) await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`) await panel.getByTitle('Sim chat').waitFor() const frame: Frame = panel.frames()[1] ?? (await panel.waitForEvent('frameattached')) @@ -231,9 +243,11 @@ try { 'A typed sim/ link opens the Sim link resolver on the configured origin', async () => { const tab = await page.context().newPage() - for (const path of ['handbook', 'jira/123?view=board']) { - await tab.goto(`http://sim/${path}`) - assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`) + for (const scheme of ['http', 'https']) { + for (const path of ['handbook', 'jira/123?view=board', 'jira/a%2Fb?view=board%20view']) { + await tab.goto(`${scheme}://sim/${path}`) + assert(tab.url() === `${origin}/sim/${path}`, `sim/${path} reached ${tab.url()}`) + } } await tab.close() } @@ -310,6 +324,7 @@ try { await check('Page extraction excludes secrets and strips URL query and fragment', async () => { const [snapshot] = await snapshots() assert(snapshot.text.includes('visible-reference-482'), 'Visible page content missing') + assert(snapshot.text.includes('visible-code-control'), 'Ordinary code was excluded') for (const sentinel of [ 'PASSWORD_SENTINEL', 'TEXTAREA_SENTINEL', @@ -318,6 +333,7 @@ try { 'CSS_HIDDEN_SENTINEL', 'ARIA_SENTINEL', 'SCRIPT_SENTINEL', + 'SECRET_REVEAL_SENTINEL', ]) { assert(!snapshot.text.includes(sentinel), `Captured ${sentinel}`) } @@ -643,8 +659,17 @@ try { expression: `parent.postMessage(${JSON.stringify(action)}, ${JSON.stringify(`chrome-extension://${extensionId}`)})`, userGesture: false, }) - await expect(panel.locator('body')).toHaveAttribute('data-observed-actions', '1') - await expect(panel.locator('body')).toHaveAttribute('data-action-queries', '0') + await expect + .poll(async () => { + const { result } = await parentCdp.send('Runtime.evaluate', { + expression: + '[document.body.dataset.observedActions, document.body.dataset.actionQueries]', + returnByValue: true, + userGesture: false, + }) + return result.value + }) + .toEqual(['1', '0']) assert((await snapshots()).length === before, 'Captured without a user gesture') await app.evaluate(({ action, targetOrigin }) => parent.postMessage(action, targetOrigin), { action: { ...action, sessionId: generateId() }, @@ -668,6 +693,87 @@ try { await parentCdp.detach() } ) + await check('Ordinary Sim and encoded remote pages attach without sensitive fields', async () => { + for (const url of [ + `${origin}/workspace/example`, + `${origin}/extension-notes`, + `${origin}/workspace/%E2%9C%93`, + `${pageOrigin}/ticket/100%done`, + ]) { + const before = (await snapshots()).length + await page.goto(`${url}?token=URL_SECRET#FRAGMENT_SECRET`) + await page.bringToFront() + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(before + 1) + const snapshot = (await snapshots()).at(-1) + assert(snapshot?.url === url, 'Page URL retained private parameters') + assert(snapshot.text.includes('visible-reference-482'), 'Visible page content is missing') + assert(!snapshot.text.includes('SENTINEL'), 'Sensitive page content was captured') + } + }) + await check('Embedded chat and malformed Sim paths fail closed', async () => { + for (const path of [ + '/extension', + '/extension/workspace', + '/extension/o/organization', + '/%65xtension', + '/%65xtension/workspace', + '/extension%2fworkspace', + '/extension%ZZ/workspace', + ]) { + await page.goto(`${origin}/workspace/example`) + await page.bringToFront() + const previous = (await snapshots()).length + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(previous + 1) + await expect(panel.getByRole('status')).toHaveText('') + const before = (await snapshots()).length + await page.goto(`${origin}${path}`) + await page.bringToFront() + await capture() + await panel.getByRole('status').filter({ hasText: 'This page cannot be attached.' }).waitFor() + assert((await snapshots()).length === before, 'An excluded Sim path was captured') + } + }) + await check( + 'Revealed credentials stay excluded from exact and surrounding selections', + async () => { + await page.goto(`${origin}/workspace/example`) + await page.bringToFront() + const before = (await snapshots()).length + await capture() + await expect.poll(async () => (await snapshots()).length).toBe(before + 1) + await expect(panel.getByRole('status')).toHaveText('') + await page + .locator('#secret-reveal code') + .first() + .evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + }) + await capture('Selection') + await expect(panel.getByRole('status')).toContainText('could not be read') + assert((await snapshots()).length === before + 1, 'A revealed credential was attached') + await page.locator('#secret-reveal').evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + }) + await capture('Selection') + await expect.poll(async () => (await snapshots()).length).toBe(before + 2) + const snapshot = (await snapshots()).at(-1) + assert(snapshot, 'Missing surrounding selection snapshot') + assert(snapshot.text.includes('Credential instructions'), 'Public selection text is missing') + assert(snapshot.text.includes('visible-code-control'), 'Ordinary selected code was excluded') + assert( + !snapshot.text.includes('SECRET_REVEAL_SENTINEL'), + 'A surrounding selection leaked the credential' + ) + } + ) await check( 'Site access stays modal without violating the packaged security policy', async () => { diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts index 9a8ff2b651c..dbd5d253f50 100644 --- a/apps/extension/src/capture.ts +++ b/apps/extension/src/capture.ts @@ -21,14 +21,23 @@ export async function captureActivePage( if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) { throw new Error('The page changed before reading. Attach it again.') } + let pathname = url.pathname + if (url.origin === SIM_ORIGIN) { + try { + pathname = decodeURIComponent(pathname) + } catch { + throw new Error('This page cannot be attached.') + } + } if ( !['http:', 'https:'].includes(url.protocol) || - url.origin === SIM_ORIGIN || + (url.origin === SIM_ORIGIN && + (pathname === '/extension' || pathname.startsWith('/extension/'))) || url.username || url.password || tab.incognito ) { - throw new Error('Choose a regular web page outside Sim to attach.') + throw new Error('This page cannot be attached.') } const results = await chrome.scripting.executeScript({ target: expected diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index 1abcb53b72d..b649fa96e4d 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -44,7 +44,7 @@ function richText(content: string): ReactNode { export const PRIVACY_CONFIG: LegalPageConfig = { title: 'Privacy Policy', description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)', - lastUpdated: 'October 31, 2026', + lastUpdated: 'November 2, 2026', intro: [ { kind: 'paragraph', @@ -833,6 +833,12 @@ export const PRIVACY_CONFIG: LegalPageConfig = { 'The hosted chat uses Your normal Sim Account and authenticated session, together with the operational diagnostics and consent controls described in this Privacy Policy. The extension document and its page attachments are excluded from session replay. Captured page content is not used for advertising.' ), }, + { + kind: 'paragraph', + content: richText( + 'When You use the sim address-bar keyword, the extension sends the typed link name, before any path values or query, to Sim with Your authenticated session to suggest Your organization’s links. Suggestions are cached in memory and are not written to extension storage. Opening a sim/ shortcut sends its path and query to Sim to resolve the destination, then opens that destination. Sim records each link’s visit count and last-visited time. These requests also use the operational diagnostics described in this Privacy Policy. The extension does not read Your browser history or unrelated address-bar input.' + ), + }, { kind: 'subheading', text: 'Chrome Web Store Limited Use', diff --git a/apps/sim/app/extension/[workspaceId]/chat.tsx b/apps/sim/app/extension/[workspaceId]/chat.tsx index 8b38a977634..f1f5324a08b 100644 --- a/apps/sim/app/extension/[workspaceId]/chat.tsx +++ b/apps/sim/app/extension/[workspaceId]/chat.tsx @@ -169,12 +169,10 @@ export function ExtensionChat({ workspaceId, workspaceName }: ExtensionChatProps draftScopeKey={`extension:${userId}:${workspaceId}:${selectedChatId ?? 'new'}`} composerAttachments={composer.attachments} composerResourceActions={composer.resourceActions} + composerToolbarActions={composer.toolbarActions} composerModelSelectorOptions={composer.modelSelectorOptions} layout='copilot-view' - className={cn( - 'h-auto min-h-0', - selectedChatId || chat.messages.length > 0 ? 'flex-1' : 'shrink-0' - )} + className={cn('h-auto min-h-0', (selectedChatId || chat.messages.length > 0) && 'flex-1')} /> + connected ? ( + + + requestAction('capture-page')} + aria-label='Attach current page' + /> + + Attach current page + + ) : undefined, + [connected, requestAction] + ) return { attachments, resourceActions, + toolbarActions, modelSelectorOptions: MODEL_SELECTOR_OPTIONS, prepareSubmission, onSubmitted: clearSnapshots, diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx index f9b7faa735c..f99b6155c19 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx @@ -35,6 +35,7 @@ import { organizationSearchUrlKeys, } from '@/app/o/[organizationId]/search/search-params' import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel' +import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area' import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context' import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat' @@ -94,6 +95,7 @@ interface OrganizationConversationProps { header: ReactNode attachments?: ReactNode resourceActions?: ReactNode + toolbarActions?: ReactNode modelSelectorOptions?: ModelSelectorOptions onChatIdResolved: (chatId: string) => void onNavigateToChat: (chatId: string) => void @@ -453,6 +455,7 @@ function OrganizationConversationContent({ compact={Boolean(panelOptions)} attachments={panelOptions?.attachments} resourceActions={panelOptions?.resourceActions} + toolbarActions={panelOptions?.toolbarActions} modelSelectorOptions={panelOptions?.modelSelectorOptions} onPlanChange={ !hasChat && destinationMode === 'agent' && planEnabled @@ -535,7 +538,7 @@ function OrganizationConversationContent({

How can I help?

-
{composer}
+ {composer} ) : ( )} {!imagesOnly && contextPicker('skills', Slash, 'Skills')} + {toolbarActions} {onPlanChange && ( +} + +/** Keeps the complete composer reachable when its contents exceed the available chat height. */ +export function ComposerScrollArea({ + children, + className, + onAnimationEnd, +}: ComposerScrollAreaProps) { + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 81025bd01fb..21a1a1689fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -34,6 +34,7 @@ import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar' import { MessageActions } from '@/app/workspace/[workspaceId]/components/message-actions' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' +import { ComposerScrollArea } from '@/app/workspace/[workspaceId]/home/components/composer-scroll-area' import { assistantMessageHasRenderableContent, getOrchestratorMessageText, @@ -87,6 +88,7 @@ interface MothershipChatProps { composer?: ReactNode composerAttachments?: ReactNode composerResourceActions?: ReactNode + composerToolbarActions?: ReactNode composerModelSelectorOptions?: ModelSelectorOptions messages: ChatMessage[] isSending: boolean @@ -173,7 +175,7 @@ const LAYOUT_STYLES = { userBubble: 'max-w-[85%] md:max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2', assistantRow: 'group/msg', - footer: 'shrink-0 px-3 pb-[max(12px,env(safe-area-inset-bottom))] md:px-[24px] md:pb-[16px]', + footer: 'px-3 pb-[max(12px,env(safe-area-inset-bottom))] md:px-[24px] md:pb-[16px]', footerInner: 'mx-auto max-w-chat', }, 'copilot-view': { @@ -185,7 +187,7 @@ const LAYOUT_STYLES = { attachmentWidth: 'max-w-[85%]', userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2', assistantRow: 'group/msg', - footer: 'shrink-0 px-3 pb-3', + footer: 'px-3 pb-3', footerInner: '', }, } as const @@ -392,6 +394,7 @@ export function MothershipChat({ composer, composerAttachments, composerResourceActions, + composerToolbarActions, composerModelSelectorOptions, messages: messagesProp, isSending, @@ -1027,7 +1030,7 @@ export function MothershipChat({ )} -
@@ -1050,6 +1053,7 @@ export function MothershipChat({ onSubmit={onSubmit} attachments={composerAttachments} resourceActions={composerResourceActions} + toolbarActions={composerToolbarActions} modelSelectorOptions={composerModelSelectorOptions} isSending={isStreamActive} onStopGeneration={onStopGeneration} @@ -1061,7 +1065,7 @@ export function MothershipChat({ /> ))}
- + ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx index e0f6a82e450..aa9a756bb20 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx @@ -170,7 +170,7 @@ export const AttachedFilesList = React.memo(function AttachedFilesList({
void @@ -98,6 +99,7 @@ const UserInputImpl = forwardRef(function UserI { attachments, resourceActions, + toolbarActions, modelSelectorOptions, requestMode = 'agent', onPlanChange, @@ -680,6 +682,7 @@ const UserInputImpl = forwardRef(function UserI Skills + {toolbarActions} {onPlanChange && ( { + await shell.setViewportSize({ width, height }) + const session = await browserContext.newCDPSession(shell) + try { + const { windowId } = await session.send('Browser.getWindowForTarget') + // OOPIF hit testing uses the native content bounds, including below the browser toolbar. + await session.send('Browser.setContentsSize', { windowId, width, height }) + } finally { + await session.detach() + } + } await panel.goto(`chrome-extension://${extensionId}/sidepanel.html`) const frame = () => { const app = shell.frames().find((candidate) => candidate.url().startsWith(base.origin)) @@ -410,20 +421,24 @@ try { }) }, base.origin) await observeCaptureReceipts() - const capture = async (mode: 'page' | 'selection' = 'page') => { + const capture = async (mode: 'page' | 'selection' | 'shortcut' = 'page') => { await source.bringToFront() const before = Number(await shell.locator('body').getAttribute('data-capture-receipts')) - await frame().getByRole('button', { name: 'Add resources', exact: true }).press('Enter') - await frame() - .getByRole('menuitem', { - name: mode === 'page' ? 'Current page' : 'Selected text', - exact: true, - }) - .press('Enter') + if (mode === 'shortcut') { + await frame().getByRole('button', { name: 'Attach current page', exact: true }).press('Enter') + } else { + await frame().getByRole('button', { name: 'Add resources', exact: true }).press('Enter') + await frame() + .getByRole('menuitem', { + name: mode === 'page' ? 'Current page' : 'Selected text', + exact: true, + }) + .press('Enter') + } await expect(shell.locator('body')).toHaveAttribute('data-capture-receipts', String(before + 1)) } const attachments = () => frame().getByRole('group', { name: 'Page attached to next message' }) - const attachFile = async () => { + const attachFile = async (name = 'extension-notes.txt') => { const completion = shell.waitForResponse( (response) => response.request().method() === 'POST' && @@ -433,7 +448,7 @@ try { await frame() .locator('input[type="file"]') .setInputFiles({ - name: 'extension-notes.txt', + name, mimeType: 'text/plain', buffer: Buffer.from('This synthetic file belongs only to extension QA.'), }) @@ -443,9 +458,7 @@ try { const asset = toRecord(toRecord(toRecord(body).data).result) assert.equal(typeof asset.key, 'string') trackUploadedFile(String(asset.key)) - await expect( - frame().getByRole('button', { name: 'Remove extension-notes.txt', exact: true }) - ).toBeEnabled() + await expect(frame().getByRole('button', { name: `Remove ${name}`, exact: true })).toBeEnabled() } await check( 'Capture, preview, removal, and the three-page cap use the real composer', @@ -492,7 +505,7 @@ try { await navigate(`${base.origin}/extension/${alphaId}`) await expect(frame().getByRole('textbox', { name: 'Message', exact: true })).toBeVisible() for (const width of [320, 390]) { - await shell.setViewportSize({ width, height: 844 }) + await setPanelViewport(width, 844) await expect .poll(() => shell.evaluate( @@ -733,7 +746,51 @@ try { async () => { await capture() await attachFile() + await setPanelViewport(320, 844) + await capture() + await capture() + const geometry = await attachments() + .first() + .evaluate((chip) => { + const strip = chip.parentElement?.parentElement + if (!strip) throw new Error('Missing attachment strip') + return { height: strip.clientHeight, contentHeight: strip.scrollHeight } + }) + assert(geometry.contentHeight > 102, 'The fixture did not exercise multiple attachment rows') + assert( + geometry.contentHeight <= geometry.height + 1, + 'Desktop attachments require nested scrolling' + ) await screenshot('mixed-attachments') + const device = await browserContext.newCDPSession(frame()) + try { + await device.send('Emulation.setTouchEmulationEnabled', { + enabled: true, + maxTouchPoints: 1, + }) + await expect + .poll(() => frame().evaluate(() => matchMedia('(pointer: coarse)').matches)) + .toBe(true) + const touchGeometry = await attachments() + .first() + .evaluate((chip) => { + const strip = chip.parentElement?.parentElement + if (!strip) throw new Error('Missing attachment strip') + return { height: strip.clientHeight, contentHeight: strip.scrollHeight } + }) + assert(touchGeometry.height <= 102, 'Touch attachments lost their height limit') + assert( + touchGeometry.contentHeight > touchGeometry.height, + 'Touch overflow fixture did not wrap' + ) + await screenshot('touch-attachments') + } finally { + await device.send('Emulation.setTouchEmulationEnabled', { enabled: false }) + await device.detach() + } + await attachments().last().getByRole('button', { name: 'Remove page context' }).click() + await attachments().last().getByRole('button', { name: 'Remove page context' }).click() + await setPanelViewport(390, 844) await attachments().first().getByRole('button', { name: 'Remove page context' }).click() await expect( frame().getByRole('button', { name: 'Remove extension-notes.txt', exact: true }) @@ -1243,6 +1300,136 @@ try { await screenshot('organization-first-open-capture') } ) + await check('The current-page shortcut attaches in both shared composers', async () => { + for (const path of [`/extension/${alphaId}`, `/extension/o/${organizationId}`]) { + await navigate(`${base.origin}${path}`) + await expect(attachments()).toHaveCount(0) + await capture('shortcut') + await expect(attachments()).toHaveCount(1) + await attachments() + .first() + .getByRole('button', { name: /^Review page context:/ }) + .click() + const preview = frame().getByRole('dialog', { name: 'Page context', exact: true }) + await expect(preview.locator('pre')).toContainText(captureText) + await preview.getByRole('button', { name: 'Close', exact: true }).click() + await frame().getByRole('button', { name: 'Attach current page', exact: true }).hover() + await expect(frame().getByRole('tooltip', { name: 'Attach current page' })).toBeVisible() + await screenshot(path.includes('/o/') ? 'shortcut-organization-agent' : 'shortcut-workspace') + await attachments().first().getByRole('button', { name: 'Remove page context' }).click() + } + }) + await check( + 'Large attachment stacks keep the composer reachable without nested desktop scrolling', + async () => { + const names = Array.from( + { length: 20 }, + (_, index) => `extension-qa-long-attachment-name-${index + 1}.txt` + ) + const endpoint = `${base.origin}/api/mothership/chat` + for (const [name, path, inputLabel, sendLabel] of [ + ['workspace-new', `/extension/${alphaId}`, 'Message', 'Send message'], + ['workspace-history', `/extension/${alphaId}?chat=${historyId}`, 'Message', 'Send message'], + ['organization-new', `/extension/o/${organizationId}`, 'Ask Sim', 'Send'], + ] as const) { + await navigate(`${base.origin}${path}`) + await setPanelViewport(320, 844) + await capture('shortcut') + for (const filename of names) await attachFile(filename) + const geometry = await attachments() + .first() + .evaluate((chip) => { + const strip = chip.parentElement?.parentElement + if (!strip) throw new Error('Missing attachment strip') + return { height: strip.clientHeight, contentHeight: strip.scrollHeight } + }) + assert(geometry.contentHeight > 844, 'The fixture did not exceed the panel height') + assert( + geometry.contentHeight <= geometry.height + 1, + 'Desktop attachments gained nested scrolling' + ) + await shell.bringToFront() + await shell.mouse.move(160, 760) + await expect + .poll(() => + attachments() + .first() + .evaluate((chip) => chip.getBoundingClientRect().top) + ) + .toBeGreaterThanOrEqual(0) + await screenshot(`large-attachments-${name}-top`) + await shell.mouse.wheel(0, 2000) + const send = frame().getByRole('button', { name: sendLabel, exact: true }) + await expect + .poll( + () => + send.evaluate((button) => { + const rect = button.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.bottom <= innerHeight && + button.contains( + document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) + ) + ) + }), + { message: `${name}: scrolling must expose Send inside the panel` } + ) + .toBe(true) + const editor = frame().getByRole('textbox', { name: inputLabel, exact: true }) + assert( + await editor.evaluate((element) => { + const rect = element.getBoundingClientRect() + return ( + rect.top >= 0 && + rect.bottom <= innerHeight && + element.contains( + document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) + ) + ) + }), + `${name}: the editor is clipped` + ) + assert( + await frame() + .getByRole('button', { name: 'New chat', exact: true }) + .evaluate((button) => { + const rect = button.getBoundingClientRect() + return rect.top >= 0 && rect.bottom <= innerHeight + }), + `${name}: scrolling hid the extension header` + ) + await screenshot(`large-attachments-${name}-bottom`) + await browserContext.route(endpoint, (route) => + route.fulfill({ status: 403, json: { error: 'QA attachment capacity admission check' } }) + ) + try { + const request = browserContext.waitForEvent('request', { + predicate: (entry) => entry.url() === endpoint && entry.method() === 'POST', + }) + await editor.fill('Use all attached context.') + await send.click() + const payload = toRecord((await request).postDataJSON()) + assert(Array.isArray(payload.fileAttachments), 'Missing file attachments') + assert.deepEqual( + payload.fileAttachments.map((file) => toRecord(file).filename).sort(), + [...names].sort() + ) + assert( + Array.isArray(payload.contexts) && payload.contexts.length === 1, + 'The attached page was lost' + ) + await expect( + frame().getByText('QA attachment capacity admission check', { exact: false }) + ).toBeVisible() + } finally { + await browserContext.unroute(endpoint) + } + } + await setPanelViewport(390, 844) + await navigate(`${base.origin}/extension/o/${organizationId}`) + } + ) if (liveChat) { await check( 'Organization live admission preserves queued pages, files, and the admitted conversation mode', @@ -1283,6 +1470,9 @@ try { await screenshot('organization-mode-file-recovery') await frame().getByRole('button', { name: 'Remove extension-notes.txt', exact: true }).click() await expect(frame().getByText(/Remove unsupported files/)).not.toBeVisible() + await attachments().first().getByRole('button', { name: 'Remove page context' }).click() + await capture('shortcut') + await expect(attachments()).toHaveCount(1) await screenshot('organization-assistant-page') if (!liveChat) await shell.route('**/api/mothership/chat', (route) => diff --git a/packages/emcn/src/components/secret-reveal/secret-reveal.tsx b/packages/emcn/src/components/secret-reveal/secret-reveal.tsx index 1a87172b74d..3be9a4d6145 100644 --- a/packages/emcn/src/components/secret-reveal/secret-reveal.tsx +++ b/packages/emcn/src/components/secret-reveal/secret-reveal.tsx @@ -44,6 +44,7 @@ export function SecretReveal({ value, className, redacted = false }: SecretRevea return (