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,