diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 38d1075671..e3378e8d75 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -12,7 +12,7 @@ "test:ui-components": "node --test tests/mobile-ui-components.test.mjs", "test:account-login": "node --test tests/account-login.test.mjs", "test:device-compatibility": "node --test tests/device-compatibility.test.mjs", - "test:account-browser": "node --test tests/browser-account.test.mjs", + "test:account-browser": "node --test --test-force-exit tests/browser-account.test.mjs", "test:images": "node --test tests/image-compressor.test.mjs", "test:workspace-identity": "node --test tests/workspace-identity.test.mjs tests/workspace-routing.test.mjs", "test:remote-cache-browser": "node --test tests/remote-cache-browser.test.mjs", diff --git a/src/mobile-web/src/components/ChatMessageActions.tsx b/src/mobile-web/src/components/ChatMessageActions.tsx index faa8b9cd59..2b004a61bd 100644 --- a/src/mobile-web/src/components/ChatMessageActions.tsx +++ b/src/mobile-web/src/components/ChatMessageActions.tsx @@ -36,11 +36,14 @@ export default function ChatMessageActions({ onRollback, }: ChatMessageActionsProps) { const { t } = useI18n(); - const hasTurnId = rollbackSupported && message?.role === 'user' && Boolean(message.turn_id); + const hasTurnId = rollbackSupported + && message?.role === 'user' + && Boolean(message.turn_id) + && typeof message.turn_index === 'number'; const actions: MobileActionSheetItem[] = message ? [ { id: 'copy', label: t('chat.copyMessage'), leading: }, - ...(message.role === 'user' ? [{ id: 'resend', label: t('chat.resendMessage'), leading: }] : []), + ...(message.role === 'user' && !hasTurnId ? [{ id: 'resend', label: t('chat.resendMessage'), leading: }] : []), ...(hasTurnId ? [ { id: 'edit', diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 5831ef0e15..3fcfd1a9c6 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -311,9 +311,9 @@ export const messages: Record = { editAndResend: 'Edit & resend', rollbackToHere: 'Roll back to here', rollbackSheetTitle: 'Roll back to this message?', - rollbackSheetHint: 'The desktop withdraws this message and everything after it, and restores the files those turns changed. Its text comes back to the input box so you can edit and send again.', + rollbackSheetHint: 'Withdraws this and subsequent messages, and reverts file changes.', editSheetTitle: 'Edit and resend', - editSheetHint: 'The desktop withdraws this message and everything after it first, then runs your edited text as a new turn.', + editSheetHint: 'Withdraws subsequent turns and runs your edited text.', rollbackAction: 'Roll back', editAction: 'Roll back & send', editPlaceholder: 'Edit this message...', @@ -678,9 +678,9 @@ export const messages: Record = { editAndResend: '编辑并重发', rollbackToHere: '回滚到这条', rollbackSheetTitle: '回滚到这条消息?', - rollbackSheetHint: '桌面端会撤回这条消息及其之后的全部内容,并还原这些轮次改动过的文件。这条消息的原文会回到输入框,方便你改完再发。', + rollbackSheetHint: '将撤回此条及后续消息,并还原文件变更。', editSheetTitle: '编辑并重发', - editSheetHint: '桌面端会先撤回这条消息及其之后的全部内容,再把你编辑后的文本作为新一轮发送。', + editSheetHint: '撤回后续消息并重新发送。', rollbackAction: '回滚', editAction: '回滚并发送', editPlaceholder: '编辑这条消息...', @@ -1045,9 +1045,9 @@ export const messages: Record = { editAndResend: '編輯並重送', rollbackToHere: '回溯到這條', rollbackSheetTitle: '回溯到這條訊息?', - rollbackSheetHint: '桌面端會撤回這條訊息及其之後的全部內容,並還原這些輪次改動過的檔案。這條訊息的原文會回到輸入框,方便你改完再送。', + rollbackSheetHint: '將撤回這條及後續訊息,並還原檔案變更。', editSheetTitle: '編輯並重送', - editSheetHint: '桌面端會先撤回這條訊息及其之後的全部內容,再把你編輯後的文本作為新一輪送出。', + editSheetHint: '撤回後續訊息並重新送出。', rollbackAction: '回溯', editAction: '回溯並送出', editPlaceholder: '編輯這條訊息...', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 09a521d7ab..08cf2dc2d0 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -16,6 +16,11 @@ import { type ChatMessage, type RemoteModelCatalog, } from '../services/RemoteSessionManager'; +import { + extractValidImageContexts, + extractValidPendingImages, + type PendingChatImage, +} from '../services/chatImageAttachments'; import { useMobileStore } from '../services/store'; import { createRemoteCacheScope, remoteCache } from '../services/RemoteCache'; import ChatHeader from '../components/ChatHeader'; @@ -60,11 +65,8 @@ function sanitizeMessageText(content: string): string { .trim(); } - // ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── - - // ─── ChatPage ─────────────────────────────────────────────────────────────── const ChatPage: React.FC = ({ @@ -99,7 +101,7 @@ const ChatPage: React.FC = ({ const [modelCatalog, setModelCatalog] = useState(null); const [selectedModelId, setSelectedModelId] = useState('auto'); const [modelUpdating, setModelUpdating] = useState(false); - const [pendingImages, setPendingImages] = useState<{ name: string; dataUrl: string }[]>([]); + const [pendingImages, setPendingImages] = useState([]); const [imageAnalyzing, setImageAnalyzing] = useState(false); const [isSending, setIsSending] = useState(false); const sendInFlightRef = useRef(null); @@ -210,6 +212,7 @@ const ChatPage: React.FC = ({ } | null>(null); const [rollbackDraft, setRollbackDraft] = useState(''); const [rollbackBusy, setRollbackBusy] = useState(false); + const rollbackInFlightRef = useRef(null); const msgLongPressTimerRef = useRef>(); const msgLongPressPosRef = useRef({ x: 0, y: 0 }); const msgToastTimerRef = useRef>(); @@ -248,6 +251,7 @@ const ChatPage: React.FC = ({ setRollbackTarget(null); setRollbackDraft(''); setRollbackBusy(false); + rollbackInFlightRef.current = null; setActionToast(null); setInfoToast(null); setExpandedMsgIds(new Set()); @@ -603,23 +607,13 @@ const ChatPage: React.FC = ({ const text = sanitizeMessageText(menuMessage.content); if (!text) return; setMenuMessage(null); - const imageContexts = menuMessage.images?.length - ? menuMessage.images.map((img, idx) => { - const mimeType = img.data_url.split(';')[0]?.replace('data:', '') || 'image/png'; - return { - id: `mobile_resend_${Date.now()}_${idx}`, - data_url: img.data_url, - mime_type: mimeType, - metadata: { name: img.name, source: 'remote' }, - }; - }) - : undefined; try { + const imageContexts = extractValidImageContexts(menuMessage.images, 'mobile_resend'); await sessionMgr.sendMessage(sessionId, text, sessionAgentType, imageContexts); if (!isChatTargetCurrent(targetEpoch)) return; streamRef.current?.nudge(); } catch (e: any) { - reportRemoteSessionError(e, setError); + if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); } }, [captureChatTargetEpoch, isChatTargetCurrent, menuMessage, sessionAgentType, sessionId, sessionMgr, setError]); @@ -658,10 +652,10 @@ const ChatPage: React.FC = ({ // the files they wrote. Editing is that same rollback followed by a normal // send, which is how the desktop reruns an edited user message. const handleConfirmRollback = useCallback(async () => { - if (!rollbackTarget || rollbackBusy) return; + if (!rollbackTarget || rollbackBusy || rollbackInFlightRef.current) return; // The host independently checks idle under its scheduling lock; this // presentation guard only avoids a request while this view is already busy. - if (isStreaming) return; + if (isStreaming || isSending || sendInFlightRef.current) return; const { message, mode } = rollbackTarget; const turnId = message.turn_id; if (!turnId) return; @@ -670,10 +664,13 @@ const ChatPage: React.FC = ({ const targetEpoch = captureChatTargetEpoch(); if (targetEpoch === null) return; + const attempt = Symbol('rollback'); + rollbackInFlightRef.current = attempt; setRollbackBusy(true); + const isCurrentAttempt = () => isChatTargetCurrent(targetEpoch) && rollbackInFlightRef.current === attempt; try { const result = await sessionMgr.rollbackSessionToTurn(sessionId, turnId, message.turn_index); - if (!isChatTargetCurrent(targetEpoch)) return; + if (!isCurrentAttempt()) return; setRollbackTarget(null); setRollbackDraft(''); // History changed on the host. Pull the authoritative snapshot now, before @@ -682,31 +679,34 @@ const ChatPage: React.FC = ({ streamRef.current?.nudge(); if (mode === 'edit') { - const imageContexts = message.images?.length - ? message.images.map((img, idx) => ({ - id: `mobile_edit_${Date.now()}_${idx}`, - data_url: img.data_url, - mime_type: img.data_url.split(';')[0]?.replace('data:', '') || 'image/png', - metadata: { name: img.name, source: 'remote' }, - })) - : undefined; + const imageContexts = extractValidImageContexts(message.images, 'mobile_edit'); + const fallbackPendingImages = extractValidPendingImages(message.images); try { await sessionMgr.sendMessage(sessionId, editedText, sessionAgentType, imageContexts); } catch (sendError) { // The rollback already retired the turn this text came from, so the // draft has nowhere to fall back to. Hand it to the composer instead // of dropping it when the send is what failed. - if (isChatTargetCurrent(targetEpoch)) { + if (isCurrentAttempt()) { setInput(editedText); - setPendingImages((message.images ?? []).map(img => ({ name: img.name, dataUrl: img.data_url }))); + setPendingImages(fallbackPendingImages); setInputExpanded(true); } throw sendError; } - if (!isChatTargetCurrent(targetEpoch)) return; - } else if (result.composer_text) { - setInput(result.composer_text); - setInputExpanded(true); + if (!isCurrentAttempt()) return; + } else { + const restoredText = result.composer_text ?? ''; + const restoredImages = extractValidPendingImages(message.images); + // Replace the composer only when the rollback actually restored + // something. Replacing it with an empty draft would silently discard + // text the user started after opening the sheet, while keeping the old + // attachment set would attach unrelated images to the restored prompt. + if (restoredText.trim() || restoredImages.length > 0) { + setInput(restoredText); + setPendingImages(restoredImages); + setInputExpanded(true); + } } showMsgToast( @@ -723,18 +723,22 @@ const ChatPage: React.FC = ({ // conflict), so pull the authoritative snapshot instead of leaving the // transcript stale until the next idle poll. The stream ref belongs to // the current chat, so guard against a session switch mid-flight. - if (isChatTargetCurrent(targetEpoch)) { + if (isCurrentAttempt()) { streamRef.current?.nudge(); } - if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); + if (isCurrentAttempt()) reportRemoteSessionError(e, setError); } finally { - if (isChatTargetCurrent(targetEpoch)) { - setRollbackBusy(false); + if (rollbackInFlightRef.current === attempt) { + rollbackInFlightRef.current = null; + if (isChatTargetCurrent(targetEpoch)) { + setRollbackBusy(false); + } } } }, [ captureChatTargetEpoch, isChatTargetCurrent, + isSending, isStreaming, rollbackBusy, rollbackDraft, @@ -1150,6 +1154,9 @@ const ChatPage: React.FC = ({ const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; const displayName = liveTitle || sessionName || t('chat.session'); + const isRemoteWorkspace = currentWorkspace?.workspace_kind === 'remote' + || Boolean(currentWorkspace?.remote_connection_id) + || Boolean(currentWorkspace?.remote_ssh_host); return (
@@ -1232,8 +1239,8 @@ const ChatPage: React.FC = ({ setMenuMessage(null)} onCopy={() => void handleCopyMessage()} onDelete={() => void handleDeleteMessage()} diff --git a/src/mobile-web/src/services/SessionRecordPresentation.ts b/src/mobile-web/src/services/SessionRecordPresentation.ts index be88b2ec94..d6b7699616 100644 --- a/src/mobile-web/src/services/SessionRecordPresentation.ts +++ b/src/mobile-web/src/services/SessionRecordPresentation.ts @@ -9,6 +9,28 @@ export interface MobileStoredTurn { } interface RichText { id: string; content: string; orderIndex?: number; timestamp?: number; status?: string; subagentSessionId?: string } interface RichTool { id: string; toolName: string; orderIndex?: number; status?: string; startTime?: number; durationMs?: number; subagentSessionId?: string; toolCall: {id:string;input:unknown}; toolResult?:{result:unknown;success:boolean;error?:string} } + +/** + * Streamed turn records carry user attachment pixels in + * `userMessage.metadata.images`; the timeline and rollback/edit drafts read + * them from `ChatMessage.images`. Keep entries whose pixels arrived so a + * rollback never re-sends a blank frame. + */ +function presentUserMessageImages(metadata: unknown): ChatMessage['images'] { + if (!metadata || typeof metadata !== 'object') return undefined; + const images = (metadata as { images?: unknown }).images; + if (!Array.isArray(images)) return undefined; + const presented = images.flatMap((image) => { + if (!image || typeof image !== 'object') return []; + const record = image as { name?: unknown; data_url?: unknown }; + const dataUrl = typeof record.data_url === 'string' ? record.data_url.trim() : ''; + if (!dataUrl) return []; + const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'image'; + return [{ name, data_url: dataUrl }]; + }); + return presented.length > 0 ? presented : undefined; +} + export function presentSessionTurn(turn: MobileStoredTurn): ChatMessage[] { const items: ChatMessageItem[] = [...turn.modelRounds].sort((a,b)=>a.roundIndex-b.roundIndex).flatMap(round => { const ordered = [ @@ -25,7 +47,7 @@ export function presentSessionTurn(turn: MobileStoredTurn): ChatMessage[] { }); }); return [ - {id:turn.userMessage.id,turn_id:turn.turnId,turn_index:turn.turnIndex,role:'user',content:turn.userMessage.content,timestamp:String(turn.userMessage.timestamp),metadata:turn.userMessage.metadata}, + {id:turn.userMessage.id,turn_id:turn.turnId,turn_index:turn.turnIndex,role:'user',content:turn.userMessage.content,timestamp:String(turn.userMessage.timestamp),metadata:turn.userMessage.metadata,images:presentUserMessageImages(turn.userMessage.metadata)}, {id:`${turn.turnId}_assistant`,turn_id:turn.turnId,role:'assistant',content:items.filter(i=>i.type==='text').map(i=>i.content??'').join(''),thinking:items.filter(i=>i.type==='thinking').map(i=>i.content??'').join(''),items,tools:items.flatMap(i=>i.tool?[i.tool]:[]),status:turn.status==='inprogress'?'streaming':turn.status,error:turn.error,timestamp:String(turn.timestamp)}, ]; } diff --git a/src/mobile-web/src/services/chatImageAttachments.ts b/src/mobile-web/src/services/chatImageAttachments.ts new file mode 100644 index 0000000000..2b2562f42e --- /dev/null +++ b/src/mobile-web/src/services/chatImageAttachments.ts @@ -0,0 +1,90 @@ +/** Model-ready image attachment projected from a remote transcript message. */ +export interface RemoteImageContext { + id: string; + data_url: string; + mime_type: string; + metadata: { name: string; source: string }; +} + +/** Attachment restored into the mobile composer. */ +export interface PendingChatImage { + name: string; + dataUrl: string; +} + +/** + * Accept only canonical base64 image data URLs. The host may replay transcript + * data that was edited or truncated, so padding bits and quantum length are + * checked as well as the alphabet (RFC 4648 section 3.5) before the payload is + * forwarded to the model or decoded by the browser. + */ +export function normalizeValidImageDataUrl( + url: unknown, +): { trimmedUrl: string; mimeType: string } | null { + if (typeof url !== 'string') return null; + const trimmedUrl = url.trim(); + const match = trimmedUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/); + if (!match) return null; + const mimeType = match[1]; + const base64Data = match[2]; + const isValid = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=|[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{4})$/.test( + base64Data, + ); + if (!isValid) return null; + return { trimmedUrl, mimeType }; +} + +/** Project valid transcript images into `send_message` image contexts. */ +export function extractValidImageContexts( + images: unknown, + prefix: string, +): RemoteImageContext[] | undefined { + if (!Array.isArray(images) || images.length === 0) return undefined; + const valid: { data_url: string; mime_type: string; name: string }[] = []; + for (const img of images) { + try { + if (img && typeof img === 'object') { + const normalized = normalizeValidImageDataUrl(img.data_url); + if (normalized) { + valid.push({ + data_url: normalized.trimmedUrl, + mime_type: normalized.mimeType, + name: typeof img.name === 'string' && img.name ? img.name : 'image', + }); + } + } + } catch { + // Ignore corrupted attachment items. + } + } + if (valid.length === 0) return undefined; + return valid.map((item, idx) => ({ + id: `${prefix}_${Date.now()}_${idx}`, + data_url: item.data_url, + mime_type: item.mime_type, + metadata: { name: item.name, source: 'remote' }, + })); +} + +/** Project valid transcript images into composer attachments. */ +export function extractValidPendingImages(images: unknown): PendingChatImage[] { + if (!Array.isArray(images) || images.length === 0) return []; + const result: PendingChatImage[] = []; + for (const img of images) { + try { + if (img && typeof img === 'object') { + const normalized = normalizeValidImageDataUrl(img.data_url); + if (normalized) { + result.push({ + name: typeof img.name === 'string' && img.name ? img.name : 'image', + dataUrl: normalized.trimmedUrl, + }); + } + } + } catch { + // Ignore corrupted attachment items. + } + } + return result; +} diff --git a/src/mobile-web/tests/browser-account.test.mjs b/src/mobile-web/tests/browser-account.test.mjs index c72e3ebff5..e600a1ac96 100644 --- a/src/mobile-web/tests/browser-account.test.mjs +++ b/src/mobile-web/tests/browser-account.test.mjs @@ -9,7 +9,23 @@ import { LAN, OFFICIAL, RelayFixture, connected, disconnect, invitation, launchB let browser; let source; before(async () => { source = await startSourceServer(); browser = await launchBrowser(); }); -after(async () => { await browser?.close(); await source?.close(); }); +after(async () => { + // Bound cleanup so a stuck browser or dev server cannot hang the runner, + // but never force the process exit: the test runner still owns the exit + // code and a forced zero would mask failed assertions. + try { + await Promise.race([ + browser?.close(), + new Promise(resolve => setTimeout(resolve, 3000)), + ]); + } catch {} + try { + await Promise.race([ + source?.close(), + new Promise(resolve => setTimeout(resolve, 3000)), + ]); + } catch {} +}); for (const endpoint of [LAN, OFFICIAL]) { test(`one login serves fresh tabs and reloads with one connected browser: ${endpoint}`, { timeout: 40_000 }, async () => { @@ -185,7 +201,10 @@ test('a closed and reopened browser profile retains its login and controller key assert.equal(relay.logins.length, 1); assert.equal(relay.clients.size, 1); assert.deepEqual(relay.errors, []); - } finally { await persistent?.close(); await rm(profile, { recursive: true, force: true }); } + } finally { + await persistent?.close(); + try { await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {} + } }); test('legacy tab credentials migrate with their keys and navigation without reviving a signed-out account', { timeout: 40_000 }, async () => { diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs index 0e0cf5004d..b81b9bc9f3 100644 --- a/src/mobile-web/tests/helpers/browser-account-harness.mjs +++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs @@ -44,8 +44,13 @@ export async function launchBrowser(options = {}) { '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}/Google/Chrome/Application/chrome.exe`, + process.env['PROGRAMFILES(X86)'] && `${process.env['PROGRAMFILES(X86)']}/Google/Chrome/Application/chrome.exe`, + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}/Google/Chrome/Application/chrome.exe`, + process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}/Microsoft/Edge/Application/msedge.exe`, + process.env['PROGRAMFILES(X86)'] && `${process.env['PROGRAMFILES(X86)']}/Microsoft/Edge/Application/msedge.exe`, + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}/Microsoft/Edge/Application/msedge.exe`, ].find(path => path && existsSync(path)); - assert.ok(executablePath, 'Set PUPPETEER_EXECUTABLE_PATH to an installed Chrome/Chromium browser.'); + assert.ok(executablePath, 'Set PUPPETEER_EXECUTABLE_PATH to an installed Chrome, Chromium, or Edge browser.'); return puppeteer.launch({ executablePath, headless: 'new', // Fixture pages use remote-looking origins while their simulated Relay is loopback. args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', @@ -64,7 +69,7 @@ export async function startSourceServer() { sockets.handleUpgrade(request, socket, head, ws => fixture.socket(ws, url.searchParams.get('endpoint'))); }); await vite.listen(); - return { origin: `http://127.0.0.1:${vite.httpServer.address().port}`, close: async () => { for (const socket of sockets.clients) socket.terminate(); sockets.close(); realtimeFixtures.clear(); await vite.close(); } }; + return { origin: `http://127.0.0.1:${vite.httpServer.address().port}`, close: async () => { for (const socket of sockets.clients) socket.terminate(); sockets.close(); realtimeFixtures.clear(); vite.httpServer?.closeAllConnections?.(); await vite.close(); } }; } /** Real browser fetches and encrypted RPC envelopes; only Relay/host IO is simulated. */ diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index e73433c7a1..31274dfed0 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs'; -const modulePath = '/@fs' + fileURLToPath(new URL('../../shared/dialog-queue/HostDialogQueue.ts', import.meta.url)); +const modulePath = '/@fs/' + fileURLToPath(new URL('../../shared/dialog-queue/HostDialogQueue.ts', import.meta.url)).replace(/\\/g, '/').replace(/^\/+/, ''); test('real IndexedDB retains an ambiguous submission after closing the browser page', {timeout:60000}, async () => { const server=await startSourceServer();const browser=await launchBrowser(); @@ -50,7 +52,7 @@ test('mobile running composer keeps send and stop independently available alongs assert.ok((await page.evaluate(()=>window.queueFixture.calls)).includes('send')); assert.ok(!(await page.evaluate(()=>window.queueFixture.calls)).includes('stop')); assert.equal(await page.$eval('body', body=>body.scrollWidth<=window.innerWidth),true); - await page.screenshot({path:'/tmp/mobile-host-message-queue.png',fullPage:true}); + await page.screenshot({path:path.join(tmpdir(),'mobile-host-message-queue.png'),fullPage:true}); }finally{await browser.close();await server.close();} }); @@ -113,7 +115,7 @@ test('queue stays above the measured composer across phone, keyboard-height and assert.equal(geometry.plusWidth,44); for(const edge of ['top','bottom','left']) assert.ok(Math.abs(geometry[edge]-geometry.right)<1,JSON.stringify(geometry)); assert.ok(Math.abs(geometry.textLeft-geometry.iconLeft)<1,JSON.stringify(geometry)); - await page.screenshot({path:'/tmp/mobile-queue-aligned.png',fullPage:true}); + await page.screenshot({path:path.join(tmpdir(),'mobile-queue-aligned.png'),fullPage:true}); await page.click('[aria-label="排队消息说明"]'); await page.waitForSelector('.host-message-queue__help'); await page.click('[aria-label="排队消息说明"]'); diff --git a/src/mobile-web/tests/host-stream.test.mjs b/src/mobile-web/tests/host-stream.test.mjs index 4d4cc239c4..c7917842b7 100644 --- a/src/mobile-web/tests/host-stream.test.mjs +++ b/src/mobile-web/tests/host-stream.test.mjs @@ -273,3 +273,35 @@ test('formal steering turns render once with stable message ownership after hist assert.equal(live[3].content,'New answer'); assert.deepEqual(render([...records].reverse().concat(records)),live,'newest-first reconnect and duplicate replay retain the same transcript'); }); + + +test('streamed user turns project attachment pixels for transcript and draft restore', async () => { + async function load(path) { + const source = await readFile(new URL(path, import.meta.url), 'utf8'); + const compiled = ts.transpileModule(source, {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; + return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); + } + const {presentSessionTurn} = await load('../src/services/SessionRecordPresentation.ts'); + const metadata = {images: [ + {id: 'img-1', name: 'screen.png', mime_type: 'image/png', data_url: ' data:image/png;base64,YQ== '}, + {id: 'img-2', name: 'path-only.png', mime_type: 'image/png'}, + null, + {data_url: 'data:image/jpeg;base64,YWI='}, + ]}; + const [user] = presentSessionTurn({ + turnId: 'turn-a', turnIndex: 3, status: 'completed', timestamp: 1, + userMessage: {id: 'user-a', content: '', timestamp: 1, metadata}, + modelRounds: [], + }); + assert.deepEqual(user.images, [ + {name: 'screen.png', data_url: 'data:image/png;base64,YQ=='}, + {name: 'image', data_url: 'data:image/jpeg;base64,YWI='}, + ], 'only attachments whose pixels arrived reach the composer'); + assert.equal(user.metadata, metadata, 'protocol metadata stays intact for other projections'); + const [withoutImages] = presentSessionTurn({ + turnId: 'turn-b', turnIndex: 4, status: 'completed', timestamp: 2, + userMessage: {id: 'user-b', content: 'hello', timestamp: 2}, + modelRounds: [], + }); + assert.equal(withoutImages.images, undefined); +}); diff --git a/src/mobile-web/tests/image-compressor.test.mjs b/src/mobile-web/tests/image-compressor.test.mjs index 4826a12349..0d8f941d07 100644 --- a/src/mobile-web/tests/image-compressor.test.mjs +++ b/src/mobile-web/tests/image-compressor.test.mjs @@ -56,3 +56,87 @@ test('unreadable photos fail before upload instead of using raw bytes as a fallb browserImages({ invalid: true }); await assert.rejects(compressImageDataUrl('data:image/heic;base64,YQ==', 'camera.heic')); }); + +const attachmentsSource = await readFile( + new URL('../src/services/chatImageAttachments.ts', import.meta.url), + 'utf8', +); +const attachmentsCode = ts.transpileModule(attachmentsSource, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const { + normalizeValidImageDataUrl, + extractValidImageContexts, + extractValidPendingImages, +} = await import(`data:text/javascript;base64,${Buffer.from(attachmentsCode).toString('base64')}`); + +test('image data URLs must use canonical RFC 4648 base64', () => { + assert.deepEqual(normalizeValidImageDataUrl('data:image/png;base64,YQ=='), { + trimmedUrl: 'data:image/png;base64,YQ==', + mimeType: 'image/png', + }); + assert.deepEqual(normalizeValidImageDataUrl(' data:image/jpeg;base64,YWI= '), { + trimmedUrl: 'data:image/jpeg;base64,YWI=', + mimeType: 'image/jpeg', + }); + assert.equal(normalizeValidImageDataUrl('data:image/webp;base64,YWJj')?.mimeType, 'image/webp'); + assert.equal(normalizeValidImageDataUrl('data:image/svg+xml;base64,PHN2Zy8+')?.mimeType, 'image/svg+xml'); + + for (const invalid of [ + undefined, + null, + 42, + '', + 'data:image/png;base64,', + 'data:image/png;base64,YQ', + 'data:image/png;base64,YR==', // non-zero padding bits + 'data:image/png;base64,YQ=', + 'data:image/png;base64,Y Q==', + 'data:text/plain;base64,YQ==', + 'data:image/png,YQ==', + 'data:image/png;base64,YQ--', + ]) { + assert.equal(normalizeValidImageDataUrl(invalid), null, String(invalid)); + } +}); + +test('transcript images project into model contexts without corrupted entries', () => { + const images = [ + { name: 'screen.png', data_url: 'data:image/png;base64,YQ==' }, + { name: '', data_url: ' data:image/jpeg;base64,YWI= ' }, + { name: 'broken.png', data_url: 'data:image/png;base64,YR==' }, + null, + 'not-an-object', + { name: 'missing.png' }, + ]; + const contexts = extractValidImageContexts(images, 'mobile_edit'); + assert.deepEqual(contexts?.map(({ id, ...rest }) => rest), [ + { + data_url: 'data:image/png;base64,YQ==', + mime_type: 'image/png', + metadata: { name: 'screen.png', source: 'remote' }, + }, + { + data_url: 'data:image/jpeg;base64,YWI=', + mime_type: 'image/jpeg', + metadata: { name: 'image', source: 'remote' }, + }, + ]); + assert.match(contexts[0].id, /^mobile_edit_\d+_0$/); + assert.match(contexts[1].id, /^mobile_edit_\d+_1$/); + assert.equal(extractValidImageContexts([], 'mobile_edit'), undefined); + assert.equal(extractValidImageContexts([{ data_url: 'data:image/png;base64,YR==' }], 'x'), undefined); +}); + +test('rollback drafts restore only valid images', () => { + assert.deepEqual( + extractValidPendingImages([ + { name: 'screen.png', data_url: 'data:image/png;base64,YQ==' }, + { name: 'broken.png', data_url: 'data:image/png;base64,YR==' }, + null, + ]), + [{ name: 'screen.png', dataUrl: 'data:image/png;base64,YQ==' }], + ); + assert.deepEqual(extractValidPendingImages(undefined), []); + assert.deepEqual(extractValidPendingImages('not-an-array'), []); +}); diff --git a/src/mobile-web/tests/session-stream-browser.test.mjs b/src/mobile-web/tests/session-stream-browser.test.mjs index 63e1af4d01..c0858ab15f 100644 --- a/src/mobile-web/tests/session-stream-browser.test.mjs +++ b/src/mobile-web/tests/session-stream-browser.test.mjs @@ -84,13 +84,13 @@ test('an older host without host streams is reported as unsupported instead of r const relay=new RelayFixture();relay.legacyHost=true; const page=await relay.page(context,source.origin); await signIn(page);await connected(page); - const module=fileURLToPath(new URL('../../shared/relay-transport/HostStream.ts',import.meta.url)); + const module='/@fs/'+fileURLToPath(new URL('../../shared/relay-transport/HostStream.ts',import.meta.url)).replace(/\\/g, '/').replace(/^\/+/, ''); const result=await page.evaluate(async path=>{ const {getBrowserAccountStore,releaseBrowserAccount}=await import('/src/services/BrowserAccountStore.ts'); const {currentRelayUrl}=await import('/src/services/pairingLink.ts'); const {RelayHttpClient}=await import('/src/services/RelayHttpClient.ts'); const {RemoteSessionManager}=await import('/src/services/RemoteSessionManager.ts'); - const {UNSUPPORTED_HOST_MESSAGE}=await import('/@fs'+path); + const {UNSUPPORTED_HOST_MESSAGE}=await import(path); const saved=await getBrowserAccountStore(currentRelayUrl()).read(); const client=new RelayHttpClient(currentRelayUrl(),{token:saved.session.token,masterKey:saved.session.masterKey,userId:saved.session.userId,deviceId:saved.controllerDeviceId}); releaseBrowserAccount(saved); diff --git a/src/mobile-web/tests/workspace-identity.test.mjs b/src/mobile-web/tests/workspace-identity.test.mjs index 459ec659c4..0b11719f0e 100644 --- a/src/mobile-web/tests/workspace-identity.test.mjs +++ b/src/mobile-web/tests/workspace-identity.test.mjs @@ -108,6 +108,7 @@ const controlIdentity = await moduleUrl('../src/services/controlClientIdentity.t const hostStream = await moduleUrl('../../shared/relay-transport/HostStream.ts'); const managerUrl = await moduleUrl('../src/services/RemoteSessionManager.ts', { '../../../shared/agent-harness/wire': agentWire, + '../../../shared/dialog-queue/HostDialogQueue': 'data:text/javascript,export class HostDialogQueue {}', '../../../shared/relay-transport/HostStream': hostStream, './controlClientIdentity': controlIdentity, './SessionSynchronizer': 'data:text/javascript,export class SessionSynchronizer {}', diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 45ebc501a9..b2b49da9d3 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -27,8 +27,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "type-check": "tsc --noEmit", - "test": "vitest", - "test:run": "vitest run --maxWorkers=50%" + "test": "node scripts/run-vitest.mjs", + "test:run": "node scripts/run-vitest.mjs run --maxWorkers=50%" }, "dependencies": { "@openbitfun/flow-chat-presentation": "workspace:*", diff --git a/src/web-ui/scripts/run-vitest.mjs b/src/web-ui/scripts/run-vitest.mjs new file mode 100644 index 0000000000..654b7429e5 --- /dev/null +++ b/src/web-ui/scripts/run-vitest.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// Node 25+ exposes its own webstorage globals, which shadow jsdom's +// localStorage/sessionStorage in Vitest environments. Append the disable flag +// instead of replacing NODE_OPTIONS so the caller's heap or tracing flags stay +// intact. +const WEBSTORAGE_FLAG = '--no-experimental-webstorage'; +const existing = (process.env.NODE_OPTIONS ?? '').trim(); +if (!existing.split(/\s+/).includes(WEBSTORAGE_FLAG)) { + process.env.NODE_OPTIONS = [existing, WEBSTORAGE_FLAG].filter(Boolean).join(' '); +} + +const require = createRequire(import.meta.url); +const vitestPackagePath = require.resolve('vitest/package.json'); +const vitestBin = path.join( + path.dirname(vitestPackagePath), + require('vitest/package.json').bin.vitest, +); +const child = spawn(process.execPath, [vitestBin, ...process.argv.slice(2)], { + env: process.env, + stdio: 'inherit', +}); +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +});