From f2909d35392ce561beef2eb6de53c6cb8b64517b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 21:30:49 -0400 Subject: [PATCH] test(mobile): verify hosted Terminal preferences in session consumers Exercise text scale, autocorrect and custom shortcut persistence through the real hosted settings and session. Reuse picker and terminal inspection logic and restore original preferences. Full iOS adversarial run passed with ok:true against build eab7fd8f5eb4a37e593526e90dcc5105552691db072da8a982cd1ed30784a2e3; all code gates and export passed. --- .../hosted-ios-browser-settings-journey.mjs | 25 +-- ...osted-ios-terminal-preference-consumer.mjs | 91 +++++++++ .../hosted-ios-terminal-settings-journey.mjs | 173 ++++++++++++++++++ ...sted-ios-workspace-device-capabilities.mjs | 11 ++ .../scripts/hosted-settings-picker-option.mjs | 23 +++ .../hosted-terminal-instance-inspection.mjs | 23 +++ .../scripts/hosted-terminal-link-locator.mjs | 49 +---- mobile/scripts/hosted-webview-e2e-report.mjs | 1 + 8 files changed, 327 insertions(+), 69 deletions(-) create mode 100644 mobile/scripts/hosted-ios-terminal-preference-consumer.mjs create mode 100644 mobile/scripts/hosted-ios-terminal-settings-journey.mjs create mode 100644 mobile/scripts/hosted-settings-picker-option.mjs create mode 100644 mobile/scripts/hosted-terminal-instance-inspection.mjs diff --git a/mobile/scripts/hosted-ios-browser-settings-journey.mjs b/mobile/scripts/hosted-ios-browser-settings-journey.mjs index b0c284b5ed8..b4c87311a04 100644 --- a/mobile/scripts/hosted-ios-browser-settings-journey.mjs +++ b/mobile/scripts/hosted-ios-browser-settings-journey.mjs @@ -1,3 +1,4 @@ +import { waitHostedSettingsPickerOption } from './hosted-settings-picker-option.mjs' import path from 'node:path' import { activateHostedWebViewControl, @@ -27,7 +28,7 @@ export async function verifyHostedIosBrowserSettings(args) { async function choose(document, label, timeoutMs) { await waitMode(document, undefined, timeoutMs) await activateHostedWebViewControl(document, { kind: 'label', value: 'Open terminal links' }) - await waitPickerOption(document, label, timeoutMs) + await waitHostedSettingsPickerOption(document, label, timeoutMs) await activateHostedWebViewControl(document, { kind: 'text', value: label }) await waitMode(document, label, timeoutMs) } @@ -52,25 +53,3 @@ async function waitMode(document, label, timeoutMs) { } throw new Error('Browser preference did not finish loading or saving') } - -async function waitPickerOption(document, label, timeoutMs) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - const visible = await evaluateHostedDocumentWithRetry( - document, - `JSON.stringify( - Array.from(document.querySelectorAll('body *')).some((element) => { - if (element.children.length || element.textContent.trim() !== ${JSON.stringify(label)}) return false; - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return style.display !== 'none' && style.visibility !== 'hidden' && - rect.width > 0 && rect.height > 0 && rect.top < innerHeight && rect.bottom > 0; - }))` - ) - if (JSON.parse(visible)) { - return - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - throw new Error(`Browser option did not become visible: ${label}`) -} diff --git a/mobile/scripts/hosted-ios-terminal-preference-consumer.mjs b/mobile/scripts/hosted-ios-terminal-preference-consumer.mjs new file mode 100644 index 00000000000..96500268e1b --- /dev/null +++ b/mobile/scripts/hosted-ios-terminal-preference-consumer.mjs @@ -0,0 +1,91 @@ +import { + activateHostedWebViewControl, + evaluateHostedDocumentWithRetry, + waitForVisibleHostedWebView +} from './hosted-webview-cdp-session.mjs' +import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' +import { HOSTED_TERMINAL_INSTANCE_LOOKUP } from './hosted-terminal-instance-inspection.mjs' + +export async function verifyHostedIosTerminalPreferenceConsumer(args, expected) { + await activateHostedWorkspaceRow( + args.workspaceDocument, + args.expectedWorkspace, + activateHostedWebViewControl, + args.timeoutMs, + () => + waitForVisibleHostedWebView({ + discoveryUrl: args.discoveryUrl, + expectedText: args.expectedWorkspace, + timeoutMs: args.timeoutMs + }) + ) + const document = await waitForVisibleHostedWebView({ + discoveryUrl: args.discoveryUrl, + expectedHrefIncludes: '/session/', + expectedText: 'Mobile Emulator', + timeoutMs: args.timeoutMs + }) + await activateHostedWebViewControl(document, { kind: 'text', value: 'Mobile Emulator' }) + const wasLive = JSON.parse( + await evaluateHostedDocumentWithRetry( + document, + `JSON.stringify(Boolean(document.querySelector('[aria-label="Switch to buffered command input"]')))` + ) + ) + if (wasLive) { + await activateHostedWebViewControl(document, { + kind: 'label', + value: 'Switch to buffered command input' + }) + } + const deadline = Date.now() + args.timeoutMs + let state + while (Date.now() < deadline) { + state = JSON.parse( + await evaluateHostedDocumentWithRetry( + document, + `(() => { + const visible = (element) => { const r=element.getBoundingClientRect(); return r.width>0&&r.height>0; }; + const terminalElement=Array.from(document.querySelectorAll('.xterm')).find(visible); + const terminal=terminalElement ? findTerminal(terminalElement) : null; + const input=Array.from(document.querySelectorAll('[placeholder="Type a command…"]')).find(visible); + const custom=document.querySelector(${JSON.stringify(`[aria-label="Send ${expected.shortcutLabel}"]`)}); + return JSON.stringify({fontSize:terminal?.options.fontSize, spellcheck:input?.spellcheck, + autocorrect:input?.getAttribute('autocorrect'), customShortcutPresent:Boolean(custom)}); + ${HOSTED_TERMINAL_INSTANCE_LOOKUP} + })()` + ) + ) + if ( + state.fontSize === expected.fontSize && + state.spellcheck === expected.autocomplete && + state.autocorrect === (expected.autocomplete ? 'on' : 'off') && + state.customShortcutPresent === expected.shortcutPresent + ) { + break + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if ( + state?.fontSize !== expected.fontSize || + state?.spellcheck !== expected.autocomplete || + state?.autocorrect !== (expected.autocomplete ? 'on' : 'off') || + state?.customShortcutPresent !== expected.shortcutPresent + ) { + throw new Error(`Hosted terminal did not consume saved preferences: ${JSON.stringify(state)}`) + } + if (wasLive) { + await activateHostedWebViewControl(document, { + kind: 'label', + value: 'Switch to live terminal input' + }) + } + await activateHostedWebViewControl(document, { kind: 'label', value: 'Back to worktrees' }) + const workspaceDocument = await waitForVisibleHostedWebView({ + discoveryUrl: args.discoveryUrl, + expectedText: args.expectedWorkspace, + expectedPathname: '/', + timeoutMs: args.timeoutMs + }) + return { workspaceDocument, evidence: state } +} diff --git a/mobile/scripts/hosted-ios-terminal-settings-journey.mjs b/mobile/scripts/hosted-ios-terminal-settings-journey.mjs new file mode 100644 index 00000000000..67b177210d3 --- /dev/null +++ b/mobile/scripts/hosted-ios-terminal-settings-journey.mjs @@ -0,0 +1,173 @@ +import path from 'node:path' +import { + activateHostedWebViewControl, + evaluateHostedDocumentWithRetry, + setHostedWebViewInput +} from './hosted-webview-cdp-session.mjs' +import { openHostedIosSettings, closeHostedIosSettings } from './hosted-ios-settings-navigation.mjs' +import { waitHostedSettingsPickerOption } from './hosted-settings-picker-option.mjs' +import { verifyHostedIosTerminalPreferenceConsumer } from './hosted-ios-terminal-preference-consumer.mjs' +import { captureAgentHistorySimulatorScreenshot as captureScreenshot } from './hosted-ios-agent-history-parity.mjs' + +const autocompleteLabel = 'Autocomplete and autocorrect' + +export async function verifyHostedIosTerminalSettings(args) { + const open = (document) => + openHostedIosSettings(args, document, 'Terminal', '/terminal-settings', 'for this paired host') + let settings = await open(args.workspaceDocument) + const original = await waitSettings(settings, {}, args.timeoutMs) + const targetSize = original.textSize === 'Large (125%)' ? 'Smaller (75%)' : 'Large (125%)' + const targetAutocomplete = !original.autocomplete + const shortcutLabel = `OTA-${Date.now() % 1_000_000}` + await chooseSize(settings, targetSize, args.timeoutMs) + await activateHostedWebViewControl(settings, { + kind: 'label', + value: autocompleteLabel, + reveal: true + }) + await waitSettings( + settings, + { textSize: targetSize, autocomplete: targetAutocomplete }, + args.timeoutMs + ) + await addShortcut(settings, shortcutLabel, args.timeoutMs) + settings = await open(await closeHostedIosSettings(args, settings)) + const changed = await waitSettings( + settings, + { + textSize: targetSize, + autocomplete: targetAutocomplete, + shortcutLabel, + shortcutPresent: true + }, + args.timeoutMs + ) + await evaluateHostedDocumentWithRetry( + settings, + `document.querySelector('[aria-label="${autocompleteLabel}"]').scrollIntoView({block:'center'}); 'ok'` + ) + const screenshot = path.join(args.runtimeDirectory, 'hosted-terminal-settings.png') + await captureScreenshot(args.deviceUdid, screenshot) + const consumer = await verifyHostedIosTerminalPreferenceConsumer( + { ...args, workspaceDocument: await closeHostedIosSettings(args, settings) }, + { + fontSize: fontSizeForLabel(targetSize), + autocomplete: targetAutocomplete, + shortcutLabel, + shortcutPresent: true + } + ) + settings = await open(consumer.workspaceDocument) + await chooseSize(settings, original.textSize, args.timeoutMs) + await waitSettings(settings, {}, args.timeoutMs) + await activateHostedWebViewControl(settings, { + kind: 'label', + value: autocompleteLabel, + reveal: true + }) + await waitSettings(settings, { autocomplete: original.autocomplete }, args.timeoutMs) + await activateHostedWebViewControl(settings, { + kind: 'label', + value: `Delete ${shortcutLabel}`, + reveal: true + }) + await waitSettings(settings, { shortcutLabel, shortcutPresent: false }, args.timeoutMs) + settings = await open(await closeHostedIosSettings(args, settings)) + const restored = await waitSettings( + settings, + { + textSize: original.textSize, + autocomplete: original.autocomplete, + shortcutLabel, + shortcutPresent: false + }, + args.timeoutMs + ) + const restoredConsumer = await verifyHostedIosTerminalPreferenceConsumer( + { ...args, workspaceDocument: await closeHostedIosSettings(args, settings) }, + { + fontSize: fontSizeForLabel(original.textSize), + autocomplete: original.autocomplete, + shortcutLabel, + shortcutPresent: false + } + ) + return { + workspaceDocument: restoredConsumer.workspaceDocument, + evidence: { + persistenceAfterReopen: true, + original, + changed, + consumer: consumer.evidence, + restored, + restoredConsumer: restoredConsumer.evidence, + screenshot + } + } +} + +async function chooseSize(document, label, timeoutMs) { + await waitSettings(document, {}, timeoutMs) + await activateHostedWebViewControl(document, { kind: 'label', value: 'Text size', reveal: true }) + await waitHostedSettingsPickerOption(document, label, timeoutMs) + await activateHostedWebViewControl(document, { kind: 'text', value: label }) + await waitSettings(document, { textSize: label }, timeoutMs) +} + +async function addShortcut(document, label, timeoutMs) { + await activateHostedWebViewControl(document, { + kind: 'label', + value: 'Add custom shortcut', + reveal: true + }) + await waitHostedSettingsPickerOption(document, 'Text Macro', timeoutMs) + await activateHostedWebViewControl(document, { kind: 'text', value: 'Text Macro' }) + await waitHostedSettingsPickerOption(document, 'Add Shortcut', timeoutMs) + await setHostedWebViewInput(document, { placeholder: 'e.g. Build', value: label }) + await setHostedWebViewInput(document, { placeholder: 'e.g. pnpm build', value: ':' }) + await activateHostedWebViewControl(document, { kind: 'text', value: 'Add Shortcut' }) + await waitSettings(document, { shortcutLabel: label, shortcutPresent: true }, timeoutMs) +} + +async function waitSettings(document, expected, timeoutMs) { + const deadline = Date.now() + timeoutMs + let state + while (Date.now() < deadline) { + state = JSON.parse( + await evaluateHostedDocumentWithRetry( + document, + `(() => { + const error=Array.from(document.querySelectorAll('[role="alert"]')).find(e=>e.textContent.includes('Could not')); + if(error) throw new Error(error.textContent); + const size=document.querySelector('[aria-label="Text size"]'); + const autocomplete=document.querySelector('[aria-label="${autocompleteLabel}"]'); + const add=document.querySelector('[aria-label="Add custom shortcut"]'); + const ready=[size,autocomplete,add].every(e=>e&&!e.disabled&&e.getAttribute('aria-disabled')!=='true'&&e.getAttribute('aria-busy')!=='true'); + return JSON.stringify({ready, + textSize:size?.textContent.match(/(?:Smallest|Smaller|Default|Larger|Largest|Large) \\(\\d+%\\)/)?.[0], + autocomplete:autocomplete?.getAttribute('aria-checked')==='true'||autocomplete?.checked===true||autocomplete?.querySelector('input')?.checked===true, + shortcutPresent:Boolean(document.querySelector(${JSON.stringify(`[aria-label="Delete ${expected.shortcutLabel ?? ''}"]`)}))}); + })()` + ) + ) + if ( + state.ready && + state.textSize && + (expected.textSize === undefined || state.textSize === expected.textSize) && + (expected.autocomplete === undefined || state.autocomplete === expected.autocomplete) && + (expected.shortcutPresent === undefined || state.shortcutPresent === expected.shortcutPresent) + ) { + return state + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`Terminal preferences did not load or persist: ${JSON.stringify(state)}`) +} + +function fontSizeForLabel(label) { + const percent = Number(label.match(/\((\d+)%\)/)?.[1]) + if (!Number.isFinite(percent) || percent <= 0) { + throw new Error(`Unexpected terminal text size: ${label}`) + } + return (13 * percent) / 100 +} diff --git a/mobile/scripts/hosted-ios-workspace-device-capabilities.mjs b/mobile/scripts/hosted-ios-workspace-device-capabilities.mjs index 39a5d4fede5..5e41c0dfeb6 100644 --- a/mobile/scripts/hosted-ios-workspace-device-capabilities.mjs +++ b/mobile/scripts/hosted-ios-workspace-device-capabilities.mjs @@ -1,3 +1,4 @@ +import { verifyHostedIosTerminalSettings } from './hosted-ios-terminal-settings-journey.mjs' import { evidenceStep } from './hosted-webview-e2e-report.mjs' import { verifyHostedIosNativeAlertJourney } from './hosted-ios-native-alert-journey.mjs' import { verifyHostedIosBrowserSettings } from './hosted-ios-browser-settings-journey.mjs' @@ -20,11 +21,21 @@ export async function verifyHostedIosWorkspaceDeviceCapabilities(args) { }) ) : null + const terminalSettings = browserSettings + ? await evidenceStep('hosted terminal preference persistence and consumers', () => + verifyHostedIosTerminalSettings({ + ...args, + workspaceDocument: browserSettings.workspaceDocument + }) + ) + : null return { nativeAlert, + terminalSettings, browserSettings, chatSettings, workspaceDocument: + terminalSettings?.workspaceDocument ?? browserSettings?.workspaceDocument ?? chatSettings?.workspaceDocument ?? nativeAlert.workspaceDocument diff --git a/mobile/scripts/hosted-settings-picker-option.mjs b/mobile/scripts/hosted-settings-picker-option.mjs new file mode 100644 index 00000000000..b843e302b3a --- /dev/null +++ b/mobile/scripts/hosted-settings-picker-option.mjs @@ -0,0 +1,23 @@ +import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs' + +export async function waitHostedSettingsPickerOption(document, label, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const visible = await evaluateHostedDocumentWithRetry( + document, + `JSON.stringify( + Array.from(document.querySelectorAll('body *')).some((element) => { + if (element.children.length || element.textContent.trim() !== ${JSON.stringify(label)}) return false; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden' && + rect.width > 0 && rect.height > 0 && rect.top < innerHeight && rect.bottom > 0; + }))` + ) + if (JSON.parse(visible)) { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`Settings option did not become visible: ${label}`) +} diff --git a/mobile/scripts/hosted-terminal-instance-inspection.mjs b/mobile/scripts/hosted-terminal-instance-inspection.mjs new file mode 100644 index 00000000000..9952341eef4 --- /dev/null +++ b/mobile/scripts/hosted-terminal-instance-inspection.mjs @@ -0,0 +1,23 @@ +export const HOSTED_TERMINAL_INSTANCE_LOOKUP = `function findTerminal(element) { + for ( + let ancestor = element; + ancestor instanceof HTMLElement; + ancestor = ancestor.parentElement + ) { + const fiberKey = Object.keys(ancestor).find((key) => key.startsWith('__reactFiber$')); + let fiber = fiberKey ? ancestor[fiberKey] : null; + for (let depth = 0; fiber && depth < 32; depth += 1, fiber = fiber.return) { + let hook = fiber.memoizedState; + for (let index = 0; hook && index < 32; index += 1, hook = hook.next) { + const current = hook.memoizedState?.current; + if ( + current && + Number.isInteger(current.cols) && + Number.isInteger(current.rows) && + current.buffer?.active + ) return current; + } + } + } + return null; + }` diff --git a/mobile/scripts/hosted-terminal-link-locator.mjs b/mobile/scripts/hosted-terminal-link-locator.mjs index 0a17cd5f707..5706eefbf63 100644 --- a/mobile/scripts/hosted-terminal-link-locator.mjs +++ b/mobile/scripts/hosted-terminal-link-locator.mjs @@ -1,3 +1,4 @@ +import { HOSTED_TERMINAL_INSTANCE_LOOKUP } from './hosted-terminal-instance-inspection.mjs' import { WebSocket } from 'ws' import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs' @@ -74,29 +75,7 @@ export async function readHostedTerminalLinkPoints(document, operations = {}) { screenWidth: Number(screen.width) }); - function findTerminal(element) { - for ( - let ancestor = element; - ancestor instanceof HTMLElement; - ancestor = ancestor.parentElement - ) { - const fiberKey = Object.keys(ancestor).find((key) => key.startsWith('__reactFiber$')); - let fiber = fiberKey ? ancestor[fiberKey] : null; - for (let depth = 0; fiber && depth < 32; depth += 1, fiber = fiber.return) { - let hook = fiber.memoizedState; - for (let index = 0; hook && index < 32; index += 1, hook = hook.next) { - const current = hook.memoizedState?.current; - if ( - current && - Number.isInteger(current.cols) && - Number.isInteger(current.rows) && - current.buffer?.active - ) return current; - } - } - } - return null; - } + ${HOSTED_TERMINAL_INSTANCE_LOOKUP} })()`, WebSocket ) @@ -156,29 +135,7 @@ export async function describeHostedTerminalLinkPoint(document, point, operation viewportTop }); - function findTerminal(element) { - for ( - let ancestor = element; - ancestor instanceof HTMLElement; - ancestor = ancestor.parentElement - ) { - const fiberKey = Object.keys(ancestor).find((key) => key.startsWith('__reactFiber$')); - let fiber = fiberKey ? ancestor[fiberKey] : null; - for (let depth = 0; fiber && depth < 32; depth += 1, fiber = fiber.return) { - let hook = fiber.memoizedState; - for (let index = 0; hook && index < 32; index += 1, hook = hook.next) { - const current = hook.memoizedState?.current; - if ( - current && - Number.isInteger(current.cols) && - Number.isInteger(current.rows) && - current.buffer?.active - ) return current; - } - } - } - return null; - } + ${HOSTED_TERMINAL_INSTANCE_LOOKUP} function describeRow(terminal, row) { const line = terminal.buffer.active.getLine(row); diff --git a/mobile/scripts/hosted-webview-e2e-report.mjs b/mobile/scripts/hosted-webview-e2e-report.mjs index 65add4957e3..bd2e2e9e6e4 100644 --- a/mobile/scripts/hosted-webview-e2e-report.mjs +++ b/mobile/scripts/hosted-webview-e2e-report.mjs @@ -30,6 +30,7 @@ export function printHostedWebViewE2eReport(evidence) { nativeAlert: evidence.nativeAlert.evidence, chatSettings: evidence.chatSettings, browserSettings: evidence.browserSettings, + terminalSettings: evidence.terminalSettings, documentUpload: evidence.terminalDeviceInput?.documentUpload?.evidence ?? null, photoPermissionDenial: evidence.terminalDeviceInput?.photoPermissionDenial?.evidence ?? null,