From 351bb9cc528dc3e82370eda70b601ca2b8ae7eaa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:02:29 -0700 Subject: [PATCH] fix(mobile): stabilize localized error and key handling --- mobile/app/h/[hostId]/accounts.tsx | 3 +- mobile/src/components/AccountUsage.tsx | 1 + mobile/src/components/CustomKeyModal.tsx | 7 ++- mobile/src/components/account-usage-state.ts | 1 + .../src/components/accounts-snapshot.test.ts | 26 ++++++++- mobile/src/components/accounts-snapshot.ts | 13 ++++- mobile/src/i18n/mobile-i18n.test.ts | 19 +++++++ .../session/mobile-clipboard-image.test.ts | 17 ++++++ mobile/src/session/mobile-clipboard-image.ts | 3 +- .../session/use-mobile-image-attachment.ts | 7 +-- ...se-mobile-native-chat-image-attachments.ts | 8 +-- .../src/session/use-mobile-terminal-paste.ts | 3 +- .../terminal-accessory-key-catalog.ts | 30 +++++----- .../src/terminal/terminal-accessory-keys.ts | 57 ++++++++++--------- src/shared/clipboard-image.test.ts | 8 ++- src/shared/clipboard-image.ts | 4 ++ .../terminal-cold-park-resource-bench.mjs | 6 +- .../terminal-cold-park-reveal-bench.mjs | 6 +- 18 files changed, 151 insertions(+), 68 deletions(-) diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 152f463edcc..1cbdaa5d69e 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -21,6 +21,7 @@ import { type AccountsSnapshot, type ProviderKey, decodeAccountsSnapshot, + isInvalidAccountsSnapshotError, getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, @@ -142,7 +143,7 @@ export default function AccountsScreen() { setError(res.error.message) } } catch (e) { - if (e instanceof Error && e.message === 'Invalid accounts snapshot from host') { + if (isInvalidAccountsSnapshotError(e)) { rejectInvalidSnapshot() } else { setError(e instanceof Error ? e.message : String(e)) diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 3b1115fed2f..f87bb9352be 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -15,6 +15,7 @@ export type { } from './account-usage-state' export { decodeAccountsSnapshot, + isInvalidAccountsSnapshotError, getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 5090abc9cc6..6982a2abec3 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -8,6 +8,7 @@ import { buildTerminalShortcutKey, normalizeShortcutKeyInput, TERMINAL_SHORTCUT_SPECIAL_KEYS, + TERMINAL_SHORTCUT_MODIFIER_LABELS, type TerminalShortcutModifier, type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' @@ -60,9 +61,9 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc const [macroEnter, setMacroEnter] = useState(true) const [previousVisible, setPreviousVisible] = useState(visible) const shortcutModifierOptions = [ - { id: 'ctrl', label: t('m.OZLCEBo') }, - { id: 'alt', label: t('m.Mz-qIO4'), glyph: '⌥' }, - { id: 'shift', label: t('m.Dl9mAnI') } + { id: 'ctrl', label: TERMINAL_SHORTCUT_MODIFIER_LABELS.ctrl }, + { id: 'alt', label: TERMINAL_SHORTCUT_MODIFIER_LABELS.alt, glyph: '⌥' }, + { id: 'shift', label: TERMINAL_SHORTCUT_MODIFIER_LABELS.shift } ] satisfies readonly { id: TerminalShortcutModifier; label: string; glyph?: string }[] const specialKeyGroups: { title: string; ids: string[]; columns: number }[] = [ { diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index 3987364a0f9..bc6f81e5e36 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -15,6 +15,7 @@ import type { export { AccountsSnapshotSchema, decodeAccountsSnapshot, + isInvalidAccountsSnapshotError, ProviderRateLimitsSchema, RateLimitRuntimeTargetSchema, type AccountsSnapshot, diff --git a/mobile/src/components/accounts-snapshot.test.ts b/mobile/src/components/accounts-snapshot.test.ts index 89e0bf84934..3bf390cc3c3 100644 --- a/mobile/src/components/accounts-snapshot.test.ts +++ b/mobile/src/components/accounts-snapshot.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' -import { decodeAccountsSnapshot } from './accounts-snapshot' +import { mobileI18n } from '../i18n/mobile-i18n' +import { decodeAccountsSnapshot, isInvalidAccountsSnapshotError } from './accounts-snapshot' + +const INITIAL_LOCALE = mobileI18n.language + +afterEach(async () => { + await mobileI18n.changeLanguage(INITIAL_LOCALE) +}) function makeSnapshot(): unknown { return { @@ -81,6 +88,21 @@ function setPath(root: unknown, path: string[], value: unknown): void { } describe('decodeAccountsSnapshot', () => { + it('classifies an invalid snapshot independently of its localized message', async () => { + await mobileI18n.changeLanguage('es') + + let thrown: unknown + try { + decodeAccountsSnapshot(null) + } catch (error) { + thrown = error + } + + expect(isInvalidAccountsSnapshotError(thrown)).toBe(true) + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).not.toBe('Invalid accounts snapshot from host') + }) + it('validates nested account/rate-limit state and preserves forward-compatible fields', () => { const snapshot = decodeAccountsSnapshot(makeSnapshot()) diff --git a/mobile/src/components/accounts-snapshot.ts b/mobile/src/components/accounts-snapshot.ts index b38d79ff887..05c7dd11e1a 100644 --- a/mobile/src/components/accounts-snapshot.ts +++ b/mobile/src/components/accounts-snapshot.ts @@ -228,10 +228,21 @@ export type ClaudeAccountSummary = z.infer export type CodexAccountSummary = z.infer export type AccountsSnapshot = z.infer +export class InvalidAccountsSnapshotError extends Error { + constructor() { + super(t('m.l0xDyns')) + this.name = 'InvalidAccountsSnapshotError' + } +} + +export function isInvalidAccountsSnapshotError(error: unknown): boolean { + return error instanceof InvalidAccountsSnapshotError +} + export function decodeAccountsSnapshot(value: unknown): AccountsSnapshot { const result = AccountsSnapshotSchema.safeParse(value) if (!result.success) { - throw new Error(t('m.l0xDyns')) + throw new InvalidAccountsSnapshotError() } return result.data } diff --git a/mobile/src/i18n/mobile-i18n.test.ts b/mobile/src/i18n/mobile-i18n.test.ts index 18329525d11..3537cd377c7 100644 --- a/mobile/src/i18n/mobile-i18n.test.ts +++ b/mobile/src/i18n/mobile-i18n.test.ts @@ -71,6 +71,25 @@ describe('mobile i18n', () => { ]) }) + it('keeps generated terminal shortcut labels canonical in translated locales', async () => { + await mobileI18n.changeLanguage('zh') + const { + buildTerminalShortcutKey, + TERMINAL_SHORTCUT_MODIFIER_LABELS, + TERMINAL_SHORTCUT_SPECIAL_KEYS + } = await import('../terminal/terminal-accessory-keys') + + expect(TERMINAL_SHORTCUT_MODIFIER_LABELS).toEqual({ + ctrl: 'Ctrl', + alt: 'Alt', + shift: 'Shift' + }) + expect(TERMINAL_SHORTCUT_SPECIAL_KEYS.find((key) => key.id === 'pageDown')?.label).toBe('PgDn') + expect(buildTerminalShortcutKey({ key: 'pageDown', modifiers: ['shift'] })?.label).toBe( + 'Shift+PgDn' + ) + }) + it.each([ ['es', ['Sin comprobaciones', 'En staging', 'Nota de revisión']], ['ja', ['チェックなし', 'ステージ済み', 'レビューメモ']], diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts index cc563127631..58f960f77e3 100644 --- a/mobile/src/session/mobile-clipboard-image.test.ts +++ b/mobile/src/session/mobile-clipboard-image.test.ts @@ -10,6 +10,8 @@ import { import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' +import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image' +import { mobileI18n } from '../i18n/mobile-i18n' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -37,6 +39,21 @@ function clientWithResponses(responses: RpcResponse[]): Pick { + it('keeps the oversized-image classifier stable in a translated locale', async () => { + const initialLocale = mobileI18n.language + await mobileI18n.changeLanguage('es') + let thrown: unknown + try { + normalizeMobileClipboardImageBase64('a'.repeat(24 * 1024 * 1024 + 1)) + } catch (error) { + thrown = error + } finally { + await mobileI18n.changeLanguage(initialLocale) + } + + expect(isClipboardImageTooLargeError(thrown)).toBe(true) + }) + it('strips data URL image prefixes', () => { expect(normalizeMobileClipboardImageBase64('data:image/png;base64,aGVsbG8=')).toBe('aGVsbG8=') }) diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts index d3e60a6f755..ce03c4d9911 100644 --- a/mobile/src/session/mobile-clipboard-image.ts +++ b/mobile/src/session/mobile-clipboard-image.ts @@ -1,6 +1,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcFailure, RpcSuccess } from '../transport/types' +import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' import { t } from '@/i18n/mobile-i18n' export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024 @@ -18,7 +19,7 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ export function normalizeMobileClipboardImageBase64(data: string): string { const contentBase64 = data.replace(DATA_URL_PREFIX_RE, '') if (contentBase64.length > MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS) { - throw new Error(t('m.Oht8bkc')) + throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) } if (contentBase64.length % 4 === 1 || !BASE64_PATTERN.test(contentBase64)) { throw new Error(t('m.64b63WI')) diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts index 2b63fc5aa1f..b01fe9f6bb2 100644 --- a/mobile/src/session/use-mobile-image-attachment.ts +++ b/mobile/src/session/use-mobile-image-attachment.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' +import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image' import { attachMobileImageToTerminal } from './mobile-image-attachment' import { ImageLibraryPermissionError, @@ -35,10 +36,6 @@ type MobileImageAttachment = { readonly isAttaching: boolean } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - export function useMobileImageAttachment({ client, activeHandle, @@ -81,7 +78,7 @@ export function useMobileImageAttachment({ showToast(t('m.JdGd1Ic'), 1500) return } - if (getErrorMessage(error) === 'Clipboard image is too large') { + if (isClipboardImageTooLargeError(error)) { showToast(t('m.hJYA7AQ'), 1500) return } diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 12d7b20178f..52b3678ba10 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useState } from 'react' -import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' +import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -79,10 +79,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -174,7 +170,7 @@ export function useMobileNativeChatImageAttachments({ showToast(t('m.jdkmIaw'), 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (isClipboardImageTooLargeError(error)) { showToast(t('m.-YjNIcU'), 1500) return } diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts index 4a632ea5727..8c1437dcf08 100644 --- a/mobile/src/session/use-mobile-terminal-paste.ts +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -5,6 +5,7 @@ import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' import type { TerminalModes } from '../terminal/terminal-webview-contract' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' +import { isClipboardImageTooLargeError } from '../../../src/shared/clipboard-image' import { buildMobileImagePastePayload, prepareMobileClipboardImageBase64, @@ -174,7 +175,7 @@ export function useMobileTerminalPaste({ console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) if (isDisconnected) { showToast(t('m.gG0saX0'), 1500) - } else if (err.message === 'Clipboard image is too large') { + } else if (isClipboardImageTooLargeError(e)) { showToast(t('m.5dhlsXo'), 1500) } else { showToast(t('m.weTQMew'), 1500) diff --git a/mobile/src/terminal/terminal-accessory-key-catalog.ts b/mobile/src/terminal/terminal-accessory-key-catalog.ts index 83c04f7ccf3..edce2d8208a 100644 --- a/mobile/src/terminal/terminal-accessory-key-catalog.ts +++ b/mobile/src/terminal/terminal-accessory-key-catalog.ts @@ -2,12 +2,12 @@ import { t } from '@/i18n/mobile-i18n' import type { TerminalAccessoryKey } from './terminal-accessory-keys' export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ - { id: 'escape', label: t('m.bNejRmQ'), bytes: '\x1b', accessibilityLabel: t('m.jHe6UUU') }, - { id: 'tab', label: t('m.hM5KUAw'), bytes: '\t', accessibilityLabel: t('m.hM5KUAw') }, - { id: 'enter', label: t('m.9KFo5zM'), bytes: '\r', accessibilityLabel: t('m.9KFo5zM') }, + { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: t('m.jHe6UUU') }, + { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: t('m.hM5KUAw') }, + { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: t('m.9KFo5zM') }, // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. - { id: 'shiftTab', label: t('m.S9DH8y4'), bytes: '\x1b[Z', accessibilityLabel: t('m.p7_d2FU') }, - { id: 'space', label: t('m.l6KJT3M'), bytes: ' ', accessibilityLabel: t('m.l6KJT3M') }, + { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: t('m.p7_d2FU') }, + { id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: t('m.l6KJT3M') }, { id: 'backspace', label: '⌫', @@ -17,7 +17,7 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ }, { id: 'delete', - label: t('m.gmy3A7M'), + label: 'Del', bytes: '\x1b[3~', accessibilityLabel: t('m.fNgDk9o'), repeatable: true @@ -50,13 +50,13 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ accessibilityLabel: t('m.v5WSGO0'), repeatable: true }, - { id: 'ctrlC', label: t('m.ke03J5M'), bytes: '\x03', accessibilityLabel: t('m.AzHoQzM') }, - { id: 'ctrlD', label: t('m.vFYSo0s'), bytes: '\x04', accessibilityLabel: t('m.PBPnPeQ') }, - { id: 'ctrlL', label: t('m.R6ZcIz8'), bytes: '\x0c', accessibilityLabel: t('m.Eqaj8oY') }, - { id: 'ctrlZ', label: t('m.NIdsbYo'), bytes: '\x1a', accessibilityLabel: t('m.hqWfNmA') }, - { id: 'ctrlR', label: t('m.a2Cb3GE'), bytes: '\x12', accessibilityLabel: t('m.mcvHf4o') }, - { id: 'ctrlA', label: t('m.u9q9lPQ'), bytes: '\x01', accessibilityLabel: t('m.CKFp_QM') }, - { id: 'ctrlE', label: t('m.5m_ucwE'), bytes: '\x05', accessibilityLabel: t('m.0VWtVO8') }, - { id: 'ctrlW', label: t('m.jVVj75Y'), bytes: '\x17', accessibilityLabel: t('m.Bd9qfdk') }, - { id: 'ctrlU', label: t('m.sL5fKGQ'), bytes: '\x15', accessibilityLabel: t('m.HrWsdDM') } + { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: t('m.AzHoQzM') }, + { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: t('m.PBPnPeQ') }, + { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: t('m.Eqaj8oY') }, + { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: t('m.hqWfNmA') }, + { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: t('m.mcvHf4o') }, + { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: t('m.CKFp_QM') }, + { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: t('m.0VWtVO8') }, + { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: t('m.Bd9qfdk') }, + { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: t('m.HrWsdDM') } ] diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 860258f92d1..bae3a4dfa03 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -29,11 +29,12 @@ export type TerminalShortcutSpecialKey = { const ESC = '\x1b' -const MODIFIER_LABELS: Record = { - ctrl: t('m.mthhwXw'), - alt: t('m.zN_6g9g'), - shift: t('m.lCZrWn4') -} +export const TERMINAL_SHORTCUT_MODIFIER_LABELS: Readonly> = + { + ctrl: 'Ctrl', + alt: 'Alt', + shift: 'Shift' + } const MODIFIER_ORDER: TerminalShortcutModifier[] = ['ctrl', 'alt', 'shift'] @@ -78,33 +79,33 @@ const CTRL_PRINTABLE_BYTES: Record = { } const SPECIAL_KEY_LABELS: Record = { - escape: t('m.bNejRmQ'), - tab: t('m.hM5KUAw'), - enter: t('m.9KFo5zM'), + escape: 'Esc', + tab: 'Tab', + enter: 'Enter', backspace: '⌫', - delete: t('m.gmy3A7M'), - insert: t('m.bh9E9Gk'), + delete: 'Del', + insert: 'Ins', arrowUp: '↑', arrowDown: '↓', arrowLeft: '←', arrowRight: '→', - home: t('m.r2L-IvQ'), - end: t('m.6UajMWQ'), - pageUp: t('m.1qkhXPI'), - pageDown: t('m.Wt9qvqo'), - space: t('m.l6KJT3M'), - f1: t('m.wPNSaMQ'), - f2: t('m.5OQeDMc'), - f3: t('m.fNbslzw'), - f4: t('m.dMpOhHI'), - f5: t('m.MmdqLFU'), - f6: t('m.xOj4ngY'), - f7: t('m.EbNp-x4'), - f8: t('m.DR9i7SU'), - f9: t('m.rI3MEx0'), - f10: t('m.d6orzBg'), - f11: t('m.T7RTOC4'), - f12: t('m._dDm4ic') + home: 'Home', + end: 'End', + pageUp: 'PgUp', + pageDown: 'PgDn', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' } const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record = { @@ -350,7 +351,7 @@ function isPrintableShortcutKey(key: string): boolean { } function formatShortcutLabel(key: string, modifiers: TerminalShortcutModifier[]): string { - const modifierLabels = modifiers.map((modifier) => MODIFIER_LABELS[modifier]) + const modifierLabels = modifiers.map((modifier) => TERMINAL_SHORTCUT_MODIFIER_LABELS[modifier]) return [...modifierLabels, displayKeyLabel(key)].join('+') } diff --git a/src/shared/clipboard-image.test.ts b/src/shared/clipboard-image.test.ts index 47b8d1978ae..3ce0c09114a 100644 --- a/src/shared/clipboard-image.test.ts +++ b/src/shared/clipboard-image.test.ts @@ -5,7 +5,8 @@ import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, assertClipboardImageBase64LengthWithinLimit, assertClipboardImageByteLengthWithinLimit, - assertClipboardImageDimensionsWithinLimit + assertClipboardImageDimensionsWithinLimit, + isClipboardImageTooLargeError } from './clipboard-image' describe('clipboard image limits', () => { @@ -28,6 +29,11 @@ describe('clipboard image limits', () => { ).toThrow('Clipboard image is too large') }) + it('classifies only the stable internal oversized-image sentinel', () => { + expect(isClipboardImageTooLargeError(new Error('Clipboard image is too large'))).toBe(true) + expect(isClipboardImageTooLargeError(new Error('La imagen es demasiado grande'))).toBe(false) + }) + it('rejects invalid or oversized image dimensions before pixel allocation', () => { expect(() => assertClipboardImageDimensionsWithinLimit({ diff --git a/src/shared/clipboard-image.ts b/src/shared/clipboard-image.ts index 092467a9de2..31acfa9b81a 100644 --- a/src/shared/clipboard-image.ts +++ b/src/shared/clipboard-image.ts @@ -5,6 +5,10 @@ export const CLIPBOARD_IMAGE_MAX_SOURCE_BYTES = Math.floor( export const CLIPBOARD_IMAGE_MAX_PIXELS = 32 * 1024 * 1024 export const CLIPBOARD_IMAGE_TOO_LARGE_ERROR = 'Clipboard image is too large' +export function isClipboardImageTooLargeError(error: unknown): boolean { + return error instanceof Error && error.message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR +} + export type ClipboardImageDimensions = { height: number width: number diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index 08d1cdd69b9..63b7222a93d 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -28,14 +28,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs } from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const PARK_DELAY_MS = 1_500 diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index 05b2f80bce0..a01cbf2cc3a 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -34,14 +34,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs } from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const scenarioTimeoutMs = 300_000