diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 303ae5363be..50c7b68f58c 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -1282,9 +1282,21 @@ try { reply.type === 'send-context' && reply.snapshot.text.includes('visible-reference-482'), `${stale} intent cancelled the current reference: ${reply.type}` ) + const replayKey = `${key}:idle-replay` + const keys = [key, replayKey] + // A fresh storage event drains deferred intents without depending on the periodic sweep's phase. + await worker.evaluate(({ key, intent }) => chrome.storage.session.set({ [key]: intent }), { + key: replayKey, + intent, + }) await expect - .poll(() => - worker.evaluate(async (key) => !(key in (await chrome.storage.session.get(key))), key) + .poll( + () => + worker.evaluate(async (keys) => { + const stored = await chrome.storage.session.get(keys) + return keys.every((key) => !(key in stored)) + }, keys), + { message: `${stale}: idle storage delivery drains deferred stale intents` } ) .toBe(true) await settleMessages() diff --git a/apps/sim/app/extension/hooks/use-page-context.ts b/apps/sim/app/extension/hooks/use-page-context.ts index ed46ea17844..bad88ac933e 100644 --- a/apps/sim/app/extension/hooks/use-page-context.ts +++ b/apps/sim/app/extension/hooks/use-page-context.ts @@ -76,7 +76,14 @@ export function usePageContext(origins: string[], scopeKey: string) { let focusRequested = false const confirmFocus = () => { const bridge = bridgeRef.current - if (!focusRequested || bridge?.sessionId !== sessionId || !document.hasFocus()) return + if ( + !focusRequested || + bridge?.sessionId !== sessionId || + !document.hasFocus() || + !composerFocus?.current?.isFocused() + ) { + return + } focusRequested = false const message: AppToExtensionMessage = { channel: 'sim-extension', @@ -98,7 +105,7 @@ export function usePageContext(origins: string[], scopeKey: string) { if (parsed.data.type === 'focus-composer') { if (bridgeRef.current?.sessionId === sessionId) { focusRequested = true - composerFocus?.current?.() + composerFocus?.current?.focus() confirmFocus() } return diff --git a/apps/sim/app/extension/providers.tsx b/apps/sim/app/extension/providers.tsx index 1f160392ae0..50dee185626 100644 --- a/apps/sim/app/extension/providers.tsx +++ b/apps/sim/app/extension/providers.tsx @@ -7,7 +7,7 @@ import { useSession } from '@/lib/auth/client' import ExtensionLoading from '@/app/extension/loading' import { ExtensionSignIn } from '@/app/extension/sign-in' import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' -import { ChatInputFocusContext } from '@/hooks/use-chat-input-focus' +import { ChatInputFocusContext, type ChatInputFocusHandle } from '@/hooks/use-chat-input-focus' interface ExtensionIdentity { origins: string[] @@ -29,7 +29,7 @@ export function ExtensionProviders({ modelSelectorEnabled, planModeEnabled, }: ExtensionProvidersProps) { - const focusComposer = useRef<(() => void) | null>(null) + const focusComposer = useRef(null) const { data: session, isPending, error } = useSession() if (isPending) return const accessDenied = isApiClientError(error) && (error.status === 401 || error.status === 403) diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx index 4c981652327..38778bf511e 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx @@ -67,7 +67,7 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature- import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { mentionifyIntegrations } from '@/blocks/integration-matcher' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' -import type { PrepareChatSubmission } from '@/hooks/use-composer-submission' +import { type PrepareChatSubmission, useComposerSubmission } from '@/hooks/use-composer-submission' import { useHydrated } from '@/hooks/use-hydrated' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import type { ChatContext } from '@/stores/panel' @@ -289,8 +289,10 @@ function OrganizationConversationContent({ } } const fileDropTarget = useRef(null) + const submission = useComposerSubmission({ mode: requestMode }) const files = useFileAttachments({ fileDropTarget: compact ? fileDropTarget : undefined, + disabled: submission.pending, userId: session?.user?.id, organizationId: organization.id, requestMode, @@ -458,6 +460,7 @@ function OrganizationConversationContent({ const composer = ( void value: string files: ReturnType + submission: ReturnType /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */ isInitialView: boolean isSending: boolean @@ -76,6 +77,7 @@ export function Composer({ onPlanChange, value, files, + submission, isInitialView, isSending, onChange, @@ -174,9 +176,7 @@ export function Composer({ ) const placeholder = isInitialView && !compact ? animatedPlaceholder : 'Send message to Sim' - const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({ - mode: requestMode, - }) + const { pending: preparingSubmission, accept: acceptSubmission } = submission const submit = () => { if (attachedFilesRef.current.some((file) => file.uploading)) return const hasPayload = diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 9ff90d8165f..3d45acba81c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -133,10 +133,15 @@ const UserInputImpl = forwardRef(function UserI const prevDefaultValueRef = useRef(defaultValue) + const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({ + scope: draftScopeKey, + mode: requestMode, + }) const files = useFileAttachments({ fileDropTarget, userId, workspaceId, + disabled: preparingSubmission, }) const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key) const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading) @@ -535,10 +540,6 @@ const UserInputImpl = forwardRef(function UserI filesRef.current = { ...filesRef.current, attachedFiles: [] } }, [resetTranscript, history.reset]) - const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({ - scope: draftScopeKey, - mode: requestMode, - }) const handleSubmit = useCallback(() => { if (chatPlanRequired) { useChatPlanModalStore.getState().open(workspaceId) diff --git a/apps/sim/hooks/use-chat-input-focus.ts b/apps/sim/hooks/use-chat-input-focus.ts index c21eccc117c..19d9e3d02d8 100644 --- a/apps/sim/hooks/use-chat-input-focus.ts +++ b/apps/sim/hooks/use-chat-input-focus.ts @@ -1,7 +1,15 @@ import { createContext, type RefObject, useContext, useEffect, useImperativeHandle } from 'react' import { isMobileViewport } from '@/hooks/use-is-mobile' -export const ChatInputFocusContext = createContext void) | null> | null>(null) +/** Requests textarea focus without interrupting dialogs and reports whether that textarea is active. */ +export interface ChatInputFocusHandle { + focus: () => void + isFocused: () => boolean +} + +export const ChatInputFocusContext = createContext | null>( + null +) interface UseChatInputFocusProps { textareaRef: RefObject @@ -12,9 +20,13 @@ export function useChatInputFocus({ textareaRef }: UseChatInputFocusProps) { const requestedFocus = useContext(ChatInputFocusContext) useImperativeHandle( requestedFocus, - () => () => { - if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus() - }, + () => ({ + focus: () => { + if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus() + }, + isFocused: () => + textareaRef.current !== null && document.activeElement === textareaRef.current, + }), [textareaRef] ) useEffect(() => { diff --git a/apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts b/apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts index 6716453cecf..c50a5279fa6 100644 --- a/apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts +++ b/apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts @@ -20,17 +20,102 @@ export async function dropOnChat(page: Page, target: Locator, payload: ChatDropP ], dragOperationsMask: 1, } - const session = await page.context().newCDPSession(page) + const observed = await target.evaluateHandle((element) => { + const events: { + event: DragEvent + receipt: { + type: string + trusted: boolean + tag: string | null + intendedTarget: boolean + chatBody: boolean + inert: boolean + files: number + types: string[] + defaultPrevented: boolean + dropEffect: string | null + x: number + y: number + } + }[] = [] + const receive = (event: DragEvent) => { + const target = event.target instanceof Element ? event.target : null + const receipt = { + type: event.type, + trusted: event.isTrusted, + tag: target?.tagName ?? null, + intendedTarget: target !== null && element.contains(target), + chatBody: Boolean(target?.closest('main')), + inert: Boolean(target?.closest('[inert]')), + files: event.dataTransfer?.files.length ?? 0, + types: Array.from(event.dataTransfer?.types ?? []), + defaultPrevented: event.defaultPrevented, + dropEffect: event.dataTransfer?.dropEffect ?? null, + x: event.clientX, + y: event.clientY, + } + events.push({ event, receipt }) + } + for (const type of ['dragenter', 'dragover', 'drop'] as const) + element.ownerDocument.addEventListener(type, receive, true) + const rect = element.getBoundingClientRect() + const x = rect.x + rect.width / 2 + const y = rect.y + rect.height / 2 + const hit = element.ownerDocument.elementFromPoint(x, y) + return { + readEvents: () => + events.map(({ event, receipt }) => ({ + ...receipt, + defaultPrevented: event.defaultPrevented, + dropEffect: event.dataTransfer?.dropEffect ?? null, + })), + frameTarget: { + x, + y, + width: window.innerWidth, + height: window.innerHeight, + tag: hit?.tagName ?? null, + intendedTarget: hit !== null && element.contains(hit), + inert: Boolean(hit?.closest('[inert]')), + }, + connected: () => element.isConnected, + dispose: () => { + for (const type of ['dragenter', 'dragover', 'drop'] as const) + element.ownerDocument.removeEventListener(type, receive, true) + }, + } + }) try { - for (const type of ['dragEnter', 'dragOver', 'drop'] as const) { - await session.send('Input.dispatchDragEvent', { - type, - x: box.x + box.width / 2, - y: box.y + box.height / 2, - data, - }) + const session = await page.context().newCDPSession(page) + try { + const { windowId, bounds } = await session.send('Browser.getWindowForTarget') + for (const type of ['dragEnter', 'dragOver', 'drop'] as const) { + await session.send('Input.dispatchDragEvent', { + type, + x: box.x + box.width / 2, + y: box.y + box.height / 2, + data, + }) + } + return { + box, + nativeWindow: { windowId, bounds }, + viewport: page.viewportSize(), + topHitTag: await page.evaluate( + ({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null, + { x: box.x + box.width / 2, y: box.y + box.height / 2 } + ), + ...(await observed.evaluate((state) => ({ + events: state.readEvents(), + frameTarget: state.frameTarget, + connected: state.connected(), + }))), + } + } finally { + await session.detach() } } finally { - await session.detach() + await observed.evaluate((state) => state.dispose()).catch(() => {}) + await observed.dispose() } } diff --git a/apps/sim/scripts/test-browser-extension-e2e.ts b/apps/sim/scripts/test-browser-extension-e2e.ts index de358dd5672..3a0aa695a99 100644 --- a/apps/sim/scripts/test-browser-extension-e2e.ts +++ b/apps/sim/scripts/test-browser-extension-e2e.ts @@ -15,6 +15,7 @@ import { type Locator, type Page, type Request, + type Route, } from '@playwright/test' import { pageSnapshotSchema } from '@sim/browser-extension-protocol' import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' @@ -84,7 +85,27 @@ const chatResponses: { events: { type: string | null; seq: number | null }[] error?: string }[] = [] +const uploadRequests: { event: string; stage: string; method: string; status?: number }[] = [] +const draftRemovalReceipts: { scope: string; phase: string; matches: number }[] = [] const responseDiagnostics: Promise[] = [] +const chatTurns = new Map() +const chatAttempts: { + attempt: number + turn: number | null + check: string + scenario: string + phase: string + scope: string + sender: string +}[] = [] +const dropReceipts: { + scope: string + phase: string + receipt: Awaited> +}[] = [] +let currentCheck = '' +let scenario = '' +let phase = '' let failureState: { path: string; chatId: string | null; text: string }[] = [] let pendingAtFailure: { path: string; method: string; resourceType: string }[] = [] const uploadedPaths = new Set() @@ -144,6 +165,9 @@ async function check( run: () => Promise ) { if (group !== 'all' && group !== owner && !(group === 'native' && owner !== 'access')) return + currentCheck = name + scenario = '' + phase = '' const started = performance.now() try { await run() @@ -324,8 +348,8 @@ try { // Only the synthetic page is pregranted; native permission prompts are a separate acceptance check. manifest.host_permissions.push(`${fixtureOrigin}/*`) await writeFile(resolve(extensionPath, 'manifest.json'), JSON.stringify(manifest)) - const launchBrowserContext = () => - chromium.launchPersistentContext(resolve(temporary, 'profile'), { + const launchBrowserContext = async () => { + const opened = await chromium.launchPersistentContext(resolve(temporary, 'profile'), { channel: 'chromium', headless: process.env.EXTENSION_APP_E2E_HEADED !== '1', viewport: { width: 390, height: 844 }, @@ -335,6 +359,38 @@ try { '--use-fake-device-for-media-stream', ], }) + opened.on('request', (request) => { + const url = new URL(request.url()) + if ( + url.origin !== base.origin || + url.pathname !== '/api/mothership/chat' || + request.method() !== 'POST' + ) + return + const body = toRecord(request.postDataJSON()) + const id = toStringOrNull(body.userMessageId) + if (id && !chatTurns.has(id)) chatTurns.set(id, chatTurns.size + 1) + let sender = 'unknown' + try { + sender = request.frame().page() === panel ? 'current-panel' : 'other-page' + } catch {} + chatAttempts.push({ + attempt: chatAttempts.length + 1, + turn: id ? (chatTurns.get(id) ?? null) : null, + check: currentCheck, + scenario, + phase, + scope: + body.organizationId === organizationId + ? 'organization' + : body.workspaceId === alphaId + ? 'workspace' + : 'other', + sender, + }) + }) + return opened + } context = await launchBrowserContext() let browserContext = context context.setDefaultTimeout(30_000) @@ -365,7 +421,16 @@ try { route.fulfill({ json: { sttAvailable: true } }) ) page.on('pageerror', (error) => browserErrors.push(error.message)) - page.on('request', (request) => pendingRequests.add(request)) + page.on('request', (request) => { + pendingRequests.add(request) + const url = new URL(request.url()) + if (url.origin === base.origin && url.pathname.startsWith('/api/files/uploads')) + uploadRequests.push({ + event: 'request', + stage: url.pathname.endsWith('/complete') ? 'complete' : 'session', + method: request.method(), + }) + }) page.on('requestfinished', (request) => pendingRequests.delete(request)) page.on('requestfailed', (request) => { pendingRequests.delete(request) @@ -376,6 +441,13 @@ try { }) page.on('response', (response) => { const url = new URL(response.url()) + if (url.origin === base.origin && url.pathname.startsWith('/api/files/uploads')) + uploadRequests.push({ + event: 'response', + stage: url.pathname.endsWith('/complete') ? 'complete' : 'session', + method: response.request().method(), + status: response.status(), + }) if (url.origin === base.origin && url.pathname === '/api/mothership/chat') { const entry: (typeof chatResponses)[number] = { status: response.status(), @@ -495,6 +567,8 @@ try { ) return document.body.dataset.captureReceipts = String(++receipts) + if ('sessionId' in event.data && typeof event.data.sessionId === 'string') + document.body.dataset.captureSession = event.data.sessionId }) }, base.origin) await observeCaptureReceipts() @@ -543,7 +617,7 @@ try { buffer: Buffer.from('This synthetic file belongs only to extension QA.'), }) ) - const attachScreenshot = async (target: Locator) => { + const screenshotFile = async () => { const name = 'drop-fixture.png' const png = resolve(temporary, name) await writeFile( @@ -553,7 +627,255 @@ try { 'base64' ) ) - return uploadFile(name, () => dropOnChat(shell, target, { files: [png] })) + return { name, png } + } + const attachScreenshot = async (target: Locator, scope?: string) => { + const { name, png } = await screenshotFile() + return uploadFile(name, async () => { + const receipt = await dropOnChat(shell, target, { files: [png] }) + if (scope) { + dropReceipts.push({ scope, phase: 'recovery', receipt }) + assert( + receipt.events.some( + (event) => + event.type === 'drop' && + event.trusted && + event.files === 1 && + event.chatBody && + event.intendedTarget && + event.defaultPrevented + ), + `${scope}: the recovery file drop did not reach the chat body` + ) + } + }) + } + const installCaptureGate = () => + shell.addInitScript((parentOrigin) => { + if (window.parent === window) return + let held: MessageEvent | undefined + let armed = false + const receive = (event: MessageEvent) => { + const message = event.data + if ( + !armed || + event.source !== window.parent || + event.origin !== parentOrigin || + typeof message !== 'object' || + message === null || + !('channel' in message) || + message.channel !== 'sim-extension' || + !('type' in message) || + message.type !== 'send-context' + ) + return + held = event + document.documentElement.dataset.qaCaptureGate = 'held' + event.stopImmediatePropagation() + } + const arm = () => { + armed = true + document.documentElement.dataset.qaCaptureGate = 'armed' + } + const release = () => { + armed = false + const received = held + held = undefined + if (received) + window.dispatchEvent( + new MessageEvent('message', { + data: received.data, + origin: received.origin, + source: received.source, + lastEventId: received.lastEventId, + ports: [...received.ports], + }) + ) + document.documentElement.dataset.qaCaptureGate = 'released' + } + const dispose = () => { + armed = false + held = undefined + window.removeEventListener('message', receive, true) + window.removeEventListener('sim-qa-arm-capture', arm) + window.removeEventListener('sim-qa-release-capture', release) + window.removeEventListener('sim-qa-dispose-capture', dispose) + } + // Window message listeners run in registration order, including capture listeners. + window.addEventListener('message', receive, true) + window.addEventListener('sim-qa-arm-capture', arm) + window.addEventListener('sim-qa-release-capture', release) + window.addEventListener('sim-qa-dispose-capture', dispose) + }, `chrome-extension://${extensionId}`) + const verifyPendingCaptureDrop = async (scope: 'workspace' | 'organization' | 'assistant') => { + const app = frame() + const input = app.getByRole('textbox', { + name: scope === 'workspace' ? 'Message' : 'Ask Sim', + exact: true, + }) + await expect(input).toBeVisible() + const editorElement = await input.elementHandle() + assert(editorElement, 'The pending capture needs a mounted composer') + await expect(attachments()).toHaveCount(0) + const greeting = app.getByRole('heading', { name: 'How can I help?', exact: true }) + await expect(greeting).toBeVisible() + const priorPreference = await worker.evaluate(() => + chrome.storage.local.get('referenceCurrentPage') + ) + const endpoint = `${base.origin}/api/mothership/chat` + const uploadEndpoint = `${base.origin}/api/files/uploads` + const submitted: Record[] = [] + let uploadAttempts = 0 + const interceptChat = (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + submitted.push(toRecord(route.request().postDataJSON())) + injectedRequests.add(route.request()) + return route.fulfill({ status: 403, json: { error: 'QA pending capture admission check' } }) + } + const interceptUpload = (route: Route) => { + if (route.request().method() !== 'POST') return route.continue() + uploadAttempts++ + injectedRequests.add(route.request()) + return route.fulfill({ status: 409, json: { error: 'QA unexpected pending upload' } }) + } + await browserContext.route(endpoint, interceptChat) + await browserContext.route(uploadEndpoint, interceptUpload) + try { + await app.getByRole('button', { name: 'Add resources', exact: true }).press('Enter') + const preference = app.getByRole('menuitemcheckbox', { name: 'Reference current page' }) + await expect(preference).toBeVisible() + if ((await preference.getAttribute('aria-checked')) !== 'true') + await preference.press('Enter') + else await preference.press('Escape') + await expect( + app.getByRole('button', { name: 'Attach current page', exact: true }) + ).toHaveText('Current page') + const draft = `Preserve the original ${scope} send during a screenshot drop.` + await input.fill(draft) + await expect(attachments()).toHaveCount(0) + await app.evaluate(() => window.dispatchEvent(new Event('sim-qa-arm-capture'))) + await expect( + app.locator('html'), + `${scope}: capture gate installed before app startup` + ).toHaveAttribute('data-qa-capture-gate', 'armed') + await source.bringToFront() + await input.press('Enter') + await expect( + app.locator('html'), + `${scope}: genuine send-context held before app delivery` + ).toHaveAttribute('data-qa-capture-gate', 'held') + const preparing = () => + editorElement.evaluate((element) => Boolean(element.closest('[inert][aria-busy="true"]'))) + await expect + .poll(preparing, { message: `${scope}: held capture keeps the composer inert` }) + .toBe(true) + assert.equal( + submitted.length, + 0, + `${scope}: the capture gate did not defer the original send` + ) + const { name, png } = await screenshotFile() + const pageCount = browserContext.pages().length + const previousUrl = app.url() + const receipt = await dropOnChat(shell, greeting, { files: [png] }) + dropReceipts.push({ scope, phase: 'held-capture', receipt }) + assert( + receipt.events.some( + (event) => + event.type === 'dragover' && + event.trusted && + event.types.includes('Files') && + event.intendedTarget && + event.chatBody && + event.defaultPrevented + ), + `${scope}: the held-capture file drag did not reach the intended chat body` + ) + await app.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + ) + await screenshot(`pending-capture-drop-${scope}`) + assert.equal(uploadAttempts, 0, `${scope}: a body drop started uploading during capture`) + await expect.poll(() => editorElement.inputValue()).toBe(draft) + await expect(app.getByRole('button', { name: `Remove ${name}`, exact: true })).toHaveCount(0) + assert.equal(submitted.length, 0, `${scope}: the held capture committed early`) + assert.equal(app.url(), previousUrl) + assert.equal(browserContext.pages().length, pageCount) + await expect + .poll(preparing, { message: `${scope}: held capture keeps the composer inert` }) + .toBe(true) + await app.evaluate(() => window.dispatchEvent(new Event('sim-qa-release-capture'))) + await expect.poll(() => submitted.length).toBe(1) + const body = submitted[0] + assert(String(body.message).startsWith(draft)) + assert.equal( + body[scope === 'workspace' ? 'workspaceId' : 'organizationId'], + scope === 'workspace' ? alphaId : organizationId + ) + if (scope === 'assistant') assert.equal(body.mode, 'assistant') + const pages = submittedPages(body) + assert.equal(pages.length, 1) + assert(pages[0].text.includes('ORCHID-482')) + assert.equal(pages[0].url, `${fixtureOrigin}/page`) + assert.equal(body.fileAttachments, undefined) + await expect + .poll( + () => input.evaluate((element) => Boolean(element.closest('[inert][aria-busy="true"]'))), + { message: `${scope}: the current composer resumes after capture` } + ) + .toBe(false) + await expect( + app.getByText('Your draft changed while the page was being read. Send again to continue.', { + exact: true, + }) + ).toHaveCount(0) + assert.equal(uploadAttempts, 0) + await browserContext.unroute(uploadEndpoint, interceptUpload) + const recoveryKey = await attachScreenshot(app.getByRole('main'), scope) + await screenshot(`pending-capture-drop-${scope}-recovered`) + await app.getByRole('button', { name: `Remove ${name}`, exact: true }).click() + await expect(app.getByRole('button', { name: `Remove ${name}`, exact: true })).toHaveCount(0) + const persistedMatches = async () => { + const stored: unknown = await app.evaluate(() => + JSON.parse(localStorage.getItem('mothership-drafts:v1') ?? '{}') + ) + const drafts = toRecord(toRecord(toRecord(stored).state).drafts) + return Object.values(drafts).filter((draft) => { + const files = toRecord(draft).fileAttachments + return Array.isArray(files) && files.some((file) => toRecord(file).key === recoveryKey) + }).length + } + draftRemovalReceipts.push({ + scope, + phase: 'after-ui-removal', + matches: await persistedMatches(), + }) + await expect + .poll(persistedMatches, { + message: `${scope}: removed recovery file leaves persisted drafts`, + }) + .toBe(0) + draftRemovalReceipts.push({ + scope, + phase: 'before-tab-close', + matches: await persistedMatches(), + }) + assert.equal(submitted.length, 1, `${scope}: recovering drops duplicated the turn`) + } finally { + await app + .evaluate(() => window.dispatchEvent(new Event('sim-qa-release-capture'))) + .catch(() => {}) + await app + .evaluate(() => window.dispatchEvent(new Event('sim-qa-dispose-capture'))) + .catch(() => {}) + await editorElement.dispose() + await browserContext.unroute(endpoint, interceptChat) + await browserContext.unroute(uploadEndpoint, interceptUpload) + await worker.evaluate(async (previous) => { + if ('referenceCurrentPage' in previous) await chrome.storage.local.set(previous) + else await chrome.storage.local.remove('referenceCurrentPage') + }, priorPreference) + } } await checkNavigation( 'Capture, preview, removal, and the three-page cap use the real composer', @@ -572,7 +894,106 @@ try { captureText.replace('QA_PRIVATE_PAGE_MARKER:', 'Delivery plan\nQA_PRIVATE_PAGE_MARKER:') ) await screenshot('page-preview') - await preview.getByRole('button', { name: 'Close', exact: true }).click() + const focusSession = await shell.locator('body').getAttribute('data-capture-session') + assert(focusSession, 'The real capture did not acknowledge its session') + const focusObserver = await shell.evaluateHandle( + ({ origin, sessionId }) => { + let focused = 0 + let processed = 0 + document.body.dataset.composerFocused = '0' + document.body.dataset.focusProcessed = '0' + const receive = (event: MessageEvent) => { + if ( + event.origin !== origin || + event.source !== document.querySelector('iframe')?.contentWindow || + typeof event.data !== 'object' || + event.data === null || + !('sessionId' in event.data) || + event.data.sessionId !== sessionId || + !('type' in event.data) + ) + return + if (event.data.type === 'composer-focused') + document.body.dataset.composerFocused = String(++focused) + if (event.data.type === 'e2e-focus-processed') + document.body.dataset.focusProcessed = String(++processed) + } + window.addEventListener('message', receive) + return () => window.removeEventListener('message', receive) + }, + { origin: base.origin, sessionId: focusSession } + ) + const focusBarrier = await frame().evaluateHandle( + ({ origin, sessionId }) => { + const receive = (event: MessageEvent) => { + if ( + event.origin !== origin || + event.source !== parent || + typeof event.data !== 'object' || + event.data === null || + !('sessionId' in event.data) || + event.data.sessionId !== sessionId || + !('type' in event.data) || + event.data.type !== 'focus-composer' + ) + return + parent.postMessage({ type: 'e2e-focus-processed', sessionId }, origin) + } + window.addEventListener('message', receive) + return () => window.removeEventListener('message', receive) + }, + { origin: `chrome-extension://${extensionId}`, sessionId: focusSession } + ) + const requestComposerFocus = async (processed: number) => { + await shell.evaluate( + ({ origin, sessionId }) => + document + .querySelector('iframe') + ?.contentWindow?.postMessage( + { channel: 'sim-extension', version: 1, type: 'focus-composer', sessionId }, + origin + ), + { origin: base.origin, sessionId: focusSession } + ) + await expect(shell.locator('body')).toHaveAttribute( + 'data-focus-processed', + String(processed) + ) + } + try { + await shell.bringToFront() + const close = preview.getByRole('button', { name: 'Close', exact: true }) + await close.focus() + await expect(close).toBeFocused() + assert(await frame().evaluate(() => document.hasFocus())) + await requestComposerFocus(1) + assert.equal( + await shell.locator('body').getAttribute('data-composer-focused'), + '0', + 'An app dialog must not acknowledge composer focus' + ) + await expect(close).toBeFocused() + await close.press('Tab') + assert(await preview.evaluate((element) => element.contains(document.activeElement))) + await requestComposerFocus(2) + assert.equal(await shell.locator('body').getAttribute('data-composer-focused'), '0') + await screenshot('focus-deferred-by-page-preview') + await close.click() + await expect(preview).toHaveCount(0) + await requestComposerFocus(3) + const editor = frame().getByRole('textbox', { name: 'Message', exact: true }) + await expect(editor).toBeFocused() + await expect(shell.locator('body')).toHaveAttribute('data-composer-focused', '1') + await shell.keyboard.type('Keyboard input belongs to the composer.') + await expect(editor).toHaveValue('Keyboard input belongs to the composer.') + await screenshot('focus-confirmed-in-composer') + await editor.fill('') + } finally { + await focusBarrier.evaluate((stop) => stop()) + await focusObserver.evaluate((stop) => stop()) + await focusBarrier.dispose() + await focusObserver.dispose() + } await capture() await capture() await capture() @@ -1546,8 +1967,9 @@ try { if (enabled) await expect(globe).toHaveText('Current page') else await expect(globe).not.toHaveText('Current page') } - const openScenario = async (target: string) => { - // A rejected send can recover from this tab's session after navigation. + const openScenario = async (target: string, name: string) => { + scenario = name + phase = 'opening' await shell.close() panel = await openPanel() shell = panel @@ -1555,6 +1977,9 @@ try { await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`) await observeCaptureReceipts() await navigate(base.origin + target) + await expect( + frame().getByRole('heading', { name: 'How can I help?', exact: true }) + ).toBeVisible() await setReference(true) } try { @@ -1562,7 +1987,7 @@ try { const target = org ? `/extension/o/${organizationId}` : `/extension/${alphaId}` const input = () => frame().getByRole('textbox', { name: org ? 'Ask Sim' : 'Message', exact: true }) - await openScenario(target) + await openScenario(target, `${org ? 'organization' : 'workspace'}-reference`) await expect(input()).toBeVisible() await input().fill('Summarize the current page') const submit = frame().getByRole('button', { @@ -1573,6 +1998,7 @@ try { await screenshot(`reference-${org ? 'organization' : 'workspace'}-enabled-320`) await second.bringToFront() let count = received.length + phase = 'send' await input().press('Enter') await expect.poll(() => received.length).toBe(count + 1) const pages = submittedPages(received[count]) @@ -1584,24 +2010,26 @@ try { org ? organizationId : alphaId ) - await openScenario(target) + await openScenario(target, `${org ? 'organization' : 'workspace'}-manual`) await expect(input()).toBeVisible() await capture() await second.bringToFront() await input().fill('Compare the attached snapshot') count = received.length + phase = 'send' await input().press('Enter') await expect.poll(() => received.length).toBe(count + 1) const manual = submittedPages(received[count]) assert.equal(manual.length, 1) assert(manual[0].text.includes('ORCHID-482') && !manual[0].text.includes('JADE-231')) - await openScenario(target) + await openScenario(target, `${org ? 'organization' : 'workspace'}-denied`) await expect(input()).toBeVisible() await restricted.bringToFront() const draft = 'Keep this draft if access is unavailable' await input().fill(draft) count = received.length + phase = 'denied-send' await input().press('Enter') await expect( shell.getByRole('button', { name: 'Request site access', exact: true }) @@ -1620,6 +2048,7 @@ try { 'Access guidance covered the composer twice' ) await setReference(false) + phase = 'opt-out-send' await input().press('Enter') await expect.poll(() => received.length).toBe(count + 1) assert.equal(submittedPages(received[count]).length, 0) @@ -1633,6 +2062,27 @@ try { } } ) + await checkComposer( + 'Pending page reference freezes body file drops in workspace and organization chat', + async () => { + for (const scope of ['workspace', 'organization'] as const) { + await shell.close() + panel = await openPanel() + shell = panel + const captureGate = await installCaptureGate() + try { + await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`) + await observeCaptureReceipts() + await navigate( + `${base.origin}/extension/${scope === 'workspace' ? alphaId : `o/${organizationId}`}` + ) + await verifyPendingCaptureDrop(scope) + } finally { + await captureGate.dispose() + } + } + } + ) await checkComposer( 'A screenshot dropped on the workspace or organization chat body uploads once and can be sent by itself', async () => { @@ -1661,6 +2111,10 @@ try { await navigate(`${base.origin}/extension/${org ? `o/${organizationId}` : alphaId}`) const greeting = frame().getByRole('heading', { name: 'How can I help?', exact: true }) await expect(greeting).toBeVisible() + await expect( + frame().getByRole('button', { name: /^Remove (?!page context$)/ }), + `${surface}: screenshot scenario starts without a previous file draft` + ).toHaveCount(0) let release = () => {} const gate = new Promise((resolve) => { release = resolve @@ -2158,6 +2612,27 @@ try { } else await shell.unroute('**/api/mothership/chat') } ) + await checkAccess( + 'Restricted Assistant freezes body file drops while a page reference is pending', + async () => { + await shell.close() + panel = await openPanel() + shell = panel + if (!liveChat) await shell.clock.install() + const captureGate = await installCaptureGate() + try { + await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`) + await observeCaptureReceipts() + await navigate(`${base.origin}/extension/o/${organizationId}`) + await expect( + frame().getByRole('button', { name: 'Attach image', exact: true }) + ).toBeVisible() + await verifyPendingCaptureDrop('assistant') + } finally { + await captureGate.dispose() + } + } + ) await checkAccess( 'Revoking organization membership removes the live composer and pending captures', async () => { @@ -2537,6 +3012,10 @@ try { failureState, httpErrors, chatResponses, + chatAttempts, + dropReceipts, + uploadRequests, + draftRemovalReceipts, }, null, 2