diff --git a/mobile/src/transport/host-status-capability-ignorability.test.ts b/mobile/src/transport/host-status-capability-ignorability.test.ts new file mode 100644 index 00000000000..4d4e9101d3c --- /dev/null +++ b/mobile/src/transport/host-status-capability-ignorability.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability' +import { + readNewWorktreeRuntimeCapabilities, + type NewWorktreeRuntimeCapabilities +} from '../tasks/worktree-create-capability' +import { supportsMobileQuickCommands } from '../terminal/quick-commands' +import { useHostStatusGates, type HostStatusGates } from './host-status-gates' +import { + hostAnsweredStatusProbe, + readHostStatusGates, + readProbedHostCapabilities +} from './host-status-probe-operations' +import type { HostStatusReply } from './host-status-reply-schema' +import type { RpcClient } from './rpc-client' +import type { RpcResponse } from './types' + +const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) + +vi.mock('./host-app-version-store', () => ({ + normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null), + recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args) +})) + +/** + * What a desktop that ships a mobile web bundle now answers. The new name sits among real ones + * rather than alone, so a reader that keeps only the head or the tail of the list cannot look + * unchanged by accident, and the rest are there to make every derived state below non-trivial. + */ +const ADVERTISED_CAPABILITIES = [ + 'files.pathsExist', + MOBILE_AI_VAULT_CAPABILITY, + 'mobile.tasks.v1', + MOBILE_WEB_BUNDLE_CAPABILITY, + 'worktree.create-idempotency.v1', + 'terminal.quick-commands.v1' +] as const + +/** The old desktop is DERIVED, never written down: one string removed from what the new one sends. */ +const OLD_DESKTOP_CAPABILITIES = ADVERTISED_CAPABILITIES.filter( + (capability) => capability !== MOBILE_WEB_BUNDLE_CAPABILITY +) + +function statusReply(capabilities: readonly string[]): RpcResponse { + return { + id: 'status-1', + ok: true, + result: { + appVersion: '1.4.200', + protocolVersion: 3, + minCompatibleMobileVersion: 2, + floatingWorkspaceEnabled: true, + capabilities: [...capabilities] + }, + _meta: { runtimeId: 'runtime-1' } + } +} + +/** Answers every method with the one status reply, which is all any reader under test asks for. */ +function clientAnswering(reply: RpcResponse): RpcClient { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every reader below reaches this client only through an rpc operation's `request`, which uses sendRequest alone; the rest of RpcClient is streaming and lifecycle none of them touch. + return { sendRequest: vi.fn().mockResolvedValue(reply) } as unknown as RpcClient +} + +async function renderGates(client: RpcClient): Promise { + // Held on an object rather than in `let`s: both are written from inside a callback, where + // narrowing would read them back as their initializer. + const mount: { renderer?: ReactTestRenderer; gates?: HostStatusGates } = {} + function Probe(): null { + mount.gates = useHostStatusGates({ hostId: 'host-1', client, connState: 'connected' }) + return null + } + try { + await act(async () => { + mount.renderer = create(createElement(Probe)) + await Promise.resolve() + }) + } finally { + mount.renderer?.unmount() + } + if (!mount.gates) { + throw new Error('the gate hook never rendered') + } + return mount.gates +} + +type ClientVisibleOutcome = { + gates: HostStatusGates + gateStatus: HostStatusReply | null + probedCapabilities: readonly string[] | null + answeredProbe: boolean + quickCommandsSupported: boolean + worktreeCreateSupport: NewWorktreeRuntimeCapabilities +} + +/** Every released path that reads status.get, plus the states derived from what it published. */ +async function readEverything(capabilities: readonly string[]): Promise { + const reply = statusReply(capabilities) + const client = clientAnswering(reply) + const gates = await renderGates(client) + return { + gates, + gateStatus: readHostStatusGates(reply), + probedCapabilities: readProbedHostCapabilities(reply), + answeredProbe: hostAnsweredStatusProbe(reply), + quickCommandsSupported: supportsMobileQuickCommands(gates.hostCapabilities), + worktreeCreateSupport: await readNewWorktreeRuntimeCapabilities(client) + } +} + +function withoutBundleCapability(values: readonly string[]): string[] { + return values.filter((capability) => capability !== MOBILE_WEB_BUNDLE_CAPABILITY) +} + +/** The outcome with the one new string removed wherever it surfaced, and nothing else touched. */ +function asIfNeverAdvertised(outcome: ClientVisibleOutcome): ClientVisibleOutcome { + return { + ...outcome, + gates: { + ...outcome.gates, + hostCapabilities: withoutBundleCapability(outcome.gates.hostCapabilities) + }, + gateStatus: outcome.gateStatus + ? { + ...outcome.gateStatus, + ...(outcome.gateStatus.capabilities + ? { capabilities: withoutBundleCapability(outcome.gateStatus.capabilities) } + : {}) + } + : outcome.gateStatus, + probedCapabilities: outcome.probedCapabilities + ? withoutBundleCapability(outcome.probedCapabilities) + : outcome.probedCapabilities + } +} + +describe('mobileWeb.bundle.v1 on a released client', () => { + /** + * The Phase A promise: a desktop that starts advertising the bundle changes nothing a shipped + * phone can observe. Both sides of the comparison come from the same list, so nothing here + * records "what the old client had" and can rot out of step with it. + * + * A closed enum or an exhaustive switch over capabilities would land on the presence assertions + * first: the strict schema drops a whole salvaged field rather than one entry, so the advertised + * read would publish nothing and the equality below would fail with it. + */ + it('changes nothing a released client reads apart from the capability string itself', async () => { + const advertised = await readEverything(ADVERTISED_CAPABILITIES) + const oldDesktop = await readEverything(OLD_DESKTOP_CAPABILITIES) + + // Preconditions: without these the comparison could hold because nothing was read at all. + expect(advertised.gates.hostCapabilities).toContain(MOBILE_WEB_BUNDLE_CAPABILITY) + expect(advertised.gateStatus).not.toBeNull() + expect(advertised.probedCapabilities).toContain(MOBILE_WEB_BUNDLE_CAPABILITY) + expect(advertised.quickCommandsSupported).toBe(true) + expect(advertised.worktreeCreateSupport.tasksSupported).toBe(true) + expect(advertised.worktreeCreateSupport.worktreeCreateIdempotency).not.toBe(false) + expect(oldDesktop.gates.hostCapabilities).not.toContain(MOBILE_WEB_BUNDLE_CAPABILITY) + + expect(asIfNeverAdvertised(advertised)).toEqual(oldDesktop) + }) +}) diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index bf7d3818045..f3d16bd1f4e 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -21,6 +21,8 @@ import { BROWSER_UNAVAILABLE_ERROR_CODE, browserUnavailableMessage } from '../../shared/runtime-types' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../shared/mobile-web-bundle/mobile-web-bundle-capability' +import { loadBundledMobileWebBundle } from './bundled-mobile-web-bundle' import { runtimeTerminalDegradation } from './native-terminal-availability' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' import type { RuntimeWorktreeLifecycleEvent } from './orca-runtime-core' @@ -89,6 +91,12 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) } + // Why not a static capability: dev trees and `orca serve` installs may carry no + // out/mobile-web, and advertising a bundle this install cannot produce would promise a + // download that only ever answers mobile_web_bundle_unavailable. + if (loadBundledMobileWebBundle()) { + capabilities.push(MOBILE_WEB_BUNDLE_CAPABILITY) + } // Why the cause and not one fixed sentence: the operator can only act on the reason // that actually applies, and a host that says "set ORCA_BROWSER_EXECUTABLE" to someone // who already set it sends them to fix a thing that is not broken. diff --git a/src/main/runtime/orca-runtime-tests/mobile-web-bundle-capability.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-web-bundle-capability.spec.ts new file mode 100644 index 00000000000..333ff8cabc8 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-web-bundle-capability.spec.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createRuntime } from '../orca-runtime-test-fixtures.spec' +import { resetBundledMobileWebBundleCacheForTests } from '../bundled-mobile-web-bundle' +import { + installMobileWebBundleAppPath, + writeSyntheticMobileWebBundle +} from '../rpc/methods/mobile-web-bundle.test-fixture' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../shared/mobile-web-bundle/mobile-web-bundle-capability' + +// The resolver caches per process and the aggregated suite shares that process, so each case +// installs its own root and hands the cache back the way it found it. +function statusCapabilitiesForInstall(appPath: string): readonly string[] { + resetBundledMobileWebBundleCacheForTests() + installMobileWebBundleAppPath(appPath) + const { capabilities } = createRuntime().getStatus() + if (!capabilities) { + // A status carrying no list at all would let every absence assertion below pass vacuously. + throw new Error('status.get answered no capability list') + } + return capabilities +} + +function withInstallRoot(prefix: string, run: (install: string) => void): void { + const install = mkdtempSync(join(tmpdir(), prefix)) + try { + run(install) + } finally { + rmSync(install, { recursive: true, force: true }) + } +} + +describe('OrcaRuntimeService mobile web bundle capability', () => { + afterEach(() => { + resetBundledMobileWebBundleCacheForTests() + }) + + // The mixed-version guarantee: a dev tree or an `orca serve` install that never built + // out/mobile-web must not promise a bundle it can only refuse. + it('omits the mobile web bundle capability where the install carries no bundle', () => { + withInstallRoot('orca-mobile-web-absent-', (install) => { + expect(statusCapabilitiesForInstall(install)).not.toContain(MOBILE_WEB_BUNDLE_CAPABILITY) + }) + }) + + it('advertises the mobile web bundle capability where the install carries one', () => { + withInstallRoot('orca-mobile-web-present-', (install) => { + writeSyntheticMobileWebBundle(join(install, 'out', 'mobile-web'), 4) + expect(statusCapabilitiesForInstall(install)).toContain(MOBILE_WEB_BUNDLE_CAPABILITY) + }) + }) + + // A manifest the contract rejects is the same answer as no bundle, so advertising off the + // directory's existence rather than off a parsed manifest would promise a download that errors. + it('omits the capability where the bundle manifest does not parse', () => { + withInstallRoot('orca-mobile-web-unparseable-', (install) => { + const root = join(install, 'out', 'mobile-web') + writeSyntheticMobileWebBundle(root, 5) + writeFileSync(join(root, 'manifest.json'), '{"schemaVersion":2}', 'utf8') + expect(statusCapabilitiesForInstall(install)).not.toContain(MOBILE_WEB_BUNDLE_CAPABILITY) + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d9e187817bf..13796169be0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11,6 +11,7 @@ await import('./orca-runtime-tests/paired-settings.spec') await import('./orca-runtime-tests/runtime-availability.spec') await import('./orca-runtime-tests/browser-capabilities.spec') await import('./orca-runtime-tests/browser-capabilities-part-02.spec') +await import('./orca-runtime-tests/mobile-web-bundle-capability.spec') await import('./orca-runtime-tests/window-authority.spec') await import('./orca-runtime-tests/terminal-handles.spec') await import('./orca-runtime-tests/terminal-handles-part-02.spec')