From b90837ee469da2aee17b3ea8a2c4bc1ad42974d7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:56:20 -0400 Subject: [PATCH] feat(mobile-web-bundle): advertise the bundle capability where a bundle ships (OTA phase A, 4/5) (#21376) * feat(mobile-web-bundle): advertise the bundle capability where one ships status.get pushes mobileWeb.bundle.v1 only when the install's bundle resolves and its manifest parses, beside the other conditional capabilities. Dev trees and `orca serve` installs may carry no out/mobile-web, and a static entry there would promise a download that only ever answers mobile_web_bundle_unavailable. No protocol version bump: protocol-version.ts asks for one when a method or a required field is removed or changes meaning, not when a capability is added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that mobileWeb.bundle.v1 is inert on a released client Derives the old desktop's reply by removing the one capability from what the new one sends, rather than writing down what the old client had, and asserts every released read of status.get lands identically apart from that string: the gate hook, the three transport readers, the quick-command predicate and the worktree-create support probe. Proved red against three mutants: a closed enum on the capability schema (the salvaged field drops whole, so nothing publishes), a client-side filter over the new name, and a gate that changes floatingWorkspaceEnabled when it sees it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): name the invariant behind the fake client's cast The changed-code casting gate wants the rationale on the line, and the reason is narrow enough to state: every reader under test reaches the client through an rpc operation's `request`, which uses sendRequest alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...ost-status-capability-ignorability.test.ts | 165 ++++++++++++++++++ src/main/runtime/orca-runtime-get-status.ts | 8 + .../mobile-web-bundle-capability.spec.ts | 65 +++++++ src/main/runtime/orca-runtime.test.ts | 1 + 4 files changed, 239 insertions(+) create mode 100644 mobile/src/transport/host-status-capability-ignorability.test.ts create mode 100644 src/main/runtime/orca-runtime-tests/mobile-web-bundle-capability.spec.ts 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')