fix(mobile): close exact-head review findings

This commit is contained in:
Brennan Benson
2026-08-01 04:19:15 -07:00
parent ad499b8a69
commit 92fcb73f8a
9 changed files with 158 additions and 25 deletions
@@ -5,8 +5,7 @@ const EMBEDDED_DOCUMENT_FILE_RE = /(?:-html|webview-html)\.[cm]?[jt]sx?$/
const USER_VISIBLE_ATTRIBUTE_RE =
/\b(aria-description|aria-label|alt|data-placeholder|placeholder|title)\s*=\s*(["'])(.*?)\2/gi
const USER_VISIBLE_PROMPT_RE = /\b(?:window\.)?(alert|confirm|prompt)\(\s*(["'])(.*?)\2/g
const INSERTED_HTML_RE =
/\bexecCommand\(\s*(["'])insertHTML\1\s*,\s*false\s*,\s*(["'])([\s\S]*?)\2\s*\)/g
const INSERTED_HTML_CALL_RE = /\bexecCommand\(\s*(["'])insertHTML\1\s*,\s*false\s*,/g
function maskNonMarkupContent(documentText) {
return documentText.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, (value) =>
@@ -48,6 +47,99 @@ function collectMarkupCandidates(markup, baseOffset, candidates) {
}
}
function collectMarkupFragmentCandidates(markup, baseOffset, candidates) {
collectMarkupCandidates(markup, baseOffset, candidates)
for (const match of [markup.match(/^([^<]+)</), markup.match(/>([^<]+)$/)]) {
if (!match) {
continue
}
const value = match[1]
const start = baseOffset + capturedOffset(match, value)
candidates.push({
start,
end: start + value.length,
kind: 'embedded-html-text',
text: value,
dynamic: false
})
}
}
function skipQuotedString(source, start) {
const quote = source[start]
let cursor = start + 1
while (cursor < source.length) {
if (source[cursor] === '\\') {
cursor += 2
} else if (source[cursor] === quote) {
return cursor + 1
} else {
cursor += 1
}
}
return source.length
}
function skipTemplateInterpolation(source, start) {
let depth = 1
let cursor = start + 2
while (cursor < source.length && depth > 0) {
if (source[cursor] === '"' || source[cursor] === "'") {
cursor = skipQuotedString(source, cursor)
} else {
if (source[cursor] === '{') {
depth += 1
}
if (source[cursor] === '}') {
depth -= 1
}
cursor += 1
}
}
return cursor
}
function insertedHtmlArgumentEnd(source, start) {
let depth = 1
let cursor = start
while (cursor < source.length) {
if (source[cursor] === '"' || source[cursor] === "'") {
cursor = skipQuotedString(source, cursor)
continue
}
if (source[cursor] === '(') {
depth += 1
}
if (source[cursor] === ')') {
depth -= 1
if (depth === 0) {
return cursor
}
}
cursor += 1
}
return source.length
}
function collectInsertedHtmlCandidates(expression, baseOffset, candidates) {
let cursor = 0
while (cursor < expression.length) {
if (expression.startsWith('${', cursor)) {
cursor = skipTemplateInterpolation(expression, cursor)
continue
}
if (expression[cursor] === '"' || expression[cursor] === "'") {
const end = skipQuotedString(expression, cursor)
const markup = expression.slice(cursor + 1, end - 1)
collectMarkupFragmentCandidates(markup, baseOffset + cursor + 1, candidates)
cursor = end
continue
}
cursor += 1
}
}
export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) {
if (!EMBEDDED_DOCUMENT_FILE_RE.test(filePath)) {
return []
@@ -85,10 +177,14 @@ export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) {
})
}
for (const match of documentText.matchAll(INSERTED_HTML_RE)) {
const markup = match[3]
const markupOffset = baseOffset + capturedOffset(match, markup)
collectMarkupCandidates(markup, markupOffset, candidates)
for (const match of documentText.matchAll(INSERTED_HTML_CALL_RE)) {
const argumentStart = (match.index ?? 0) + match[0].length
const argumentEnd = insertedHtmlArgumentEnd(documentText, argumentStart)
collectInsertedHtmlCandidates(
documentText.slice(argumentStart, argumentEnd),
baseOffset + argumentStart,
candidates
)
}
return
}
@@ -21,7 +21,7 @@ export const HTML = \`<!doctype html>
<button>Copy</button><button>Select All</button>
<script>
window.prompt('Link URL');
document.execCommand('insertHTML', false, '<p>Task</p>');
document.execCommand('insertHTML', false, '<p>Task ' + localized + '</p><p>Follow up</p>');
</script>
\`
`
@@ -36,7 +36,8 @@ export const HTML = \`<!doctype html>
'Copy',
'Select All',
'Link URL',
'Task'
'Task',
'Follow up'
])
})
})
@@ -4,7 +4,7 @@ import { t } from '../i18n/mobile-i18n'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
return JSON.stringify(value).replaceAll('<', '\\u003c')
}
export function buildMobileRichMarkdownEditorHtml(): string {
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { buildMobileRichMarkdownEditorHtml } from '../components/mobile-rich-markdown-editor-html'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
} from '../components/mobile-rich-markdown-editor-html'
import { buildTerminalWebViewHtml } from '../terminal/terminal-webview-html'
import { escapeEmbeddedHtmlCopy } from './embedded-webview-copy'
import { mobileI18n } from './mobile-i18n'
@@ -32,6 +35,10 @@ describe('embedded WebView copy', () => {
expect(terminalHtml).toContain('id="sel-menu-all">Seleccionar todo</button>')
})
it('escapes script-state delimiters in translated JavaScript strings', () => {
expect(escapeInjectedJavaScriptString('<!--<script>')).toBe('"\\u003c!--\\u003cscript>"')
})
it('HTML-escapes rich-editor placeholders before insertHTML parses them', () => {
const codePlaceholder = mobileI18n.getResource(
'en',
+2 -2
View File
@@ -1739,7 +1739,7 @@
"qE1-68U": "Creando reseña...",
"JokOS5o": "Fuerza empujando con arrendamiento...",
"0BMXLfE": "Impulsando commits...",
"PnL-W2o": "Rama editorial...",
"PnL-W2o": "Publicando rama...",
"uN9IaYI": "Confirmando cambios...",
"us6yszU": "Generando mensaje de commit...",
"eCa7Me8": "Añadiendo cambios al staging...",
@@ -1778,7 +1778,7 @@
"7HjRY3c": "Commit",
"koj5rtA": "Force Push",
"63siVQ8": "Solicitud fallida: {{value0}}",
"8I2nZuQ": "No se puede actualizar",
"8I2nZuQ": "Omitido",
"Ba1jOdE": "Neutral",
"lyHptRM": "Tiempo agotado",
"thrHxG4": "Cancelado",
+9 -9
View File
@@ -145,7 +145,7 @@
"6N6POoY": "PR の作成",
"8fhgeQQ": "PR の準備中にブランチが変更されました。",
"zAg6hUc": "commit メッセージを生成できませんでした。ソース管理に追加して、再試行してください。",
"x1skfkY": "PR を作成する前に、変更を解決またはステージングします。",
"x1skfkY": "PR を作成する前に、変更を解決するかステージングしてください。",
"695pbL4": "PR を作成する前に競合を解決してください。",
"QeyCTxE": "プッシュに失敗しました。プッシュエラーを解決してから、再試行してください。",
"hjs2AB4": "Commits",
@@ -161,7 +161,7 @@
"s7LT0Wk": "agent の起動に失敗しました",
"Ngsl81c": "デスクトップを待っています...",
"h4RNhQ0": "commit メッセージの生成に失敗しました",
"IhhRQcI": "PR を作成する前にブランチをチェックアウトします。",
"IhhRQcI": "PR を作成する前にブランチをチェックアウトしてください。",
"8lyq50g": "モバイルでソース管理を使用できるように Orca デスクトップを更新します。",
"v0PMFRE": "比較のためのベース ブランチを解決できません。",
"xLlZko0": "デスクトップを待っています...",
@@ -1739,12 +1739,12 @@
"qE1-68U": "レビューを作成しています...",
"JokOS5o": "リースを使用して強制的にプッシュします...",
"0BMXLfE": "commits をプッシュしています...",
"PnL-W2o": "出版ブランチ...",
"PnL-W2o": "ブランチを公開しています...",
"uN9IaYI": "変更をコミットしています...",
"us6yszU": "commit メッセージを生成中...",
"eCa7Me8": "変更をステージしています...",
"Kdbzhtg": "このブランチはまだ {{value0}} の準備ができていません。",
"eeoaUzE": "{{value0}} を作成する前にベース ブランチをプッシュします。",
"eeoaUzE": "{{value0}} を作成する前にベース ブランチをプッシュしてください。",
"dhReg-4": "このフォークからの {{value0}} の作成はサポートされていません。",
"UNrSqqU": "このブランチには {{value0}} がすでに存在します。",
"CMzOREY": "この repo では {{value0}} の作成はサポートされていません。",
@@ -1758,8 +1758,8 @@
"nUw7_cE": "{{value0}} が作成されましたが、Orca はまだ更新できませんでした。",
"89Qhlf4": "{{value0}} #{{value1}} はすでにオープンしています。",
"G0fuKJo": "commit するものがありません。ブランチは最新です。",
"qgmf_L8": "押す",
"0OsPYDw": "引く",
"qgmf_L8": "プッシュ",
"0OsPYDw": "プル",
"gSinyKw": "リモート ブランチを更新するには、リースを使用して強制的にプッシュします。",
"5MUJYGA": "このブランチをオリジンに公開します。",
"5OKMsK4": "commits を公開する前にブランチをチェックアウトしてください。",
@@ -1778,7 +1778,7 @@
"7HjRY3c": "Commit",
"koj5rtA": "フォースプッシュ",
"63siVQ8": "リクエストが失敗しました: {{value0}}",
"8I2nZuQ": "更新できません",
"8I2nZuQ": "スキップ済み",
"Ba1jOdE": "中性",
"lyHptRM": "タイムアウト",
"thrHxG4": "キャンセル済み",
@@ -1825,10 +1825,10 @@
"N0HpMMg": "同期",
"26SoVIw": "同期 (↓{{value0}} ↑{{value1}})",
"Ia0aBfU": "何も引っ張るものがない",
"0pGBpyQ": "引く",
"0pGBpyQ": "プル",
"oCECucM": "プル ({{value0}})",
"zgbzIDs": "押し付けるものは何もない",
"eBZzWkw": "押す",
"eBZzWkw": "プッシュ",
"9Xtk0Pg": "プッシュ ({{value0}})",
"cBrXr-8": "Commit に失敗しました",
"5jVDzOo": "デスクトップを待っています...",
+2 -2
View File
@@ -1739,7 +1739,7 @@
"qE1-68U": "리뷰 작성 중...",
"JokOS5o": "임대로 강제 추진 중...",
"0BMXLfE": "commits 푸시 중...",
"PnL-W2o": "출판 브랜치...",
"PnL-W2o": "브랜치 게시 중...",
"uN9IaYI": "변경사항을 커밋하는 중...",
"us6yszU": "commit 메시지 생성 중...",
"eCa7Me8": "변경 사항을 스테이징하는 중...",
@@ -1778,7 +1778,7 @@
"7HjRY3c": "Commit",
"koj5rtA": "강제 푸시",
"63siVQ8": "요청 실패: {{value0}}",
"8I2nZuQ": "업데이트할 수 없음",
"8I2nZuQ": "건너뜀",
"Ba1jOdE": "중립",
"lyHptRM": "시간 초과",
"thrHxG4": "취소됨",
+2 -2
View File
@@ -1736,7 +1736,7 @@
"qE1-68U": "正在创建评论...",
"JokOS5o": "用租约强制推动...",
"0BMXLfE": "推动 commits...",
"PnL-W2o": "出版分社...",
"PnL-W2o": "正在发布分支...",
"uN9IaYI": "正在提交更改...",
"us6yszU": "正在生成 commit 消息...",
"eCa7Me8": "正在暂存更改...",
@@ -1775,7 +1775,7 @@
"7HjRY3c": "Commit",
"koj5rtA": "强力推",
"63siVQ8": "请求失败:{{value0}}",
"8I2nZuQ": "无法更新",
"8I2nZuQ": "已跳过",
"Ba1jOdE": "Neutral",
"lyHptRM": "已超时",
"thrHxG4": "已取消",
+29
View File
@@ -94,16 +94,22 @@ describe('mobile i18n', () => {
await mobileI18n.changeLanguage('ja')
expect([
t('m.x1skfkY'),
t('m.IhhRQcI'),
t('m.-CpqM4g', { value0: 'PR' }),
t('m.Kdbzhtg', { value0: 'PR' }),
t('m.eeoaUzE', { value0: 'PR' }),
t('m.i65iwXo', { value0: 'PR' }),
t('m.h5c6qGM', { value0: 'PR' }),
t('m.aDj0ISs', { value0: 'PR' }),
t('m.eL3YqZQ', { value0: 'PR' }),
t('m.fS2uz3k', { value0: 'PR' })
]).toEqual([
'PR を作成する前に、変更を解決するかステージングしてください。',
'PR を作成する前にブランチをチェックアウトしてください。',
'このブランチはまだ PR の準備ができていません。',
'このブランチはまだ PR の準備ができていません。',
'PR を作成する前にベース ブランチをプッシュしてください。',
'認証してから PR を作成してください。',
'このブランチを同期してから PR を作成してください。',
'コミットを公開してから PR を作成してください。',
@@ -112,6 +118,29 @@ describe('mobile i18n', () => {
])
})
it.each([
['es', ['Omitido', 'Publicando rama...']],
['ja', ['スキップ済み', 'ブランチを公開しています...']],
['ko', ['건너뜀', '브랜치 게시 중...']],
['zh', ['已跳过', '正在发布分支...']]
] satisfies [MobileUiLocale, string[]][])(
'%s uses Git operation terminology',
async (locale, expected) => {
await mobileI18n.changeLanguage(locale)
expect([t('m.8I2nZuQ'), t('m.PnL-W2o')]).toEqual(expected)
}
)
it('uses Git push and pull terminology in Japanese', async () => {
await mobileI18n.changeLanguage('ja')
expect([t('m.qgmf_L8'), t('m.0OsPYDw'), t('m.eBZzWkw'), t('m.0pGBpyQ')]).toEqual([
'プッシュ',
'プル',
'プッシュ',
'プル'
])
})
it.each([
['es', ['Sin comprobaciones', 'En staging', 'Nota de revisión']],
['ja', ['チェックなし', 'ステージ済み', 'レビューメモ']],