diff --git a/.prettierignore b/.prettierignore index 796a04e4c9..860d0a1b29 100644 --- a/.prettierignore +++ b/.prettierignore @@ -41,6 +41,9 @@ package/src/theme/generated/ perf/gauntlet/out/ perf/gauntlet/runtime/generated/ +# A2UI's conformance cases, kept as upstream wrote them +package/__fixtures__/a2ui-conformance/ + # Workflow / tooling configs not owned by this project's Prettier .github/ .husky/ diff --git a/.yarnrc.yml b/.yarnrc.yml index e37b3de9cf..d85843a87a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -22,6 +22,7 @@ npmPreapprovedPackages: - '@stream-io/state-store' - '@stream-io/worker-timer' - '@stream-io/ws' + - '@a2ui/web_core' npmPublishProvenance: true diff --git a/AGENTS.md b/AGENTS.md index 886d53c370..58539287b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Targets iOS and Android. - **Languages:** TypeScript + React Native - **Runtime:** Node 24 (`.nvmrc` is `v24`; root `engines.node` is `>=22.13.0`; CI runs `24.x`) - **Package manager:** Yarn 4.15.0 (Berry). The binary lives at `.yarn/releases/yarn-4.15.0.cjs` and is activated via `yarnPath` in `.yarnrc.yml`. Any globally installed `yarn` (even the Homebrew classic 1.x) acts only as a launcher — no Corepack required. -- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/E2ETestApp`, `examples/ExpoMessaging`, `examples/ExpoChatGPTSample`. **No Lerna.** +- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/E2ETestApp`, `examples/ExpoMessaging`. **No Lerna.** - **Testing:** Jest with the `@react-native/jest-preset` + `@testing-library/react-native`. - **Build:** `react-native-builder-bob` → CommonJS (`lib/commonjs`), ESM (`lib/module`), types (`lib/typescript`) - **Lint/format:** ESLint 9 flat config + Prettier, strict (`--max-warnings 0`) @@ -41,7 +41,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco - `native-package/` — bare RN wrapper (`stream-chat-react-native`) - `expo-package/` — Expo wrapper (`stream-chat-expo`) - `shared-native/{ios,android}` — native source shared by both wrappers; synced into them, never edited in place -- `examples/` — `SampleApp` (full-featured), `E2ETestApp` (minimal app for the Detox e2e suite), `ExpoMessaging`, `ExpoChatGPTSample` (AI components) +- `examples/` — `SampleApp` (full-featured), `E2ETestApp` (minimal app for the Detox e2e suite), `ExpoMessaging`. The AI components samples live in Vision-Agents (`examples/voice_agents/react_native_assistant`, `examples/voice_agents/crew_assistant/app`) - `configs/typescript-config/` — shared `tsconfig` presets - `ai-docs/` — agent-facing deep dives (see [References](#references)) - `perf/` — on-device performance toolkit (see [Accessibility, RTL & performance](#accessibility-rtl--performance)) @@ -219,6 +219,8 @@ Native code shared by both wrappers lives in `package/shared-native/{ios,android - the main entry points must never reach `src/ai-components` or the AI-only packages (`react-syntax-highlighter`, `@khanacademy/simple-markdown`, `zod`, `victory-native`, Skia). `@stream-io/state-store` and `lodash` are shared: the main entry imports them too (the stores in `state-store/`, the Poll contexts); - the AI entry points may only reach `src/ai-components` plus an explicit allowlist (`src/native.ts`, the native shimmer). The wrappers' `ai-components/index.js` registers the native pieces the AI code needs itself and must never import the wrappers' `src/index.js`. +`AIConversation` and its list sit behind a third entry point, `ai-components/conversation` (`package/ai-components/conversation/`, `package/{native,expo}-package/ai-components/conversation/`), because `@legendapp/list/keyboard` requires `react-native-keyboard-controller` unguarded. The isolation check keeps the `ai-components` and `a2ui` entry points from reaching `src/ai-components/conversation` or `@legendapp/list/keyboard`, and lets them load keyboard-controller only through the guarded `src/ai-components/internal/keyboard.tsx`. The conversation's state layer (`AIConversationController`, its types, `useAIConversationState`) lives in `src/ai-components/conversation-state` and stays in the plain entry. + Do not add AI slots to `DEFAULT_COMPONENTS`; core `require`s the defaults, which would break isolation. Skia and `victory-native` are optional peers (native libraries must be owned by the app); the JS-only AI dependencies are regular dependencies. Native pieces live in `package/shared-native/`: the Android-only `PerfText` Fabric view (Kotlin plus a C++ shadow node in `shared-native/android/jni`, built through each wrapper's `android/src/main/jni/CMakeLists.txt`, registered with `cmakeListsPath`/`componentDescriptors` in `react-native.config.js`) and the `AIDictation` TurboModule. **The iOS dictation sources are excluded from the pod unless the app opts in** (`$StreamChatEnableDictation = true` in the Podfile, or the `stream-chat-expo` config plugin in `package/expo-package/plugin`), because referencing the Speech framework makes App Store Connect demand a speech recognition usage description from every app. Never declare `RECORD_AUDIO` in a library manifest. The dictation codegen spec is duplicated into each wrapper's `src/native/` (codegen only reads a package's own `jsSrcsDir`); the isolation check keeps the copies identical. diff --git a/examples/E2ETestApp/e2e/README.md b/examples/E2ETestApp/e2e/README.md index 28061827e5..3695a4201c 100644 --- a/examples/E2ETestApp/e2e/README.md +++ b/examples/E2ETestApp/e2e/README.md @@ -88,7 +88,6 @@ test names and expectations. Where the SDK disagrees with the native expectation grouped channel lists, channel/message/member search, reminders, push notifications (app-level in RN), Android's `BackendTests` (real backend), hard delete from the message menu, iOS read counts and the iOS scroll performance tests. Tests that cut the network run only on Android (`itOnAndroid`): the iOS simulator shares the Mac's connection. -Tests that take the Android CI emulator down run only on iOS (`itOnIOS`), with a comment linking the ticket. ## Impact analysis diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts index aece96ded9..20b8367ab3 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts @@ -199,10 +199,16 @@ UserRobot.prototype.assertMessageDeliveryStatus = function (status, count) { case MessageDeliveryStatus.FAILED: return assertFailedIcon(true, count); case MessageDeliveryStatus.NIL: - await assertVisibility(Message.deliveryStatusIsRead, false); - await assertVisibility(Message.deliveryStatusIsDelivered, false); - await assertVisibility(Message.deliveryStatusIsPending, false); - await assertVisibility(Message.deliveryStatusIsSent, false); + // Only the list's message cells: a thread's parent message sits above the replies outside + // any cell (ThreadFooterComponent) and shows its own status. + for (const status of [ + Message.deliveryStatusIsRead, + Message.deliveryStatusIsDelivered, + Message.deliveryStatusIsPending, + Message.deliveryStatusIsSent, + ]) { + await assertVisibility(status.withAncestor(MessageList.messages), false); + } return; default: throw new Error(`Unsupported delivery status: ${status}`); diff --git a/examples/E2ETestApp/e2e/support/StreamTestCase.ts b/examples/E2ETestApp/e2e/support/StreamTestCase.ts index 1b32d810b6..c1785726bf 100644 --- a/examples/E2ETestApp/e2e/support/StreamTestCase.ts +++ b/examples/E2ETestApp/e2e/support/StreamTestCase.ts @@ -113,13 +113,6 @@ export const itOnAndroid: typeof it = ((name: string, fn: () => Promise, t ? it(name, fn, timeout) : it.skip(name, fn, timeout)) as typeof it; -/** - * `it` on iOS, `it.skip` on Android: for tests that take the Android CI emulator down with them - * (RN-513). Name the ticket in a comment above each one. - */ -export const itOnIOS: typeof it = ((name: string, fn: () => Promise, timeout?: number) => - device.getPlatform() === 'ios' ? it(name, fn, timeout) : it.skip(name, fn, timeout)) as typeof it; - /** * `it`, except `it.skip` on iOS below 18: for tests that hit an SDK bug only older iOS versions * show. Name the ticket in a comment above each one. Runs when the version can't be read. diff --git a/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts b/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts index 4a6eaec2b6..20b9ee5213 100644 --- a/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts +++ b/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts @@ -2,13 +2,12 @@ import { describe, it } from '@jest/globals'; import { MessageDeliveryStatus } from '../mock-server/DataTypes'; import { allureId, step } from '../support/step'; -import { itOnIOS, setUpStreamTestCase } from '../support/StreamTestCase'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; describe('GiphyTests', () => { const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessage', async () => { + it('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessage', async () => { allureId(13081); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user sends a giphy using giphy command', () => userRobot.uploadGiphy()); @@ -17,16 +16,14 @@ describe('GiphyTests', () => { ); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_userObservesAnimatedGiphy_whenParticipantAddsGiphyMessage', async () => { + it('test_userObservesAnimatedGiphy_whenParticipantAddsGiphyMessage', async () => { allureId(13082); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN participant sends a giphy', () => participantRobot.uploadGiphy()); await step('THEN user observes the animated gif', () => userRobot.assertGiphyImage()); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessageInThread', async () => { + it('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessageInThread', async () => { allureId(13083); await step('GIVEN user opens a channel', async () => { await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); @@ -38,8 +35,7 @@ describe('GiphyTests', () => { await step('THEN user observes the animated gif in thread', () => userRobot.assertGiphyImage()); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_userObservesAnimatedGiphy_whenParticipantAddsGiphyMessageInThread', async () => { + it('test_userObservesAnimatedGiphy_whenParticipantAddsGiphyMessageInThread', async () => { allureId(13084); await step('GIVEN user opens a channel', async () => { await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); @@ -54,8 +50,7 @@ describe('GiphyTests', () => { ); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_userObservesAnimatedGiphy_afterAddingGiphyThroughComposerMenu', async () => { + it('test_userObservesAnimatedGiphy_afterAddingGiphyThroughComposerMenu', async () => { allureId(13085); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user sends a giphy using the command suggestions', () => @@ -64,8 +59,7 @@ describe('GiphyTests', () => { await step('THEN user observes the animated gif', () => userRobot.assertGiphyImage()); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessage_AfterShuffling', async () => { + it('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessage_AfterShuffling', async () => { allureId(13086); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user shuffles a giphy', () => @@ -80,8 +74,7 @@ describe('GiphyTests', () => { ); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_messageIsNotSent_whenUserCancelsEphemeralMessage', async () => { + it('test_messageIsNotSent_whenUserCancelsEphemeralMessage', async () => { allureId(13087); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user cancels a giphy', () => @@ -126,8 +119,7 @@ describe('GiphyTests', () => { ); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_deliveryStatusHidden_whenEphemeralMessageShown', async () => { + it('test_deliveryStatusHidden_whenEphemeralMessageShown', async () => { allureId(13090); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user runs a giphy command', () => @@ -140,8 +132,7 @@ describe('GiphyTests', () => { ); }); - // https://linear.app/stream/issue/RN-513 - itOnIOS('test_deliveryStatusHidden_whenEphemeralMessageShownInThread', async () => { + it('test_deliveryStatusHidden_whenEphemeralMessageShownInThread', async () => { allureId(13091); await step('GIVEN user opens a channel', async () => { await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); diff --git a/examples/ExpoChatGPTSample/.gitignore b/examples/ExpoChatGPTSample/.gitignore deleted file mode 100644 index 5f1ef56382..0000000000 --- a/examples/ExpoChatGPTSample/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files - -# dependencies -node_modules/ - -# Expo -.expo/ -ios/ -android/ -dist/ -web-build/ -expo-env.d.ts - -# Native -*.orig.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# Metro -.metro-health-check* - -# debug -npm-debug.* -yarn-debug.* -yarn-error.* - -# macOS -.DS_Store -*.pem - -# local env files -.env*.local - -# typescript -*.tsbuildinfo - -app-example diff --git a/examples/ExpoChatGPTSample/README.md b/examples/ExpoChatGPTSample/README.md deleted file mode 100644 index 7f68eeacef..0000000000 --- a/examples/ExpoChatGPTSample/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# ExpoChatGPTSample - -A ChatGPT-style assistant built with [`stream-chat-expo`](../../package/expo-package) and its AI components -(`stream-chat-expo/ai-components`): streamed markdown answers with syntax highlighting, tables and charts, an "AI is -thinking" indicator, and a composer with attachments, a custom actions sheet and speech-to-text. - -Ported from the `ExpoChatGPTSample` of the deprecated `@stream-io/chat-react-native-ai` repository -([`GetStream/ai-components-js`](https://github.com/GetStream/ai-components-js)). - -## How it works - -- The drawer lists the user's conversations; `+` starts a new one. -- A new conversation is created on its first message. Its title is summarized from that message by the sample backend. -- Before sending, the app asks the sample backend to add an AI agent to the channel (`http/requests.ts`). The agent - replies with streamed `ai_generated` messages, rendered by `StreamingMessageView`. - -The Stream app, user and backend in `chatConfig.ts` and `http/requests.ts` are Stream's public demo setup. To use your -own, replace them and point `http/requests.ts` at your own AI agent server. - -## Running - -From the repository root: - -```bash -yarn install -yarn workspace expochatgptsample ios # or: android -``` - -`ios` and `android` sync the SDK's shared native code, run `expo prebuild` and build the app. The native `ios` and -`android` folders are generated and not committed. diff --git a/examples/ExpoChatGPTSample/app.json b/examples/ExpoChatGPTSample/app.json deleted file mode 100644 index 7310f41c67..0000000000 --- a/examples/ExpoChatGPTSample/app.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "expo": { - "name": "ExpoChatGPTSample", - "slug": "ExpoChatGPTSample", - "scheme": "expochatgptsample", - "ios": { - "bundleIdentifier": "io.getstream.expochatgptsample", - "appleTeamId": "EHV7XZLAHA" - }, - "android": { - "permissions": ["android.permission.RECORD_AUDIO"], - "package": "io.getstream.expochatgptsample" - }, - "plugins": [ - "expo-router", - [ - "expo-image-picker", - { - "photosPermission": "The app accesses your photos to let them share with others.", - "cameraPermission": "The app accesses your camera to let you take photos and share with others." - } - ], - [ - "stream-chat-expo", - { - "dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.", - "dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice." - } - ] - ] - } -} diff --git a/examples/ExpoChatGPTSample/app/_layout.tsx b/examples/ExpoChatGPTSample/app/_layout.tsx deleted file mode 100644 index f08b3c34d6..0000000000 --- a/examples/ExpoChatGPTSample/app/_layout.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect } from 'react'; - -import { StyleSheet } from 'react-native'; - -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; - -import { Drawer } from 'expo-router/drawer'; - -import type { LocalMessage, OperationRequestFn, StreamChat } from 'stream-chat'; -import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo'; - -import { StreamTheme } from 'stream-chat-expo/ai-components'; - -import { chatApiKey, chatUserId, chatUserName, chatUserToken } from '../chatConfig'; -import { AppProvider } from '../contexts/AppContext'; -import { startAI, summarize } from '../http/requests'; - -import { MenuDrawer } from '../screens/MenuDrawer'; - -const isMessageAIGenerated = (message: LocalMessage) => !!message.custom?.ai_generated; - -// Creates the channel on its first message, names it after a summary of that message, and makes -// sure the AI bot is watching before the message goes out. -const createSendMessageRequest = - (client: StreamChat): OperationRequestFn<'send'> => - async (params, defaultRequest) => { - const { localMessage } = params; - const [type, id] = localMessage.cid.split(':'); - const channel = client.channel(type, id); - - if (!channel.initialized) { - await channel.watch(); - summarize(localMessage.text ?? '').then((response) => { - // `post` resolves to undefined when the request fails; the channel then keeps its id. - const summary = (response as { summary?: string } | undefined)?.summary; - if (summary) { - channel.updatePartial({ set: { name: summary } }); - } - }); - } - - if ( - !Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot')) && - channel.id - ) { - await startAI(channel.id); - } - - return defaultRequest(params); - }; - -export default function Layout() { - const chatClient = useCreateChatClient({ - apiKey: chatApiKey, - tokenOrProvider: chatUserToken, - userData: { id: chatUserId, name: chatUserName }, - }); - - useEffect(() => { - if (!chatClient) { - return; - } - chatClient.config.set({ - channel: { requestHandlers: { sendMessageRequest: createSendMessageRequest(chatClient) } }, - messageComposer: { attachments: { pendingUploadsEnabled: true } }, - }); - }, [chatClient]); - - if (!chatClient) { - return null; - } - return ( - - - - - - - - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1 }, -}); diff --git a/examples/ExpoChatGPTSample/app/index.tsx b/examples/ExpoChatGPTSample/app/index.tsx deleted file mode 100644 index 062b54b1a5..0000000000 --- a/examples/ExpoChatGPTSample/app/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ChatContent } from '../screens/ChatContent'; - -const Page = () => { - return ; -}; -export default Page; diff --git a/examples/ExpoChatGPTSample/babel.config.js b/examples/ExpoChatGPTSample/babel.config.js deleted file mode 100644 index 5b84251884..0000000000 --- a/examples/ExpoChatGPTSample/babel.config.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: [ - // Needed by `stream-chat-expo/ai-components`: its `zod` dependency ships `export * as`. - '@babel/plugin-transform-export-namespace-from', - // react-native-worklets/plugin has to be the last one - 'react-native-worklets/plugin', - ], - }; -}; diff --git a/examples/ExpoChatGPTSample/bottomSheetOptions.tsx b/examples/ExpoChatGPTSample/bottomSheetOptions.tsx deleted file mode 100644 index b83226ad5d..0000000000 --- a/examples/ExpoChatGPTSample/bottomSheetOptions.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Alert } from 'react-native'; - -import { Copy, Edit, FilePickerIcon, Flag, IconProps, Picture } from 'stream-chat-expo'; - -// The sheet renders each icon without props, and some SDK icons (e.g. `Flag`, `Copy`) only draw -// with an explicit stroke, so every icon gets the same colour here. -const withStroke = (Icon: React.ComponentType) => { - const StrokedIcon = () => ; - StrokedIcon.displayName = `Stroked(${Icon.displayName ?? Icon.name})`; - return StrokedIcon; -}; - -export const bottomSheetOptions = [ - { - title: 'Create Image', - subtitle: 'Visualize anything', - action: () => Alert.alert('Pressed on Create Image !'), - Icon: withStroke(Picture), - }, - { - title: 'Thinking', - subtitle: 'Think longer for better answers', - action: () => Alert.alert('Pressed on Thinking !'), - Icon: withStroke(Flag), - }, - { - title: 'Deep research', - subtitle: 'Get a detailed report', - action: () => Alert.alert('Pressed on Deep research !'), - Icon: withStroke(FilePickerIcon), - }, - { - title: 'Web search', - subtitle: 'Find real-time news and info', - action: () => Alert.alert('Pressed on Web search !'), - Icon: withStroke(Copy), - }, - { - title: 'Study and learn', - subtitle: 'Learn a new concept', - action: () => Alert.alert('Pressed on Study and learn !'), - Icon: withStroke(Edit), - }, -]; diff --git a/examples/ExpoChatGPTSample/chatConfig.ts b/examples/ExpoChatGPTSample/chatConfig.ts deleted file mode 100644 index e385f5f427..0000000000 --- a/examples/ExpoChatGPTSample/chatConfig.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const chatApiKey = 'zcgvnykxsfm8'; -export const chatUserId = 'rn-ai-test-user-3'; -export const chatUserToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm4tYWktdGVzdC11c2VyLTMifQ.kOg3zLkzHc-b_5IyckQr-RixbebN-GDs7N3c570oA5A'; -export const chatUserName = 'Bob'; diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx deleted file mode 100644 index d1a0984bff..0000000000 --- a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from 'react'; -import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Svg, { Line } from 'react-native-svg'; - -import { - StreamTheme, - useDictationAudioLevel, - type VoiceAgentState, - VoiceGlow, - VoiceWave, -} from 'stream-chat-expo/ai-components'; - -import { useVoiceConversation } from './useVoiceConversation'; - -const BACKDROP = '#0b0b10'; -const COLORS = ['#005fff', '#7b5cff', '#00c2ff']; - -const LABELS: Record = { - connecting: 'Connecting…', - idle: 'Tap the glow to talk', - listening: 'Listening', - speaking: 'Speaking', - thinking: 'Thinking', -}; - -const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => { - const { top, bottom } = useSafeAreaInsets(); - const { error, listen, phase, userIsTalking } = useVoiceConversation(true); - const micLevel = useDictationAudioLevel(); - - return ( - - - - - ✕ - - - - - - - {error ? 'Couldn’t hear you. Tap the glow to try again.' : LABELS[phase]} - - - - - ); -}; - -/** - * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame - * for as long as the chat is open. - */ -const VoiceIcon = () => ( - - {[ - [3, 8, 14], - [7, 4, 18], - [11, 2, 20], - [15, 5, 17], - [19, 9, 13], - ].map(([x, y1, y2]) => ( - - ))} - -); - -/** Opens a full-screen voice conversation with the AI agent. */ -export const VoiceMode = () => { - const [open, setOpen] = useState(false); - - return ( - <> - - setOpen(true)} - style={styles.button} - > - - - - setOpen(false)} visible={open}> - {open ? setOpen(false)} /> : null} - - - ); -}; - -const styles = StyleSheet.create({ - button: { - alignItems: 'center', - backgroundColor: '#ffffff', - borderColor: '#ececec', - borderRadius: 24, - borderWidth: 1, - height: 48, - justifyContent: 'center', - overflow: 'hidden', - width: 48, - }, - close: { color: '#ffffff', fontSize: 22 }, - entry: { alignItems: 'flex-end', paddingHorizontal: 16, paddingBottom: 8 }, - glow: { aspectRatio: 1, width: '100%' }, - glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' }, - header: { alignItems: 'flex-end', paddingHorizontal: 20 }, - label: { color: '#c9cbd3', fontSize: 17, marginVertical: 16, textAlign: 'center' }, - overlay: { backgroundColor: BACKDROP, flex: 1 }, - wave: { alignSelf: 'stretch', height: 140 }, -}); diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/index.ts b/examples/ExpoChatGPTSample/components/VoiceMode/index.ts deleted file mode 100644 index abe97f1086..0000000000 --- a/examples/ExpoChatGPTSample/components/VoiceMode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './VoiceMode'; diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts deleted file mode 100644 index d635f1e7d0..0000000000 --- a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import * as Speech from 'expo-speech'; - -import { AIStates, useAIState, useChannelContext, useStableCallback } from 'stream-chat-expo'; - -import { - dictationStore, - useDictation, - useDictationState, - useDictationTranscript, - type VoiceAgentState, -} from 'stream-chat-expo/ai-components'; - -import { useSendAIMessage } from '../../hooks/useSendAIMessage'; - -/** A turn the agent is never seen working on ends after this long. */ -const TURN_TIMEOUT_MS = 20_000; - -const WORKING_STATES: string[] = [AIStates.Thinking, AIStates.Generating, AIStates.ExternalSources]; - -// Marks voice mode's dictation as its own, so the composer underneath ignores it. -const DICTATION_SOURCE = { source: 'voice-mode' }; - -/** Markdown read aloud as plain words. */ -const toSpeech = (text: string) => - text - .replace(/```[\s\S]*?```/g, ' ') - .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') - .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') - .replace(/[*_#>`~|]/g, '') - .replace(/\s+/g, ' ') - .trim(); - -/** - * A hands-free conversation with the AI agent: it listens with dictation, sends what it heard, - * waits for the agent to answer, reads the answer aloud and listens again. - * - * Returns what the agent is doing, for `VoiceGlow`, and whether the user is being listened to, - * for `VoiceWave`. - */ -export const useVoiceConversation = (active: boolean) => { - const [phase, setPhase] = useState('idle'); - const { start, cancel } = useDictation(undefined, DICTATION_SOURCE); - const { state: dictationState, error } = useDictationState(); - const { transcript } = useDictationTranscript(); - const send = useSendAIMessage(); - const { channel } = useChannelContext(); - const { aiState } = useAIState(channel); - - // The messages already in the channel when the question was sent, so the reply is found by - // identity rather than by comparing the server's timestamps with the device's clock. - const turn = useRef<{ seenIds: Set; sawWorking: boolean } | null>(null); - const timeout = useRef | null>(null); - // An error left over from an earlier turn, ignored until dictation starts again and clears it. - const staleError = useRef(dictationStore.getLatestValue().error); - - const listen = useStableCallback(() => { - if (!active) return; - turn.current = null; - staleError.current = dictationStore.getLatestValue().error; - setPhase('connecting'); - void start(); - }); - - const latestReply = useStableCallback(() => { - const seenIds = turn.current?.seenIds ?? new Set(); - const messages = channel?.messagePaginator.items ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (seenIds.has(message.id)) break; - if (message.custom?.ai_generated && message.text) return message.text; - } - return null; - }); - - /** Ends the agent's turn: reads its answer aloud if there is one, then listens again. */ - const finishTurn = useStableCallback(() => { - if (timeout.current) clearTimeout(timeout.current); - timeout.current = null; - const reply = latestReply(); - turn.current = null; - if (!active) return; - if (!reply) { - listen(); - return; - } - setPhase('speaking'); - Speech.speak(toSpeech(reply), { - onDone: listen, - onError: listen, - // Its own audio session on iOS, so the reply is heard whatever dictation left the app's in. - useApplicationAudioSession: false, - }); - }); - - // Starts listening when voice mode opens, and stops everything when it closes. - useEffect(() => { - if (!active) return; - listen(); - return () => { - if (timeout.current) clearTimeout(timeout.current); - timeout.current = null; - turn.current = null; - cancel(); - void Speech.stop(); - setPhase('idle'); - }; - }, [active, cancel, listen]); - - // Follows dictation: listening while it records, and sends what it heard once it stops. - useEffect(() => { - if (!active || turn.current) return; - if (dictationState === 'listening') { - setPhase('listening'); - return; - } - if (dictationState !== 'idle' || (phase !== 'listening' && phase !== 'connecting')) return; - if (error && error !== staleError.current) { - setPhase('idle'); - return; - } - if (phase === 'connecting') return; - const text = transcript.trim(); - if (!text) { - // Nothing was heard; keep listening. - listen(); - return; - } - turn.current = { - sawWorking: false, - seenIds: new Set((channel?.messagePaginator.items ?? []).map((message) => message.id)), - }; - setPhase('thinking'); - void send({ text }); - timeout.current = setTimeout(() => { - if (!turn.current?.sawWorking) finishTurn(); - }, TURN_TIMEOUT_MS); - }, [active, channel, dictationState, error, finishTurn, listen, phase, send, transcript]); - - // Follows the agent: its turn ends once it has been seen working and is done. - useEffect(() => { - const current = turn.current; - if (!active || !current) return; - if (WORKING_STATES.includes(aiState)) { - current.sawWorking = true; - } else if (current.sawWorking) { - finishTurn(); - } - }, [active, aiState, finishTurn]); - - return { - // Only an error from the current attempt, not one left over while dictation restarts. - error: phase === 'idle' ? error : null, - listen, - phase, - userIsTalking: phase === 'listening', - }; -}; diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx deleted file mode 100644 index f838b13ada..0000000000 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; - -import { ChannelPaginator } from 'stream-chat'; -import type { Channel, SortParamRequest, StreamChat } from 'stream-chat'; - -import { chatUserId } from '../chatConfig'; - -const channelListFilters = { members: { $in: [chatUserId] } }; -const channelListSort: SortParamRequest[] = [{ direction: -1, field: 'last_updated' }]; - -export type AppContextValue = { - channel: Channel | undefined; - channelListPaginator: ChannelPaginator | undefined; - setChannel: (channel: Channel) => void; -}; - -export const AppContext = React.createContext({ - setChannel: () => {}, - channel: undefined, - channelListPaginator: undefined, -}); - -const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'; - -export const nanoid = (size: number = 21): string => { - let id = ''; - for (let i = 0; i < size; i++) { - const r = Math.floor(Math.random() * ALPHABET.length); - id += ALPHABET[r]; - } - return id; -}; - -export const createChannel = (client: StreamChat) => - client.channel('messaging', nanoid(), { - members: [{ user_id: chatUserId }], - }); - -export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => { - const [channel, setChannel] = useState(() => createChannel(client)); - - // The app owns the channel list's paginator. It lives as long as the client, so the list keeps its - // channels when the drawer unmounts. `disconnectUser` empties the registered lists but keeps them - // registered, so it is removed when the client goes away. - const channelListPaginator = useMemo( - () => - new ChannelPaginator({ - client, - filters: channelListFilters, - id: 'channel-list', - sort: channelListSort, - }), - [client], - ); - - useEffect(() => { - client.channelManager.insertPaginator({ paginator: channelListPaginator }); - return () => { - client.channelManager.removePaginator(channelListPaginator); - channelListPaginator.dispose(); - }; - }, [channelListPaginator, client]); - - const contextValue = useMemo( - () => ({ channel, channelListPaginator, setChannel }), - [channel, channelListPaginator], - ); - - return {children}; -}; - -export const useAppContext = () => React.useContext(AppContext); diff --git a/examples/ExpoChatGPTSample/custom-types.d.ts b/examples/ExpoChatGPTSample/custom-types.d.ts deleted file mode 100644 index af1dc3dcdb..0000000000 --- a/examples/ExpoChatGPTSample/custom-types.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - DefaultAttachmentData, - DefaultChannelData, - DefaultEventData, - DefaultMemberData, - DefaultMessageData, - DefaultPollData, - DefaultPollOptionData, - DefaultReactionData, - DefaultThreadData, - DefaultUserData, -} from 'stream-chat-expo'; - -declare module 'stream-chat' { - // Module augmentation: the empty interfaces are how the default data types are applied. - /* eslint-disable @typescript-eslint/no-empty-object-type */ - - interface CustomAttachmentData extends DefaultAttachmentData {} - - interface CustomChannelData extends DefaultChannelData {} - - interface CustomEventData extends DefaultEventData {} - - interface CustomMemberData extends DefaultMemberData {} - - interface CustomUserData extends DefaultUserData {} - - interface CustomMessageData extends DefaultMessageData { - ai_generated?: boolean; - } - - interface CustomPollOptionData extends DefaultPollOptionData {} - - interface CustomPollData extends DefaultPollData {} - - interface CustomReactionData extends DefaultReactionData {} - - interface CustomThreadData extends DefaultThreadData {} -} diff --git a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts deleted file mode 100644 index c60df86cb8..0000000000 --- a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMessageComposer, useMessageInputContext, useStableCallback } from 'stream-chat-expo'; - -import type { ComposerViewProps } from 'stream-chat-expo/ai-components'; - -export type AIMessage = Parameters[0]; - -/** - * Sends a message to the AI channel through the channel's composer, with its attachments. Shared - * by the composer and voice mode, so both go through the client's `sendMessageRequest` handler - * (which starts the agent on the first message). - */ -export const useSendAIMessage = () => { - const messageComposer = useMessageComposer(); - const { sendMessage } = useMessageInputContext(); - - return useStableCallback(async ({ text, attachments }: AIMessage) => { - messageComposer.textComposer.setText(text); - if (attachments && attachments.length > 0) { - const localAttachments = await Promise.all( - attachments.map((asset) => - messageComposer.attachmentManager.fileToLocalUploadAttachment({ - ...asset, - // The picker can leave these out (e.g. some iOS photos have no file name). - name: asset.name ?? asset.uri.split('/').pop() ?? 'image', - size: asset.size ?? 0, - type: asset.type ?? 'image/jpeg', - }), - ), - ); - messageComposer.attachmentManager.upsertAttachments(localAttachments); - } - - await sendMessage(); - }); -}; diff --git a/examples/ExpoChatGPTSample/http/api.ts b/examples/ExpoChatGPTSample/http/api.ts deleted file mode 100644 index 4ff8fba92f..0000000000 --- a/examples/ExpoChatGPTSample/http/api.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const post = async (url: string, data: unknown = {}) => { - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`An HTTP Error has occurred. Status: ${response.status}`); - } - - return await response.json(); - } catch (error) { - console.error('Error:', error); - } -}; diff --git a/examples/ExpoChatGPTSample/http/requests.ts b/examples/ExpoChatGPTSample/http/requests.ts deleted file mode 100644 index fce4cd3941..0000000000 --- a/examples/ExpoChatGPTSample/http/requests.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { post } from './api'; - -// The AI provider the sample backend uses, as in the React SDK's ai-chatbot example. -const PLATFORM = 'openai'; - -export const startAI = (channelId: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/start-ai-agent', { - channel_id: channelId, - platform: PLATFORM, - }); -export const stopAI = (channelId: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/stop-ai-agent', { - channel_id: channelId, - }); -export const summarize = (text: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { - platform: PLATFORM, - text, - }); diff --git a/examples/ExpoChatGPTSample/metro.config.js b/examples/ExpoChatGPTSample/metro.config.js deleted file mode 100644 index 5fa02acffa..0000000000 --- a/examples/ExpoChatGPTSample/metro.config.js +++ /dev/null @@ -1,74 +0,0 @@ -const { getDefaultConfig } = require('@expo/metro-config'); -const { exclusionList, resolveUniqueModule } = require('@rnx-kit/metro-config'); - -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const config = getDefaultConfig(__dirname); - -const PATH = require('path'); -const packageDirPath = PATH.resolve(__dirname, '../../package'); -const expoPackageDirPath = PATH.resolve(__dirname, '../../package/expo-package'); - -const symlinked = { - 'stream-chat-expo': expoPackageDirPath, - 'stream-chat-react-native-core': packageDirPath, -}; - -// find what all modules need to be unique for the app (mainly react and react-native) -// note: we filter the symlinked modules as they are already unique -// and as they dont follow the workspace pattern the auto-generated path to the module is incorrect -const dependencyPackageNames = Object.keys(require('./package.json').dependencies); - -const uniqueModules = dependencyPackageNames.map((packageName) => { - if (symlinked[packageName]) { - const modulePath = symlinked[packageName]; - const escapedPackageName = PATH.normalize(packageName).replace(/\\/g, '\\\\'); - - // exclude the symlinked package from being resolved from node_modules - // example: .*\/node_modules\/stream-chat-react-native-core\/.* - // the above would avoid native-package to resolve core from its own node_modules - const exclusionRE = new RegExp( - `.*${PATH.sep}node_modules\\${PATH.sep}${escapedPackageName}\\${PATH.sep}.*`, - ); - - return { - packageName, // name of the package - modulePath, - blockPattern: exclusionRE, // paths that match this pattern will be blocked from being resolved - }; - } - const [modulePath, blockPattern] = resolveUniqueModule(packageName, __dirname); - // resolveUniqueModule emits a regex ending in `/` (package-dir boundary). metro-config's - // exclusionList anchors every alternation branch with `$`, which would require file paths to - // literally end with `/node_modules//`. Append `.*` so the anchor lands at end-of-file - // instead of end-of-directory, otherwise the blocklist silently matches nothing. - const fixedBlockPattern = new RegExp(blockPattern.source + '.*'); - return { - packageName, // name of the package - modulePath, // actual path to the module in the project's node modules - blockPattern: fixedBlockPattern, // paths that match this pattern will be blocked from being resolved - }; -}); - -// block the other paths for unique modules from being resolved -const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); - -// provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); - -config.resolver.blockList = exclusionList(blockList); -config.resolver.extraNodeModules = extraNodeModules; - -config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; - -// add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; - -module.exports = config; diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json deleted file mode 100644 index 9ec164fe7f..0000000000 --- a/examples/ExpoChatGPTSample/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "expochatgptsample", - "version": "1.0.1", - "main": "expo-router/entry", - "scripts": { - "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh expo-package && bash ../../package/scripts/sync-shared-native.sh expo-package", - "start": "yarn sync-native && expo start --dev-client", - "android": "yarn sync-native && expo run:android", - "ios": "yarn sync-native && expo run:ios", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@react-native-community/netinfo": "12.0.1", - "@shopify/react-native-skia": "^2.14.0", - "expo": "^58.0.8", - "expo-clipboard": "~58.0.3", - "expo-constants": "~58.0.11", - "expo-image-manipulator": "~58.0.14", - "expo-image-picker": "~58.0.10", - "expo-linking": "~58.0.12", - "expo-router": "~58.0.18", - "expo-speech": "~58.0.3", - "react": "19.3.0", - "react-native": "0.88.0", - "react-native-gesture-handler": "~3.4.0", - "react-native-reanimated": "4.7.1", - "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "~4.29.0", - "react-native-svg": "15.15.5", - "react-native-teleport": "^1.2.2", - "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.1", - "stream-chat-expo": "workspace:^", - "stream-chat-react-native-core": "workspace:^", - "victory-native": "^42.0.1" - }, - "devDependencies": { - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@rnx-kit/metro-config": "^2.1.2", - "@stream-io/typescript-config": "workspace:^", - "@types/react": "~19.3.0", - "typescript": "6.0.3" - }, - "private": true -} diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx deleted file mode 100644 index 7a98628935..0000000000 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { Platform, StatusBar, StyleSheet, Text, View } from 'react-native'; -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { useHeaderHeight } from 'expo-router/react-navigation'; - -import type { MessageProps } from 'stream-chat-expo'; -import { - AIStates, - Channel, - isLocalUrl, - mergeThemes, - Message, - MessageList, - ThemeProvider, - useAIState, - useChannelContext, - useMessageContext, - useTheme, - WithComponents, -} from 'stream-chat-expo'; - -import { - AITypingIndicatorView, - ComposerView, - StreamingMessageView, -} from 'stream-chat-expo/ai-components'; - -import { bottomSheetOptions } from '../bottomSheetOptions'; -import { VoiceMode } from '../components/VoiceMode'; -import { useAppContext } from '../contexts/AppContext'; -import { useSendAIMessage } from '../hooks/useSendAIMessage'; - -const CustomMessage = (props: MessageProps) => { - const { theme } = useTheme(); - const { message } = props; - const isFromBot = message.custom?.ai_generated; - const hasPendingAttachments = useMemo( - () => - (message.attachments ?? []).some( - (attachment) => - (attachment.image_url && isLocalUrl(attachment.image_url)) || - (attachment.asset_url && isLocalUrl(attachment.asset_url)), - ), - [message.attachments], - ); - - const modifiedTheme = useMemo( - () => - mergeThemes({ - theme, - style: { - messageItemView: isFromBot - ? { - content: { - containerInner: { - backgroundColor: 'transparent', - borderRadius: 0, - borderColor: 'transparent', - }, - textContainer: { maxWidth: '100%' }, - }, - } - : { - container: { - opacity: hasPendingAttachments ? 0.5 : 1, - }, - }, - }, - }), - [theme, isFromBot, hasPendingAttachments], - ); - - return ( - - - - ); -}; - -const CustomStreamingMessageView = () => { - const { message } = useMessageContext(); - return ( - - - - ); -}; - -const CustomComposerView = () => { - const { channel } = useChannelContext(); - - const { aiState } = useAIState(channel); - - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); - - const isGenerating = aiState === AIStates.Thinking || aiState === AIStates.Generating; - - const safeAreaInsets = useSafeAreaInsets(); - const insets = useMemo( - () => ({ - ...safeAreaInsets, - bottom: - safeAreaInsets.bottom + - (Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0), - }), - [safeAreaInsets], - ); - - const serializeToMessage = useSendAIMessage(); - - return ( - - ); -}; - -const EmptyStateIndicator = () => ( - - What can I help with ? - -); - -const CustomAITypingIndicatorView = () => { - const { channel } = useChannelContext(); - const { aiState } = useAIState(channel); - - const allowedStates = { - [AIStates.Thinking]: 'Thinking about the question...', - [AIStates.Generating]: 'Generating a response...', - [AIStates.ExternalSources]: 'Checking external sources...', - }; - - const indicatorText = allowedStates[aiState as keyof typeof allowedStates]; - - if (!indicatorText) { - return null; - } - - return ( - - - - ); -}; - -const RenderNull = () => null; - -const additionalFlatListProps = { - maintainVisibleContentPosition: { - minIndexForVisible: 0, - autoscrollToTopThreshold: 0, - }, - ListHeaderComponent: undefined, -}; - -export const ChatContent = () => { - const { channel } = useAppContext(); - const { bottom } = useSafeAreaInsets(); - // The channel sits below the drawer header, so the keyboard offset has to account for it - // (without it, Android keeps the composer behind the keyboard). - const headerHeight = useHeaderHeight(); - - if (!channel) { - return null; - } - - return ( - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - wrapper: { flex: 1, backgroundColor: '#fcfcfc' }, - emptyContainer: { - flex: 1, - width: '100%', - backgroundColor: 'transparent', - justifyContent: 'center', - alignItems: 'center', - }, - emptyContainerText: { fontSize: 24, fontWeight: 'bold' }, - streamingMessageViewWrapper: { width: '100%', paddingHorizontal: 16 }, - aiTypingIndicatorWrapper: { - paddingHorizontal: 24, - paddingVertical: 12, - }, -}); diff --git a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx deleted file mode 100644 index 12783c765f..0000000000 --- a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { DrawerContentComponentProps } from 'expo-router/drawer'; -import type { Channel as ChannelClass } from 'stream-chat'; -import { - ChannelList, - ChannelPreviewProps, - Plus, - useChannelsContext, - useChatContext, - useStableCallback, - WithComponents, -} from 'stream-chat-expo'; - -import { createChannel, useAppContext } from '../contexts/AppContext'; - -const ChannelPreview = (props: ChannelPreviewProps) => { - const channel = props.channel; - const { onSelect } = useChannelsContext(); - const onPress = useStableCallback(() => { - onSelect?.(channel); - }); - return ( - ({ - paddingVertical: 8, - paddingHorizontal: 12, - opacity: pressed ? 0.6 : 1, - })} - onPress={onPress} - > - - {channel.data?.custom?.name ?? channel.cid} - - - ); -}; - -export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { - const { client } = useChatContext(); - const { channelListPaginator, setChannel } = useAppContext(); - - const onSelect = useStableCallback((channel: ChannelClass) => { - setChannel(channel); - navigation.closeDrawer(); - }); - - const onCreateNewChat = useStableCallback(() => { - setChannel(createChannel(client)); - navigation.closeDrawer(); - }); - - return ( - - - Conversations - - - - - - {channelListPaginator ? ( - - ) : null} - - - ); -}; - -const styles = StyleSheet.create({ - wrapper: { flex: 1, backgroundColor: '#FFFFFF' }, - container: { - flexDirection: 'row', - justifyContent: 'space-between', - marginHorizontal: 12, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: 'grey', - }, - title: { fontSize: 15, fontWeight: 'bold' }, - previewText: { fontSize: 15, fontWeight: 'bold' }, -}); diff --git a/examples/ExpoChatGPTSample/tsconfig.json b/examples/ExpoChatGPTSample/tsconfig.json deleted file mode 100644 index d4352f5a2e..0000000000 --- a/examples/ExpoChatGPTSample/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": ["expo/tsconfig.base", "@stream-io/typescript-config/base.json"], - "compilerOptions": { - "paths": { - // Same as ExpoMessaging: pins the whole program to this workspace's copy of React Native. - "react-native": ["./node_modules/react-native/types_generated/index.d.ts"], - "react-native/*": ["./node_modules/react-native/*"] - } - }, - "include": ["**/*.ts", "**/*.tsx"] -} diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index 42baf1e14c..b21eb19e8f 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -344,13 +344,13 @@ lane :e2e_check_required do end # `/` ids of the tests that can run, e.g. `MessageListTests.e2e/test_x`: the coverage -# map's keys. `it.skip` tests are left out; `itOnAndroid`, `itOnIOS` and `itExceptOnOldIOS` ones are -# in (Jest skips them where they don't run). +# map's keys. `it.skip` tests are left out; `itOnAndroid` and `itExceptOnOldIOS` ones are in (Jest +# skips them where they don't run). def e2e_test_ids(tests_path) Dir.glob(File.join(tests_path, '*.e2e.ts')).sort.flat_map do |file| source = File.read(file) - calls = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS|itExceptOnOldIOS)(?:\.skip)?\(/).size - tests = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS|itExceptOnOldIOS)(\.skip)?\(\s*'(test_\w+)'/) + calls = source.scan(/^\s*(?:it|itOnAndroid|itExceptOnOldIOS)(?:\.skip)?\(/).size + tests = source.scan(/^\s*(?:it|itOnAndroid|itExceptOnOldIOS)(\.skip)?\(\s*'(test_\w+)'/) # A test this misses would never run on a pull request, so fail loudly instead. UI.user_error!("#{file}: read #{tests.size} of its #{calls} tests; name each one with a `test_*` literal") if tests.size != calls @@ -542,10 +542,12 @@ lane :frameworks_sizes do sh('yarn build') sh('yarn minify-bundle') sh('yarn minify-ai-components-bundle') + sh('yarn minify-a2ui-bundle') { js_bundle_size: file_size(path: 'package/lib/module/bundle.min.js'), - ai_components_size: file_size(path: 'package/lib/ai-components.min.js') + ai_components_size: file_size(path: 'package/lib/ai-components.min.js'), + a2ui_size: file_size(path: 'package/lib/a2ui.min.js') } end end diff --git a/package.json b/package.json index ee702cd07b..52fa17562d 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "package/expo-package", "examples/SampleApp", "examples/E2ETestApp", - "examples/ExpoMessaging", - "examples/ExpoChatGPTSample" + "examples/ExpoMessaging" ], "resolutions": { "@types/react": "^19.3.0" @@ -72,12 +71,13 @@ "lint-fix": "prettier --write . && eslint . --fix --max-warnings 0", "prettier": "prettier --list-different .", "prettier-fix": "prettier --write .", - "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' --include 'expochatgptsample' run typecheck", + "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' run typecheck", "build": "yarn workspace stream-chat-react-native-core build", "test:coverage": "yarn workspace stream-chat-react-native-core test:coverage", "test:unit": "yarn workspace stream-chat-react-native-core test:unit", - "minify-bundle": "uglifyjs package/lib/module/*.js package/lib/module/!(ai-components)/**/*.js -o package/lib/module/bundle.min.js -c -m", - "minify-ai-components-bundle": "uglifyjs package/lib/module/ai-components/**/*.js -o package/lib/ai-components.min.js -c -m" + "minify-bundle": "uglifyjs package/lib/module/*.js package/lib/module/!(ai-components|a2ui)/**/*.js -o package/lib/module/bundle.min.js -c -m", + "minify-ai-components-bundle": "uglifyjs package/lib/module/ai-components/**/*.js -o package/lib/ai-components.min.js -c -m", + "minify-a2ui-bundle": "uglifyjs package/lib/module/a2ui/**/*.js -o package/lib/a2ui.min.js -c -m" }, "packageManager": "yarn@4.15.0" } diff --git a/package/__fixtures__/a2ui-conformance/NOTICE.md b/package/__fixtures__/a2ui-conformance/NOTICE.md new file mode 100644 index 0000000000..a2472d4e53 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/NOTICE.md @@ -0,0 +1,6 @@ +# A2UI conformance suite + +The cases in `core/` and the catalogs in `catalogs/` are the conformance suite of the A2UI +specification, from [a2ui-project/a2ui](https://github.com/a2ui-project/a2ui) at commit `d378d20`, +licensed under the Apache License, Version 2.0. `src/a2ui/__tests__/conformance.test.ts` runs them. +Copied as they are: update them by copying a newer commit's `conformance/` over these. diff --git a/package/__fixtures__/a2ui-conformance/catalogs/agent_functions_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/agent_functions_catalog_v1_0.json new file mode 100644 index 0000000000..48af1e761b --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/agent_functions_catalog_v1_0.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/agent_functions_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Agent Functions Catalog (v1.0)", + "description": "A catalog whose function the agent may call, for the cases that compile a callRendererFunction. The functions of the other fixtures declare no allowedCallers, so only the renderer may call them.", + "catalogId": "conformance/agent_functions", + "components": {}, + "functions": { + "notify": { + "type": "object", + "description": "Shows the user a short message.", + "returnType": "void", + "allowedCallers": "rendererOrAgent", + "properties": { + "@call": { + "const": "notify" + }, + "args": { + "type": "object", + "properties": { + "message": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "message" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/notify" + } + ] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/allowed_callers_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/allowed_callers_catalog_v1_0.json new file mode 100644 index 0000000000..cabfeef8cc --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/allowed_callers_catalog_v1_0.json @@ -0,0 +1,64 @@ +{ + "protocolVersion": "1.0", + "catalogId": "https://a2ui.org/catalogs/v10/allowed_callers", + "components": { + "Spacer": { + "type": "object", + "allowedParents": [ + "Surface", + "Spacer" + ], + "allowedChildren": [ + "Spacer" + ], + "properties": { + "component": { + "const": "Spacer" + } + }, + "required": [ + "component" + ] + } + }, + "functions": { + "readSessionToken": { + "type": "object", + "description": "Returns the renderer's session token to the agent.", + "returnType": "string", + "allowedCallers": "agentOnly", + "properties": { + "@call": { + "const": "readSessionToken" + }, + "args": { + "type": "object", + "properties": {}, + "unevaluatedProperties": false + } + }, + "required": [ + "@call" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Spacer" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/readSessionToken" + } + ] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_catalog_v1_0.json new file mode 100644 index 0000000000..c472e201bb --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_catalog_v1_0.json @@ -0,0 +1,215 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/component_metadata_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Component Metadata Catalog (v1.0)", + "description": "A copy of the simplified catalog whose component definitions carry ComponentDefinition.metadata extension envelopes, for the catalog metadata round-trip cases.", + "catalogId": "conformance/component-metadata", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "enum": [ + "body", + "caption" + ], + "default": "body" + } + }, + "required": [ + "component", + "text" + ], + "metadata": { + "extensions": { + "com_example_telemetry": { + "track": true + }, + "a2ui_experimental": "preview" + } + } + }, + "Button": { + "type": "object", + "description": "A control that emits an action when pressed.", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + }, + "action": { + "$ref": "common_types.json#/$defs/Action" + } + }, + "required": [ + "component", + "child", + "action" + ], + "metadata": { + "extensions": { + "com_example_policy": "interactive" + } + } + }, + "Card": { + "type": "object", + "description": "A container holding a single child.", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + } + }, + "required": [ + "component", + "child" + ] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component", + "children" + ] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "formatString": { + "type": "object", + "description": "Interpolates data model values into a template string.", + "returnType": "string", + "properties": { + "@call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "openUrl": { + "type": "object", + "description": "Opens a URL in the renderer's browser or handler.", + "returnType": "void", + "requiresUserActivation": true, + "properties": { + "@call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "url" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/Column" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/required" + }, + { + "$ref": "#/functions/formatString" + }, + { + "$ref": "#/functions/openUrl" + } + ], + "discriminator": { + "propertyName": "@call" + } + } + } +} \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_invalid_key_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_invalid_key_catalog_v1_0.json new file mode 100644 index 0000000000..3b9ce6c06a --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/component_metadata_invalid_key_catalog_v1_0.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/component_metadata_invalid_key_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Component Metadata Catalog With Invalid Key (v1.0)", + "description": "Like component_metadata_catalog_v1_0.json, but Card.metadata.extensions has a key that is not a UAX 31 identifier.", + "catalogId": "conformance/component-metadata-invalid", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { + "const": "Text" + }, + "text": { + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "enum": [ + "body", + "caption" + ], + "default": "body" + } + }, + "required": [ + "component", + "text" + ], + "metadata": { + "extensions": { + "com_example_telemetry": { + "track": true + }, + "a2ui_experimental": "preview" + } + } + }, + "Button": { + "type": "object", + "description": "A control that emits an action when pressed.", + "properties": { + "component": { + "const": "Button" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + }, + "action": { + "$ref": "common_types.json#/$defs/Action" + } + }, + "required": [ + "component", + "child", + "action" + ], + "metadata": { + "extensions": { + "com_example_policy": "interactive" + } + } + }, + "Card": { + "type": "object", + "description": "A container holding a single child.", + "properties": { + "component": { + "const": "Card" + }, + "child": { + "$ref": "common_types.json#/$defs/Child" + } + }, + "required": [ + "component", + "child" + ], + "metadata": { + "extensions": { + "com.example.policy": "dotted-key" + } + } + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { + "const": "Column" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component", + "children" + ] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { + "const": "required" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "formatString": { + "type": "object", + "description": "Interpolates data model values into a template string.", + "returnType": "string", + "properties": { + "@call": { + "const": "formatString" + }, + "args": { + "type": "object", + "properties": { + "value": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "value" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "openUrl": { + "type": "object", + "description": "Opens a URL in the renderer's browser or handler.", + "returnType": "void", + "requiresUserActivation": true, + "properties": { + "@call": { + "const": "openUrl" + }, + "args": { + "type": "object", + "properties": { + "url": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "url" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + }, + { + "$ref": "#/components/Button" + }, + { + "$ref": "#/components/Card" + }, + { + "$ref": "#/components/Column" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/required" + }, + { + "$ref": "#/functions/formatString" + }, + { + "$ref": "#/functions/openUrl" + } + ], + "discriminator": { + "propertyName": "@call" + } + } + } +} \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/catalogs/composition_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/composition_catalog_v1_0.json new file mode 100644 index 0000000000..e65fcab540 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/composition_catalog_v1_0.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/composition_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Composition Catalog (v1.0)", + "description": "A two component catalog whose components declare allowedParents and allowedChildren, for the timing cases of core/message_processor_scaling.yaml.", + "catalogId": "conformance/composition", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "allowedParents": ["Column"], + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["component", "text"] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "allowedParents": ["Surface", "Column"], + "allowedChildren": ["Text", "Column"], + "properties": { + "component": { "const": "Column" }, + "children": { "$ref": "common_types.json#/$defs/ChildList" } + }, + "required": ["component", "children"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { "$ref": "#/components/Text" }, + { "$ref": "#/components/Column" } + ], + "discriminator": { "propertyName": "component" } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/custom_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/custom_catalog_v1_0.json new file mode 100644 index 0000000000..a86a0ee17b --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/custom_catalog_v1_0.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/custom_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Custom Catalog (v1.0)", + "description": "A second catalog used by the multi catalog and capability negotiation cases. Its component and function names do not overlap with the basic catalog, so a case can tell the two apart by name alone.", + "catalogId": "conformance/custom", + "components": { + "Chart": { + "type": "object", + "description": "Plots a series of numbers.", + "properties": { + "component": {"const": "Chart"}, + "values": {"type": "array", "items": {"type": "number"}}, + "caption": {"$ref": "common_types.json#/$defs/DynamicString"} + }, + "required": ["component", "values"] + }, + "Gauge": { + "type": "object", + "description": "Shows a single value on a scale.", + "properties": { + "component": {"const": "Gauge"}, + "value": {"type": "number"} + }, + "required": ["component", "value"] + } + }, + "functions": { + "percent": { + "type": "object", + "description": "Renders a ratio as a percentage string.", + "returnType": "string", + "properties": { + "@call": {"const": "percent"}, + "args": { + "type": "object", + "properties": {"value": {"type": "number"}}, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [{"$ref": "#/components/Chart"}, {"$ref": "#/components/Gauge"}] + }, + "anyFunction": { + "oneOf": [{"$ref": "#/functions/percent"}] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/derive_source_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/derive_source_catalog_v1_0.json new file mode 100644 index 0000000000..7728c5115e --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/derive_source_catalog_v1_0.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/catalogs/v10/derive_source.json", + "title": "Derive source", + "description": "The catalog the derive cases start from.", + "catalogId": "https://a2ui.org/catalogs/v10/derive_source", + "protocolVersion": "1.0", + "instructions": "Write components of https://a2ui.org/catalogs/v10/derive_source.", + "components": { + "Text": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ] + }, + "Image": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "Button": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + } + } + }, + "functions": { + "upper": { + "type": "object", + "properties": { + "call": { + "const": "upper" + }, + "args": { + "type": "object" + }, + "returnType": { + "const": "string" + } + } + }, + "lower": { + "type": "object", + "properties": { + "call": { + "const": "lower" + }, + "args": { + "type": "object" + }, + "returnType": { + "const": "string" + } + } + } + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string" + } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/forms_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/forms_catalog_v1_0.json new file mode 100644 index 0000000000..c604346cc8 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/forms_catalog_v1_0.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/forms_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Forms Catalog (v1.0)", + "description": "An input catalog for the cases the simplified catalog cannot express: a component with enough optional properties to skip one in the middle, and a component that takes validation checks. Common types come from the protocol's common_types.json.", + "catalogId": "conformance/forms", + "components": { + "TextField": { + "type": "object", + "description": "A single line text input.", + "properties": { + "component": { "const": "TextField" }, + "label": { "$ref": "common_types.json#/$defs/DynamicString" }, + "value": { "$ref": "common_types.json#/$defs/DataBinding" }, + "placeholder": { "$ref": "common_types.json#/$defs/DynamicString" }, + "checks": { + "type": "array", + "items": { "$ref": "common_types.json#/$defs/CheckRule" } + } + }, + "required": ["component", "label"] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { "const": "required" }, + "args": { + "type": "object", + "properties": { "value": {} }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + }, + "regex": { + "type": "object", + "description": "Checks the value against a regular expression.", + "returnType": "validationResult", + "properties": { + "@call": { "const": "regex" }, + "args": { + "type": "object", + "properties": { + "value": {}, + "pattern": { "type": "string" } + }, + "required": ["value", "pattern"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [{ "$ref": "#/components/TextField" }], + "discriminator": { "propertyName": "component" } + }, + "anyFunction": { + "oneOf": [ + { "$ref": "#/functions/required" }, + { "$ref": "#/functions/regex" } + ], + "discriminator": { "propertyName": "@call" } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/function_metadata_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/function_metadata_catalog_v1_0.json new file mode 100644 index 0000000000..d9210ad835 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/function_metadata_catalog_v1_0.json @@ -0,0 +1,73 @@ +{ + "protocolVersion": "1.0", + "catalogId": "https://a2ui.org/catalogs/v10/function_metadata", + "components": { + "Spacer": { + "type": "object", + "allowedParents": [ + "Surface", + "Spacer" + ], + "allowedChildren": [ + "Spacer" + ], + "properties": { + "component": { + "const": "Spacer" + } + }, + "required": [ + "component" + ] + } + }, + "functions": { + "launch": { + "type": "object", + "description": "Launches a URL.", + "returnType": "void", + "allowedCallers": "rendererOnly", + "requiresUserActivation": true, + "properties": { + "@call": { + "const": "launch" + }, + "args": { + "type": "object", + "properties": { + "url": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "url" + ], + "unevaluatedProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Spacer" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "oneOf": [ + { + "$ref": "#/functions/launch" + } + ] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/list_item_action_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/list_item_action_catalog_v1_0.json new file mode 100644 index 0000000000..9db00f3100 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/list_item_action_catalog_v1_0.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/list_item_action_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance List Item Action Catalog (v1.0)", + "description": "A catalog whose component carries actions inside list items, for testing that an Express function call in a nested action slot compiles to a functionCall action.", + "catalogId": "conformance/list_item_action", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["component", "text"] + }, + "Links": { + "type": "object", + "description": "A list of labelled links, each firing an action when chosen.", + "properties": { + "component": { "const": "Links" }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "action": { "$ref": "common_types.json#/$defs/Action" } + }, + "required": ["label", "action"] + } + }, + "primary": { "$ref": "common_types.json#/$defs/Action" } + }, + "required": ["component", "items"] + }, + "Tabs": { + "type": "object", + "description": "A tabbed view whose tabItems carry a title and a Child componentId.", + "properties": { + "component": { "const": "Tabs" }, + "tabItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { "$ref": "common_types.json#/$defs/DynamicString" }, + "child": { "$ref": "common_types.json#/$defs/Child" } + }, + "required": ["title", "child"] + } + } + }, + "required": ["component", "tabItems"] + } + }, + "functions": { + "openUrl": { + "type": "object", + "description": "Opens a URL.", + "returnType": "void", + "properties": { + "@call": { "const": "openUrl" }, + "args": { + "type": "object", + "properties": { + "url": { "type": "string" } + }, + "required": ["url"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/literal_map_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/literal_map_catalog_v1_0.json new file mode 100644 index 0000000000..dfc2c4ab16 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/literal_map_catalog_v1_0.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/literal_map_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Literal Map Catalog (v1.0)", + "description": "A catalog for testing that Express map literals keep their keys, including keys that share a name with an Express construct such as variable, path, call or check.", + "catalogId": "conformance/literal_map", + "components": { + "Probe": { + "type": "object", + "description": "Carries an arbitrary object payload.", + "properties": { + "component": { "const": "Probe" }, + "payload": { "type": "object" } + }, + "required": ["component", "payload"] + } + }, + "functions": { + "series": { + "type": "object", + "description": "Repeats an item a number of times, naming the loop variable.", + "returnType": "array", + "properties": { + "@call": { "const": "series" }, + "args": { + "type": "object", + "properties": { + "count": { "type": "integer" }, + "variable": { "type": "string" }, + "item": { "type": "object" } + }, + "required": ["count", "item"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/simplified_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/simplified_catalog_v1_0.json new file mode 100644 index 0000000000..bd577e6741 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/simplified_catalog_v1_0.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/simplified_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Simplified Catalog (v1.0)", + "description": "A four component catalog for the agent SDK conformance suites. Like a published catalog it takes its common types from the protocol's common_types.json and defines only anyComponent and anyFunction itself.", + "catalogId": "conformance/simplified", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" }, + "variant": { + "type": "string", + "enum": ["body", "caption"], + "default": "body" + } + }, + "required": ["component", "text"] + }, + "Button": { + "type": "object", + "description": "A control that emits an action when pressed.", + "properties": { + "component": { "const": "Button" }, + "child": { "$ref": "common_types.json#/$defs/Child" }, + "action": { "$ref": "common_types.json#/$defs/Action" } + }, + "required": ["component", "child", "action"] + }, + "Card": { + "type": "object", + "description": "A container holding a single child.", + "properties": { + "component": { "const": "Card" }, + "child": { "$ref": "common_types.json#/$defs/Child" } + }, + "required": ["component", "child"] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { "const": "Column" }, + "children": { "$ref": "common_types.json#/$defs/ChildList" } + }, + "required": ["component", "children"] + } + }, + "functions": { + "required": { + "type": "object", + "description": "Checks that the value is not null, undefined or empty.", + "returnType": "validationResult", + "properties": { + "@call": { "const": "required" }, + "args": { + "type": "object", + "properties": { "value": {} }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + }, + "formatString": { + "type": "object", + "description": "Interpolates data model values into a template string.", + "returnType": "string", + "properties": { + "@call": { "const": "formatString" }, + "args": { + "type": "object", + "properties": { + "value": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["value"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + }, + "openUrl": { + "type": "object", + "description": "Opens a URL in the renderer's browser or handler.", + "returnType": "void", + "requiresUserActivation": true, + "properties": { + "@call": { "const": "openUrl" }, + "args": { + "type": "object", + "properties": { + "url": { "$ref": "common_types.json#/$defs/DynamicString" } + }, + "required": ["url"], + "unevaluatedProperties": false + } + }, + "required": ["@call", "args"] + } + }, + "$defs": { + "anyComponent": { + "oneOf": [ + { "$ref": "#/components/Text" }, + { "$ref": "#/components/Button" }, + { "$ref": "#/components/Card" }, + { "$ref": "#/components/Column" } + ], + "discriminator": { "propertyName": "component" } + }, + "anyFunction": { + "oneOf": [ + { "$ref": "#/functions/required" }, + { "$ref": "#/functions/formatString" }, + { "$ref": "#/functions/openUrl" } + ], + "discriminator": { "propertyName": "@call" } + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/catalogs/weighted_catalog_v1_0.json b/package/__fixtures__/a2ui-conformance/catalogs/weighted_catalog_v1_0.json new file mode 100644 index 0000000000..af7c5974b1 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/catalogs/weighted_catalog_v1_0.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/conformance/test_data/catalogs/weighted_catalog_v1_0.json", + "protocolVersion": "1.0", + "title": "Conformance Weighted Catalog (v1.0)", + "description": "A catalog for testing positional numeric argument placement when optional enum properties precede a numeric property such as weight.", + "catalogId": "conformance/weighted", + "components": { + "Text": { + "type": "object", + "description": "Displays a run of text.", + "properties": { + "component": { "const": "Text" }, + "text": { "$ref": "common_types.json#/$defs/DynamicString" }, + "variant": { + "type": "string", + "enum": ["body", "caption"], + "default": "body" + }, + "weight": { "type": "number" } + }, + "required": ["component", "text"] + }, + "Column": { + "type": "object", + "description": "A layout that stacks its children vertically.", + "properties": { + "component": { "const": "Column" }, + "children": { "$ref": "common_types.json#/$defs/ChildList" }, + "justify": { + "type": "string", + "enum": ["start", "center", "end"], + "default": "start" + }, + "align": { + "type": "string", + "enum": ["start", "center", "end"], + "default": "start" + }, + "weight": { "type": "number" } + }, + "required": ["component", "children"] + }, + "Image": { + "type": "object", + "description": "Displays an image.", + "properties": { + "component": { "const": "Image" }, + "url": { "$ref": "common_types.json#/$defs/DynamicString" }, + "description": { "$ref": "common_types.json#/$defs/DynamicString" }, + "weight": { "type": "number" } + }, + "required": ["component", "url"] + }, + "TextField": { + "type": "object", + "description": "A single-line text input.", + "properties": { + "component": { "const": "TextField" }, + "label": { "$ref": "common_types.json#/$defs/DynamicString" }, + "value": { "$ref": "common_types.json#/$defs/DataBinding" }, + "placeholder": { "$ref": "common_types.json#/$defs/DynamicString" }, + "weight": { "type": "number" } + }, + "required": ["component", "label"] + }, + "Slider": { + "type": "object", + "description": "A slider input whose value is a DynamicNumber ref.", + "properties": { + "component": { "const": "Slider" }, + "orientation": { + "type": "string", + "enum": ["horizontal", "vertical"], + "default": "horizontal" + }, + "value": { "$ref": "common_types.json#/$defs/DynamicNumber" } + }, + "required": ["component"] + }, + "DualMetric": { + "type": "object", + "description": "A component with two later numeric properties.", + "properties": { + "component": { "const": "DualMetric" }, + "variant": { + "type": "string", + "enum": ["a", "b"], + "default": "a" + }, + "min": { "type": "number" }, + "max": { "type": "number" } + }, + "required": ["component"] + }, + "Status": { + "type": "object", + "description": "A status component with a required enum property.", + "properties": { + "component": { "const": "Status" }, + "state": { + "type": "string", + "enum": ["active", "inactive"] + }, + "priority": { "type": "number" } + }, + "required": ["component", "state"] + }, + "Meter": { + "type": "object", + "description": "A meter component with title, optional unit enum, numeric level, and optional description.", + "properties": { + "component": { "const": "Meter" }, + "label": { "$ref": "common_types.json#/$defs/DynamicString" }, + "unit": { + "type": "string", + "enum": ["percent", "ratio"], + "default": "percent" + }, + "level": { "type": "number" }, + "description": { "type": "string" } + }, + "required": ["component", "label"] + } + }, + "functions": {}, + "$defs": { + "anyComponent": { + "oneOf": [ + { "$ref": "#/components/Text" }, + { "$ref": "#/components/Column" }, + { "$ref": "#/components/Image" }, + { "$ref": "#/components/TextField" }, + { "$ref": "#/components/Slider" }, + { "$ref": "#/components/DualMetric" }, + { "$ref": "#/components/Status" }, + { "$ref": "#/components/Meter" } + ], + "discriminator": { "propertyName": "component" } + }, + "anyFunction": { + "not": {} + } + } +} diff --git a/package/__fixtures__/a2ui-conformance/core/accessibility.json b/package/__fixtures__/a2ui-conformance/core/accessibility.json new file mode 100644 index 0000000000..54ccd426f4 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/accessibility.json @@ -0,0 +1,722 @@ +[ + { + "name": "test_accessibility_attributes_plumbing", + "description": "Verifies plumbing of AccessibilityAttributes (label, description, live, hidden) to accessibility node trees and axe-core rules.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "btn1", + "status_text" + ] + }, + { + "id": "btn1", + "component": "Button", + "child": "btn1_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Mute Notifications", + "description": "Silences notifications about this conversation" + } + }, + { + "id": "btn1_label", + "component": "Text", + "text": "Mute" + }, + { + "id": "status_text", + "component": "Text", + "text": "Status: Active", + "accessibility": { + "live": "polite" + } + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "btn1", + "status_text" + ], + "components": { + "btn1": { + "component": "Button", + "child": "btn1_label", + "title": "Mute", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Mute Notifications", + "description": "Silences notifications about this conversation" + } + }, + "btn1_label": { + "component": "Text", + "text": "Mute" + }, + "status_text": { + "component": "Text", + "text": "Status: Active", + "accessibility": { + "live": "polite" + } + } + } + }, + "assertions": { + "axeCore": [ + "button-name", + "aria-valid-attr-value", + "aria-allowed-attr" + ], + "accessibilityTree": { + "btn1": { + "label": "Mute Notifications", + "description": "Silences notifications about this conversation" + }, + "status_text": { + "live": "polite" + } + } + } + }, + { + "name": "test_accessibility_live_region_assertive", + "description": "Verifies assertive live region announcement for critical alert banner updates.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Card", + "child": "alert_box" + }, + { + "id": "alert_box", + "component": "Text", + "text": "Connection Lost!", + "accessibility": { + "label": "Critical Alert", + "live": "assertive" + } + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Card", + "child": "alert_box", + "components": { + "alert_box": { + "component": "Text", + "text": "Connection Lost!", + "accessibility": { + "label": "Critical Alert", + "live": "assertive" + } + } + } + }, + "assertions": { + "axeCore": [ + "aria-valid-attr-value" + ], + "accessibilityTree": { + "alert_box": { + "label": "Critical Alert", + "live": "assertive" + } + } + } + }, + { + "name": "test_accessibility_hidden_subtree", + "description": "Verifies that setting hidden=true hides the component and its children from assistive technologies.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "accessibility": { + "hidden": true + }, + "children": [ + "visible_text", + "decorative_icon" + ] + }, + { + "id": "visible_text", + "component": "Text", + "text": "Visible Content" + }, + { + "id": "decorative_icon", + "component": "Icon", + "name": "star" + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "accessibility": { + "hidden": true + }, + "children": [ + "visible_text", + "decorative_icon" + ], + "components": { + "visible_text": { + "component": "Text", + "text": "Visible Content" + }, + "decorative_icon": { + "component": "Icon", + "name": "star" + } + } + }, + "assertions": { + "axeCore": [ + "aria-hidden-focus" + ], + "accessibilityTree": { + "root": { + "hidden": true + } + } + } + }, + { + "name": "test_accessibility_implicit_label_inference", + "description": "Verifies default screen reader semantic inference from visible text properties when explicit accessibility attributes are omitted.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "submit_btn" + ] + }, + { + "id": "submit_btn", + "component": "Button", + "child": "submit_btn_label", + "action": { + "event": { + "name": "tap" + } + } + }, + { + "id": "submit_btn_label", + "component": "Text", + "text": "Submit Form" + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "submit_btn" + ], + "components": { + "submit_btn": { + "component": "Button", + "child": "submit_btn_label", + "title": "Submit Form", + "action": { + "event": { + "name": "tap" + } + } + }, + "submit_btn_label": { + "component": "Text", + "text": "Submit Form" + } + } + }, + "assertions": { + "axeCore": [ + "button-name" + ], + "accessibilityTree": { + "submit_btn": { + "label": "Submit Form" + } + } + } + }, + { + "name": "test_accessibility_interactive_roles_and_states", + "description": "Verifies accessibility role mapping and ARIA states for Button, TextField, CheckBox, ChoicePicker.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "btn", + "text_input", + "check_box", + "choice_picker" + ] + }, + { + "id": "btn", + "component": "Button", + "child": "btn_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Submit Feedback" + } + }, + { + "id": "btn_label", + "component": "Text", + "text": "Submit" + }, + { + "id": "text_input", + "component": "TextField", + "label": "Your Email", + "value": "", + "accessibility": { + "description": "Enter a valid email address" + } + }, + { + "id": "check_box", + "component": "CheckBox", + "label": "I agree to terms", + "value": true, + "accessibility": { + "label": "Accept Terms and Conditions" + } + }, + { + "id": "choice_picker", + "component": "ChoicePicker", + "variant": "mutuallyExclusive", + "value": [ + "1" + ], + "options": [ + { + "label": "Option 1", + "value": "1" + }, + { + "label": "Option 2", + "value": "2" + } + ], + "accessibility": { + "label": "Select Preference" + } + } + ] + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "btn", + "text_input", + "check_box", + "choice_picker" + ], + "components": { + "btn": { + "component": "Button", + "child": "btn_label", + "title": "Submit", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": "Submit Feedback" + } + }, + "btn_label": { + "component": "Text", + "text": "Submit" + }, + "text_input": { + "component": "TextField", + "label": "Your Email", + "value": "", + "accessibility": { + "description": "Enter a valid email address" + } + }, + "check_box": { + "component": "CheckBox", + "label": "I agree to terms", + "value": true, + "checked": true, + "accessibility": { + "label": "Accept Terms and Conditions" + } + }, + "choice_picker": { + "component": "ChoicePicker", + "variant": "mutuallyExclusive", + "value": [ + "1" + ], + "options": [ + { + "label": "Option 1", + "value": "1" + }, + { + "label": "Option 2", + "value": "2" + } + ], + "selectedIndex": 0, + "accessibility": { + "label": "Select Preference" + } + } + } + }, + "assertions": { + "axeCore": [ + "button-name", + "aria-valid-attr-value", + "aria-allowed-attr" + ], + "accessibilityTree": { + "btn": { + "label": "Submit Feedback", + "role": "button" + }, + "text_input": { + "description": "Enter a valid email address", + "role": "textbox" + }, + "check_box": { + "label": "Accept Terms and Conditions", + "role": "checkbox", + "checked": true + }, + "choice_picker": { + "label": "Select Preference", + "role": "radiogroup" + } + } + } + }, + { + "name": "test_accessibility_dynamic_label_and_hidden_binding", + "description": "Verifies dynamic data bindings in accessibility attributes (label, hidden) reactively update accessibility trees.", + "protocolVersion": "1.0", + "catalogPath": "catalogs/basic/v1/catalog.json", + "action": "accessibility_check", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "dataModel": { + "buttonLabel": "Mute Audio", + "isButtonHidden": false, + "isStatusHidden": true + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "dynamic_btn", + "dynamic_status" + ] + }, + { + "id": "dynamic_btn", + "component": "Button", + "child": "dynamic_btn_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": { + "@path": "/buttonLabel" + }, + "hidden": { + "@path": "/isButtonHidden" + } + } + }, + { + "id": "dynamic_btn_label", + "component": "Text", + "text": "Dynamic Action" + }, + { + "id": "dynamic_status", + "component": "Text", + "text": "Status", + "accessibility": { + "hidden": { + "@path": "/isStatusHidden" + } + } + } + ] + } + } + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "a11y_surface", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "dataModel": { + "buttonLabel": "Mute Audio", + "isButtonHidden": false, + "isStatusHidden": true + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "dynamic_btn", + "dynamic_status" + ] + }, + { + "id": "dynamic_btn", + "component": "Button", + "child": "dynamic_btn_label", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": { + "@path": "/buttonLabel" + }, + "hidden": { + "@path": "/isButtonHidden" + } + } + }, + { + "id": "dynamic_btn_label", + "component": "Text", + "text": "Dynamic Action" + }, + { + "id": "dynamic_status", + "component": "Text", + "text": "Status", + "accessibility": { + "hidden": { + "@path": "/isStatusHidden" + } + } + } + ] + } + } + ], + "assertions": { + "axeCore": [ + "button-name", + "aria-hidden-focus" + ], + "accessibilityTree": { + "dynamic_btn": { + "label": "Mute Audio", + "hidden": false + }, + "dynamic_status": { + "hidden": true + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "a11y_surface", + "path": "/buttonLabel", + "value": "Unmute Audio" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "a11y_surface", + "path": "/isButtonHidden", + "value": true + } + } + ], + "assertions": { + "axeCore": [ + "button-name", + "aria-hidden-focus" + ], + "accessibilityTree": { + "dynamic_btn": { + "label": "Unmute Audio", + "hidden": true + }, + "dynamic_status": { + "hidden": true + } + } + } + } + ], + "surface": { + "id": "root", + "component": "Column", + "children": [ + "dynamic_btn", + "dynamic_status" + ], + "components": { + "dynamic_btn": { + "component": "Button", + "child": "dynamic_btn_label", + "title": "Dynamic Action", + "action": { + "event": { + "name": "tap" + } + }, + "accessibility": { + "label": { + "@path": "/buttonLabel" + }, + "hidden": { + "@path": "/isButtonHidden" + } + } + }, + "dynamic_btn_label": { + "component": "Text", + "text": "Dynamic Action" + }, + "dynamic_status": { + "component": "Text", + "text": "Status", + "accessibility": { + "hidden": { + "@path": "/isStatusHidden" + } + } + } + } + }, + "assertions": { + "axeCore": [ + "button-name", + "aria-hidden-focus" + ], + "accessibilityTree": { + "dynamic_btn": { + "label": { + "path": "/buttonLabel" + }, + "hidden": { + "path": "/isButtonHidden" + } + }, + "dynamic_status": { + "hidden": { + "path": "/isStatusHidden" + } + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/actions.json b/package/__fixtures__/a2ui-conformance/core/actions.json new file mode 100644 index 0000000000..2570480951 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/actions.json @@ -0,0 +1,458 @@ +[ + { + "name": "test_action_event_basic", + "description": "Verifies dispatching a basic server event with name only.", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "submit" + } + }, + "expectDispatched": { + "name": "submit" + } + }, + { + "name": "test_action_event_with_literal_context", + "description": "Verifies event action preserves literal context properties.", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "filter_change", + "context": { + "query": "books", + "limit": 10, + "active": true + } + } + }, + "expectDispatched": { + "name": "filter_change", + "context": { + "query": "books", + "limit": 10, + "active": true + } + } + }, + { + "name": "test_action_event_context_literal_keys_path_and_call", + "description": "Verifies context keys literally named 'path' and 'call' are preserved as literal keys (D1).", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "save", + "context": { + "path": "/custom/file.txt", + "call": "external", + "regular": "data" + } + } + }, + "expectDispatched": { + "name": "save", + "context": { + "path": "/custom/file.txt", + "call": "external", + "regular": "data" + } + } + }, + { + "name": "test_action_event_with_data_binding", + "description": "Verifies event action dynamically resolves bound context values from DataModel.", + "action": "dispatch_action", + "dataModel": { + "user": { + "id": "u_123", + "role": "admin" + } + }, + "actionPayload": { + "event": { + "name": "user_action", + "context": { + "userId": { + "path": "/user/id" + }, + "role": { + "path": "/user/role" + }, + "staticKey": "constant" + } + } + }, + "expectDispatched": { + "name": "user_action", + "context": { + "userId": "u_123", + "role": "admin", + "staticKey": "constant" + } + } + }, + { + "name": "test_action_event_with_relative_scope", + "description": "Verifies event action resolves relative data bindings against the provided scope.", + "action": "dispatch_action", + "scope": "/items/1", + "dataModel": { + "items": [ + { + "title": "Item 0", + "id": "id_0" + }, + { + "title": "Item 1", + "id": "id_1" + } + ] + }, + "actionPayload": { + "event": { + "name": "select_item", + "context": { + "selectedId": { + "path": "id" + }, + "selectedTitle": { + "path": "title" + } + } + } + }, + "expectDispatched": { + "name": "select_item", + "context": { + "selectedId": "id_1", + "selectedTitle": "Item 1" + } + } + }, + { + "name": "test_action_event_with_user_message", + "description": "Verifies event action preserves and dispatches userMessage.", + "action": "dispatch_action", + "actionPayload": { + "event": { + "name": "feedback", + "userMessage": "User requested additional details", + "context": { + "score": 5 + } + } + }, + "expectDispatched": { + "name": "feedback", + "userMessage": "User requested additional details", + "context": { + "score": 5 + } + } + }, + { + "name": "test_action_direct_name_shorthand", + "description": "Verifies v0.9 direct action name shorthand is dispatched as an event.", + "action": "dispatch_action", + "actionPayload": { + "name": "click", + "context": { + "target": "btn1" + } + }, + "expectDispatched": { + "name": "click", + "context": { + "target": "btn1" + } + } + }, + { + "name": "test_action_event_with_dispatch_metadata", + "description": "Verifies that metadata passed to dispatchAction is attached to the dispatched action as action.metadata (v1.0 extension envelope).", + "action": "dispatch_action", + "protocolVersion": "1.0", + "actionPayload": { + "event": { + "name": "submit", + "context": { + "form": "signup" + } + } + }, + "metadata": { + "extensions": { + "com_example_attestation": "signed", + "a2ui_experimental": { + "trace": 7 + } + } + }, + "expectDispatched": { + "name": "submit", + "context": { + "form": "signup" + }, + "metadata": { + "extensions": { + "com_example_attestation": "signed", + "a2ui_experimental": { + "trace": 7 + } + } + } + } + }, + { + "name": "test_action_event_without_dispatch_metadata_has_none", + "description": "Verifies that an action dispatched without metadata carries no action.metadata envelope.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "actionPayload": { + "event": { + "name": "submit" + } + }, + "expectDispatched": { + "name": "submit", + "metadata": null + } + }, + { + "name": "test_action_event_dispatch_metadata_invalid_uax31_key_error", + "description": "Verifies that dispatchAction rejects metadata whose extensions key is not a UAX 31 identifier and dispatches nothing.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "actionPayload": { + "event": { + "name": "submit" + } + }, + "metadata": { + "extensions": { + "com-example-attestation": "signed" + } + }, + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_action_v10_event_with_at_path_and_at_call_context", + "description": "Verifies v1.0 @path and @call context bindings, @@ literal prefix unescaping, and @index inside template scope.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "row_btn", + "scope": "/items/1", + "dataModel": { + "items": [ + { + "title": "First" + }, + { + "title": "Second" + } + ] + }, + "actionPayload": { + "event": { + "name": "item_selected", + "context": { + "itemTitle": { + "@path": "title" + }, + "upperTitle": { + "@call": "formatString", + "args": { + "value": "Selected: ${title}" + } + }, + "itemIndex": { + "@call": "@index" + }, + "oneBasedIndex": { + "@call": "@index", + "args": { + "offset": 1 + } + }, + "escapedKey": { + "@@path": "/literal/not/bound" + } + } + } + }, + "expectDispatched": { + "name": "item_selected", + "surfaceId": "main_surface", + "sourceComponentId": "row_btn", + "timestampIso8601": true, + "context": { + "itemTitle": "Second", + "upperTitle": "Selected: Second", + "itemIndex": 1, + "oneBasedIndex": 2, + "escapedKey": { + "@path": "/literal/not/bound" + } + } + } + }, + { + "name": "test_action_v10_event_context_literal_path_and_call_keys", + "description": "Verifies v1.0 literal 'path' and 'call' keys inside event.context are preserved as literal keys through GenericBinder / NodeResolver without being misread as v0.9 bindings.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "save_btn", + "dataModel": { + "user": "Ada" + }, + "actionPayload": { + "event": { + "name": "save_document", + "context": { + "path": "/documents/report.md", + "call": "sync", + "author": { + "@path": "/user" + } + } + } + }, + "expectDispatched": { + "name": "save_document", + "surfaceId": "main_surface", + "sourceComponentId": "save_btn", + "timestampIso8601": true, + "context": { + "path": "/documents/report.md", + "call": "sync", + "author": "Ada" + } + } + }, + { + "name": "test_action_function_call_local_mutation_no_event_dispatched", + "description": "Verifies a local functionCall action executes locally, mutates dataModel, and dispatches no outbound event (expectDispatched is null).", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "inc_btn", + "dataModel": { + "count": 1 + }, + "actionPayload": { + "functionCall": { + "@call": "incrementCounter", + "args": { + "path": "/count", + "step": 2 + } + } + }, + "expectDataModel": { + "count": 3 + }, + "expectDispatched": null + }, + { + "name": "test_action_function_call_open_url_with_user_activation_allowed", + "description": "Verifies openUrl functionCall action succeeds when userActivation is true and dispatches no outbound event.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "link_btn", + "userActivation": true, + "actionPayload": { + "functionCall": { + "@call": "openUrl", + "args": { + "url": "https://example.com" + } + } + }, + "expectDispatched": null + }, + { + "name": "test_action_function_call_open_url_without_user_activation_denied", + "description": "Verifies openUrl functionCall action fails when userActivation is false and emits EXECUTION_ERROR / INVALID_ACTION on surface.onError.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "link_btn", + "userActivation": false, + "actionPayload": { + "functionCall": { + "@call": "openUrl", + "args": { + "url": "https://example.com" + } + } + }, + "expectDispatched": null, + "expectError": { + "surfaceId": "main_surface", + "message": "openUrl" + } + }, + { + "name": "test_action_event_missing_name_error", + "description": "Verifies dispatch_action with a missing event name emits INVALID_ACTION on surface.onError.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "bad_btn", + "actionPayload": { + "event": { + "context": { + "foo": "bar" + } + } + }, + "expectDispatched": null, + "expectError": { + "code": "INVALID_ACTION", + "surfaceId": "main_surface" + } + }, + { + "name": "test_action_event_non_string_user_message_error", + "description": "Verifies dispatch_action with a non-string userMessage emits INVALID_ACTION on surface.onError.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "bad_msg_btn", + "actionPayload": { + "event": { + "name": "submit", + "userMessage": 12345 + } + }, + "expectDispatched": null, + "expectError": { + "code": "INVALID_ACTION", + "surfaceId": "main_surface" + } + }, + { + "name": "test_action_function_call_execution_error", + "description": "Verifies a failing local functionCall action emits EXECUTION_ERROR on surface.onError and dispatches no outbound event.", + "action": "dispatch_action", + "protocolVersion": "1.0", + "surfaceId": "main_surface", + "sourceComponentId": "fail_btn", + "actionPayload": { + "functionCall": { + "@call": "failingAction", + "args": {} + } + }, + "expectDispatched": null, + "expectError": { + "code": "EXECUTION_ERROR", + "surfaceId": "main_surface" + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/composition_constraints.json b/package/__fixtures__/a2ui-conformance/core/composition_constraints.json new file mode 100644 index 0000000000..201b787401 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/composition_constraints.json @@ -0,0 +1,734 @@ +[ + { + "name": "test_composition_surface_implicit_parent_container", + "description": "Verifies root components treating Surface as canonical parent container when allowedParents includes Surface.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "t1" + ] + }, + { + "id": "t1", + "component": "Text", + "text": "Hello World" + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "components": { + "root": { + "component": "Column", + "children": [ + "t1" + ] + }, + "t1": { + "component": "Text", + "text": "Hello World" + } + } + } + } + } + }, + { + "name": "test_composition_unallowed_parent_error", + "description": "Verifies validation error thrown when component is placed under an unallowed parent component type.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "ColumnHeader": { + "type": "object", + "allowedParents": [ + "Column" + ] + }, + "Row": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Row", + "children": [ + "invalid_header" + ] + }, + { + "id": "invalid_header", + "component": "ColumnHeader" + } + ] + } + } + ] + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/1", + "message": "Component 'invalid_header' (ColumnHeader) cannot be placed under parent 'root' (Row). Allowed parents: ['Column'].", + "details": [ + { + "path": "/components/1", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/1", + "message": "Component 'invalid_header' (ColumnHeader) cannot be placed under parent 'root' (Row). Allowed parents: ['Column']." + } + } + } + }, + { + "name": "test_composition_unallowed_parent_root_error", + "description": "Verifies validation error thrown when root component does not allow Surface as its implicit parent.", + "catalog": { + "protocolVersion": "1.0", + "catalogSchema": { + "catalogId": "custom", + "components": { + "ColumnHeader": { + "allowedParents": [ + "Column" + ] + } + } + } + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "ColumnHeader" + } + ] + } + } + ] + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/0", + "message": "Component 'root' (ColumnHeader) cannot be placed under parent 'Surface' (Surface). Allowed parents: ['Column'].", + "details": [ + { + "path": "/components/0", + "code": "UNALLOWED_PARENT", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "s1", + "path": "/components/0", + "message": "Component 'root' (ColumnHeader) cannot be placed under parent 'Surface' (Surface). Allowed parents: ['Column']." + } + } + } + }, + { + "name": "test_composition_unallowed_child_error", + "description": "Verifies validation error thrown when container contains an unallowed child component type.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "RestrictedBox": { + "type": "object", + "allowedChildren": [ + "Text", + "Button" + ], + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Video": { + "type": "object" + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "RestrictedBox", + "children": [ + "v1" + ] + }, + { + "id": "v1", + "component": "Video" + } + ] + } + } + ] + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_CHILD", + "surfaceId": "s1", + "path": "/components/0/children/0", + "message": "Container 'root' (RestrictedBox) cannot contain child 'v1' (Video). Allowed children: ['Text', 'Button'].", + "details": [ + { + "path": "/components/0/children/0", + "code": "UNALLOWED_CHILD", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_CHILD", + "surfaceId": "s1", + "path": "/components/0/children/0", + "message": "Container 'root' (RestrictedBox) cannot contain child 'v1' (Video). Allowed children: ['Text', 'Button']." + } + } + } + }, + { + "name": "test_composition_dropped_child_is_not_checked_against_surface", + "description": "A container rewritten to drop a child leaves a component that nothing references. Only the surface's root is checked against Surface in allowedParents, so when orphans are allowed the update is accepted, even though the child's allowedParents does not include Surface. The other checks are on, so the processor checks the whole surface.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": true + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Box", + "children": [] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "components": { + "root": { + "component": "Box", + "children": [] + }, + "h": { + "component": "Header" + } + } + } + } + } + }, + { + "name": "test_composition_dropped_child_is_not_checked_against_surface_when_references_may_dangle", + "description": "The same payloads as test_composition_dropped_child_is_not_checked_against_surface, when references to components that have not arrived are also allowed. An SDK may then check only the components each update changes; the dropped child is still not checked against Surface.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": true, + "allowDanglingReferences": true + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Box", + "children": [] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "components": { + "root": { + "component": "Box", + "children": [] + }, + "h": { + "component": "Header" + } + } + } + } + } + }, + { + "name": "test_composition_dropped_child_is_reported_as_orphan", + "description": "The same payloads as test_composition_dropped_child_is_not_checked_against_surface, when orphans are not allowed. The topology check reports the dropped child as unreachable from the root; it is not reported as UNALLOWED_PARENT against Surface.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": false + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Box", + "children": [] + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "message": "Component 'h' is not reachable from 'root'" + } + } + ] + }, + { + "name": "test_composition_root_referenced_by_another_component_checked_against_surface", + "description": "The surface's root is checked against Surface in allowedParents even when another component also lists it as a child. The other component is an orphan, which the config allows.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Card": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": { + "allowOrphanComponents": true + }, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Card" + }, + { + "id": "box", + "component": "Box", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "message": "Component 'root' (Card) cannot be placed under parent 'Surface' (Surface). Allowed parents: ['Box']." + } + } + ] + }, + { + "name": "test_composition_multiple_parents_disallowed_first", + "description": "Every parent of a component is checked against its allowedParents. Here the disallowed parent comes first in surface order.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Card": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "card", + "b" + ] + }, + { + "id": "card", + "component": "Card", + "children": [ + "h" + ] + }, + { + "id": "b", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "message": "Component 'h' (Header) cannot be placed under parent 'card' (Card). Allowed parents: ['Box']." + } + } + ] + }, + { + "name": "test_composition_multiple_parents_disallowed_second", + "description": "The same as test_composition_multiple_parents_disallowed_first, with the disallowed parent second in surface order.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "Box": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Card": { + "type": "object", + "properties": { + "children": { + "$ref": "#/$defs/ChildList" + } + } + }, + "Header": { + "type": "object", + "allowedParents": [ + "Box" + ] + } + } + }, + "action": "process_messages", + "validationConfig": {}, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "Box", + "children": [ + "b", + "card" + ] + }, + { + "id": "b", + "component": "Box", + "children": [ + "h" + ] + }, + { + "id": "card", + "component": "Card", + "children": [ + "h" + ] + }, + { + "id": "h", + "component": "Header" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "message": "Component 'h' (Header) cannot be placed under parent 'card' (Card). Allowed parents: ['Box']." + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_context.json b/package/__fixtures__/a2ui-conformance/core/data_context.json new file mode 100644 index 0000000000..10c6456d07 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_context.json @@ -0,0 +1,144 @@ +[ + { + "name": "test_resolve_path_relative_against_context", + "description": "Resolves a relative path against a scoped context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "name", + "contextPath": "/user" + }, + "expect": "/user/name" + }, + { + "name": "test_resolve_path_nested_relative_against_context", + "description": "Resolves a multi-segment relative path against a scoped context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "address/city", + "contextPath": "/user" + }, + "expect": "/user/address/city" + }, + { + "name": "test_resolve_path_absolute_ignores_context", + "description": "An absolute path starting with `/` ignores the context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "/list/0", + "contextPath": "/user" + }, + "expect": "/list/0" + }, + { + "name": "test_resolve_path_relative_against_root", + "description": "Resolves a relative path against the root context `/`.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "user/name", + "contextPath": "/" + }, + "expect": "/user/name" + }, + { + "name": "test_resolve_path_relative_without_context", + "description": "Resolves a relative path when no context path is provided.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "foo" + }, + "expect": "/foo" + }, + { + "name": "test_resolve_path_context_with_trailing_slash", + "description": "Strips a trailing slash on the context path before joining a relative path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "foo", + "contextPath": "/bar/" + }, + "expect": "/bar/foo" + }, + { + "name": "test_resolve_path_empty_in_scoped_context", + "description": "An empty relative path resolves to the current context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "", + "contextPath": "/user" + }, + "expect": "/user" + }, + { + "name": "test_resolve_path_empty_in_trailing_slash_context", + "description": "An empty relative path in a context with a trailing slash resolves to the trimmed context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "", + "contextPath": "/user/" + }, + "expect": "/user" + }, + { + "name": "test_resolve_path_dot_in_scoped_context", + "description": "A `.` path resolves to the current context path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": ".", + "contextPath": "/user" + }, + "expect": "/user" + }, + { + "name": "test_resolve_path_empty_in_root_context", + "description": "An empty relative path in the root context resolves to `/`.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": "", + "contextPath": "/" + }, + "expect": "/" + }, + { + "name": "test_resolve_path_dot_in_root_context", + "description": "A `.` path in the root context resolves to `/`.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "resolve_path", + "args": { + "path": ".", + "contextPath": "/" + }, + "expect": "/" + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_deletion.json b/package/__fixtures__/a2ui-conformance/core/data_deletion.json new file mode 100644 index 0000000000..64471a4081 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_deletion.json @@ -0,0 +1,358 @@ +[ + { + "name": "test_data_deletion_value_null", + "description": "Verifies that updateDataModel with value=null deletes the specified JSON Pointer path.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": { + "profile": { + "name": "Alice", + "bio": "Software Engineer" + } + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/profile/bio", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "user": { + "profile": { + "name": "Alice" + } + } + } + } + } + } + }, + { + "name": "test_data_deletion_nested_pointer_sibling_preservation", + "description": "Verifies deleting a child property via value=null preserves sibling keys in parent object.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "settings": { + "notifications": { + "email": true, + "push": false, + "sms": true + } + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/settings/notifications/email", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "settings": { + "notifications": { + "push": false, + "sms": true + } + } + } + } + } + } + }, + { + "name": "test_data_deletion_top_level_key", + "description": "Verifies deleting a top-level JSON Pointer path via value=null.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "persistentData": "keep", + "transientData": "remove" + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/transientData", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "persistentData": "keep" + } + } + } + } + }, + { + "name": "test_data_deletion_parent_object_recursive", + "description": "Verifies deleting an intermediate parent object key via value=null recursively removes all nested child properties.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "settings": { + "notifications": { + "email": true, + "push": false, + "sms": true + }, + "theme": "dark" + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/settings/notifications", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "settings": { + "theme": "dark" + } + } + } + } + } + }, + { + "name": "test_data_deletion_all_keys_empty_data_model", + "description": "Verifies deleting the only top-level key via value=null results in an empty dataModel.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": { + "profile": { + "name": "Alice" + } + } + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": {} + } + } + } + }, + { + "name": "test_data_deletion_array_index_preservation", + "description": "Verifies deleting array element via value=null preserves outer array length and structure with null.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "a", + "b", + "c" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/1", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "items": [ + "a", + null, + "c" + ] + } + } + } + } + }, + { + "name": "test_data_deletion_root_replacement", + "description": "Verifies updating root path / with value=null resets data model to empty object.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": { + "name": "Alice" + }, + "count": 42 + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/", + "value": null + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": {} + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_model.json b/package/__fixtures__/a2ui-conformance/core/data_model.json new file mode 100644 index 0000000000..c4cf040e49 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_model.json @@ -0,0 +1,2012 @@ +[ + { + "name": "test_data_model_initializes_empty", + "description": "A model created without data starts as an empty object.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "steps": [ + { + "op": "get", + "path": "/", + "expect": {} + } + ] + }, + { + "name": "test_data_model_retrieves_root", + "description": "The root path returns the whole data model.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/", + "expect": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + } + } + ] + }, + { + "name": "test_data_model_retrieves_nested_path", + "description": "Nested object paths resolve segment by segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/user/name", + "expect": "Alice" + }, + { + "op": "get", + "path": "/user/settings/theme", + "expect": "dark" + } + ] + }, + { + "name": "test_data_model_retrieves_list_items", + "description": "List elements resolve by numeric segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/items/0", + "expect": "a" + }, + { + "op": "get", + "path": "/items/1", + "expect": "b" + } + ] + }, + { + "name": "test_data_model_absent_for_missing_paths", + "description": "Paths that do not exist resolve to no value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/user/age", + "expect_absent": true + }, + { + "op": "get", + "path": "/unknown/path", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_absent_through_empty_segment", + "description": "Traversing through a segment that holds no value yields no value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/nullable/inner", + "value": 1 + }, + { + "op": "delete", + "path": "/nullable/inner" + }, + { + "op": "get", + "path": "/nullable/deep/path", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_sets_existing_path", + "description": "Setting an existing path replaces its value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Bob" + }, + { + "op": "get", + "path": "/user/name", + "expect": "Bob" + } + ] + }, + { + "name": "test_data_model_sets_new_path", + "description": "Setting a new key on an existing object adds it.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/age", + "value": 30 + }, + { + "op": "get", + "path": "/user/age", + "expect": 30 + } + ] + }, + { + "name": "test_data_model_creates_intermediate_objects", + "description": "Missing intermediate objects are created on write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/a/b/c", + "value": "foo" + }, + { + "op": "get", + "path": "/a/b/c", + "expect": "foo" + }, + { + "op": "get", + "path": "/a/b", + "expect_type": "object" + } + ] + }, + { + "name": "test_data_model_delete_removes_key", + "description": "Deleting a path removes the key from its parent object.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "delete", + "path": "/user/name" + }, + { + "op": "get", + "path": "/user/name", + "expect_absent": true + }, + { + "op": "get", + "path": "/user", + "expect": { + "settings": { + "theme": "dark" + } + } + } + ] + }, + { + "name": "test_data_model_list_set_and_get", + "description": "Writing a numeric segment into a missing path creates a list.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/list/0", + "value": "hello" + }, + { + "op": "get", + "path": "/list/0", + "expect": "hello" + }, + { + "op": "get", + "path": "/list", + "expect_type": "list" + } + ] + }, + { + "name": "test_data_model_list_append", + "description": "Writing successive indices appends to a list.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/list/0", + "value": "hello" + }, + { + "op": "set", + "path": "/list/1", + "value": "world" + }, + { + "op": "get", + "path": "/list", + "expect": [ + "hello", + "world" + ] + } + ] + }, + { + "name": "test_data_model_list_update_index", + "description": "Writing an existing index replaces that element.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/1", + "value": "updated" + }, + { + "op": "get", + "path": "/items", + "expect": [ + "a", + "updated", + "c" + ] + } + ] + }, + { + "name": "test_data_model_creates_nested_structures", + "description": "Auto-vivification picks a list or an object based on the next segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/a/b/0/c", + "value": 123 + }, + { + "op": "get", + "path": "/a/b/0/c", + "expect": 123 + }, + { + "op": "get", + "path": "/a/b", + "expect_type": "list" + }, + { + "op": "get", + "path": "/a/b/0", + "expect_type": "object" + }, + { + "op": "set", + "path": "/x/y/z", + "value": "hello" + }, + { + "op": "get", + "path": "/x/y/z", + "expect": "hello" + }, + { + "op": "set", + "path": "/nestedList/0/0", + "value": "inner" + }, + { + "op": "get", + "path": "/nestedList/0/0", + "expect": "inner" + }, + { + "op": "get", + "path": "/nestedList", + "expect_type": "list" + }, + { + "op": "get", + "path": "/nestedList/0", + "expect_type": "list" + } + ] + }, + { + "name": "test_data_model_get_out_of_range_index", + "description": "Out of range and non numeric list indices resolve to no value on read.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/items/99", + "expect_absent": true + }, + { + "op": "get", + "path": "/items/-1", + "expect_absent": true + }, + { + "op": "get", + "path": "/items/invalid", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_normalizes_trailing_slash", + "description": "A trailing slash is not a distinct path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/foo" + ], + "steps": [ + { + "op": "set", + "path": "/foo/", + "value": "bar", + "expect_notified": [ + "/foo" + ] + }, + { + "op": "get", + "path": "/foo/", + "expect": "bar" + }, + { + "op": "get", + "path": "/foo", + "expect": "bar" + } + ] + }, + { + "name": "test_data_model_replaces_root", + "description": "Setting the root path replaces the whole model.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/", + "/unrelated" + ], + "steps": [ + { + "op": "set", + "path": "/", + "value": { + "newRoot": "foo" + }, + "expect_notified": [ + "/" + ], + "expect_values": { + "/": { + "newRoot": "foo" + } + } + }, + { + "op": "get", + "path": "", + "expect": { + "newRoot": "foo" + } + } + ] + }, + { + "name": "test_data_model_escaped_slash", + "description": "A ~1 escape addresses a key containing a slash (RFC 6901).", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/detailed~1info", + "value": "some info" + }, + { + "op": "get", + "path": "/user/detailed~1info", + "expect": "some info" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "detailed/info": "some info" + } + } + ] + }, + { + "name": "test_data_model_escaped_tilde", + "description": "A ~0 escape addresses a key containing a tilde (RFC 6901).", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/profile~0name", + "value": "profile~name" + }, + { + "op": "get", + "path": "/user/profile~0name", + "expect": "profile~name" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "profile~name": "profile~name" + } + } + ] + }, + { + "name": "test_data_model_escaped_mixed", + "description": "Both escapes may appear in one segment.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/a~0b~1c", + "value": "value" + }, + { + "op": "get", + "path": "/user/a~0b~1c", + "expect": "value" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "a~b/c": "value" + } + } + ] + }, + { + "name": "test_data_model_escape_order", + "description": "A ~01 sequence decodes to ~1, not to a slash.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/a~01b", + "value": "value" + }, + { + "op": "get", + "path": "/user/a~01b", + "expect": "value" + }, + { + "op": "get", + "path": "/user", + "expect": { + "name": "Alice", + "settings": { + "theme": "dark" + }, + "a~1b": "value" + } + } + ] + }, + { + "name": "test_data_model_rejects_write_through_primitive", + "description": "Writing through a primitive intermediate segment is an error.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "not an object" + }, + { + "op": "set", + "path": "/user/name/first", + "value": "Alice", + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + } + ] + }, + { + "name": "test_data_model_rejects_write_through_list_primitive", + "description": "Writing through a primitive list element is an error.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/0/foo", + "value": "bar", + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + } + ] + }, + { + "name": "test_data_model_rejects_non_numeric_list_segment", + "description": "A non numeric segment cannot address a list element on write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/foo", + "value": "bar", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_data_model_rejects_non_numeric_list_segment_intermediate", + "description": "A non numeric intermediate segment cannot address a list element.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/foo/bar", + "value": "value", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_data_model_notifies_exact_path", + "description": "An observer fires when its own path is written.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user/name" + ], + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Charlie", + "expect_values": { + "/user/name": "Charlie" + }, + "expect_notified": [ + "/user/name" + ] + } + ] + }, + { + "name": "test_data_model_notifies_ancestor", + "description": "An observer on a container fires when a descendant is written.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user" + ], + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Dave", + "expect_values": { + "/user": { + "name": "Dave", + "settings": { + "theme": "dark" + } + } + }, + "expect_notified": [ + "/user" + ] + } + ] + }, + { + "name": "test_data_model_notifies_descendant", + "description": "An observer on a leaf fires when an ancestor is replaced.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user/settings/theme" + ], + "steps": [ + { + "op": "set", + "path": "/user/settings", + "value": { + "theme": "light" + }, + "expect_values": { + "/user/settings/theme": "light" + }, + "expect_notified": [ + "/user/settings/theme" + ] + } + ] + }, + { + "name": "test_data_model_notifies_root_observer", + "description": "A root observer fires on any write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/" + ], + "steps": [ + { + "op": "set", + "path": "/newProp", + "value": "test", + "expect_notified": [ + "/" + ] + } + ] + }, + { + "name": "test_data_model_notifies_parent_on_child_write", + "description": "A container observer sees the updated container value.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/parent" + ], + "steps": [ + { + "op": "set", + "path": "/parent", + "value": { + "child": "initial" + } + }, + { + "op": "set", + "path": "/parent/child", + "value": "updated", + "expect_values": { + "/parent": { + "child": "updated" + } + } + } + ] + }, + { + "name": "test_data_model_does_not_notify_unrelated", + "description": "An observer does not fire for an unrelated path.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "a": 1, + "b": 2 + }, + "watch": [ + "/b" + ], + "steps": [ + { + "op": "set", + "path": "/a", + "value": 99, + "expect_notified": [] + } + ] + }, + { + "name": "test_data_model_does_not_notify_prefix_sibling", + "description": "A sibling whose name merely shares a prefix is not a descendant.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "foo": 1, + "foobar": 2 + }, + "watch": [ + "/foo", + "/foobar" + ], + "steps": [ + { + "op": "set", + "path": "/foo", + "value": 99, + "expect_notified": [ + "/foo" + ] + } + ] + }, + { + "name": "test_data_model_multiple_observers_same_path", + "description": "Every observer on a path is notified.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/user/name", + "/user/name" + ], + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Eve", + "expect_values": { + "/user/name": "Eve" + }, + "expect_notified": [ + "/user/name", + "/user/name" + ] + } + ] + }, + { + "name": "test_data_model_observe_absent_path", + "description": "Observing a path that does not exist yet starts empty and fires on creation.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/non/existent" + ], + "steps": [ + { + "op": "get", + "path": "/non/existent", + "expect_absent": true + }, + { + "op": "set", + "path": "/non/existent", + "value": "exists now", + "expect_values": { + "/non/existent": "exists now" + }, + "expect_notified": [ + "/non/existent" + ] + } + ] + }, + { + "name": "test_data_model_stops_notifying_after_dispose", + "description": "Disposing the model detaches every observer.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + }, + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/" + ], + "steps": [ + { + "op": "dispose" + }, + { + "op": "set", + "path": "/foo", + "value": "bar", + "expect_notified": [] + } + ] + }, + { + "name": "test_data_model_does_not_notify_unchanged_descendant", + "description": "An observer on a descendant path does not fire when an ancestor is rewritten but the observer's own value is still absent.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "watch": [ + "/a/b" + ], + "steps": [ + { + "op": "set", + "path": "/a", + "value": { + "c": 1 + }, + "expect_notified": [] + } + ] + }, + { + "name": "test_data_model_does_not_notify_same_value_rewrite", + "description": "Writing a path the value it already holds does not fire its observers.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "k": "v" + }, + "watch": [ + "/k" + ], + "steps": [ + { + "op": "set", + "path": "/k", + "value": "v", + "expect_notified": [] + }, + { + "op": "set", + "path": "/k", + "value": "w", + "expect_notified": [ + "/k" + ] + } + ] + }, + { + "name": "test_data_model_reactivity_child_deletion_notifies_parent_and_child", + "description": "Deleting a child property notifies both the child path and ancestor containers.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice", + "settings": { + "theme": "dark" + } + } + }, + "watch": [ + "/user", + "/user/name" + ], + "steps": [ + { + "op": "delete", + "path": "/user/name", + "expect_notified": [ + "/user", + "/user/name" + ], + "expect_values": { + "/user": { + "settings": { + "theme": "dark" + } + } + } + }, + { + "op": "get", + "path": "/user/name", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_reactivity_array_element_update_notifies_array", + "description": "Updating an array element notifies both the element observer and the array observer.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "watch": [ + "/items", + "/items/1" + ], + "steps": [ + { + "op": "set", + "path": "/items/1", + "value": "updated", + "expect_notified": [ + "/items", + "/items/1" + ], + "expect_values": { + "/items": [ + "a", + "updated", + "c" + ], + "/items/1": "updated" + } + } + ] + }, + { + "name": "test_data_model_reactivity_ancestor_deletion_notifies_descendants", + "description": "Deleting a parent container notifies observers on nested descendant paths whose values become absent.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "settings": { + "theme": "dark" + } + } + }, + "watch": [ + "/user", + "/user/settings", + "/user/settings/theme" + ], + "steps": [ + { + "op": "delete", + "path": "/user", + "expect_notified": [ + "/user", + "/user/settings", + "/user/settings/theme" + ] + }, + { + "op": "get", + "path": "/user/settings/theme", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_delete_out_of_range_list_index_is_noop", + "description": "Deleting a list index past the end of the list leaves the list unchanged rather than padding it out to that index.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "delete", + "path": "/items/10" + }, + { + "op": "get", + "path": "/items", + "expect": [ + "a", + "b", + "c" + ] + } + ] + }, + { + "name": "test_data_model_notifies_descendant_on_root_replacement", + "description": "Replacing the root notifies observers on descendant paths whose values change or become absent.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice" + }, + "stale": "present" + }, + "watch": [ + "/user/name", + "/stale" + ], + "steps": [ + { + "op": "set", + "path": "/", + "value": { + "user": { + "name": "Bob" + } + }, + "expect_notified": [ + "/user/name", + "/stale" + ], + "expect_values": { + "/user/name": "Bob" + } + }, + { + "op": "get", + "path": "/stale", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_rejects_forbidden_path_segments", + "description": "Paths containing __proto__, constructor, or prototype are rejected on both read and write with DataError.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": { + "name": "Alice" + } + }, + "steps": [ + { + "op": "set", + "path": "/__proto__/polluted", + "value": "hacked", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "get", + "path": "/__proto__/polluted", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "set", + "path": "/constructor/prototype/polluted", + "value": "hacked", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "get", + "path": "/constructor/prototype/polluted", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "set", + "path": "/user/prototype/polluted", + "value": "hacked", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + }, + { + "op": "get", + "path": "/user/prototype/polluted", + "expect_error": { + "category": "DataError", + "message": "Forbidden path segment" + } + } + ] + }, + { + "name": "test_data_model_allows_own_properties_named_after_object_methods", + "description": "Unset keys like toString, valueOf, and hasOwnProperty are absent on read, and can be written and read back as ordinary keys.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "steps": [ + { + "op": "get", + "path": "/toString", + "expect_absent": true + }, + { + "op": "get", + "path": "/valueOf", + "expect_absent": true + }, + { + "op": "get", + "path": "/hasOwnProperty", + "expect_absent": true + }, + { + "op": "set", + "path": "/toString", + "value": "custom toString" + }, + { + "op": "get", + "path": "/toString", + "expect": "custom toString" + }, + { + "op": "set", + "path": "/valueOf/nested", + "value": "custom valueOf" + }, + { + "op": "get", + "path": "/valueOf/nested", + "expect": "custom valueOf" + } + ] + }, + { + "name": "test_data_model_delete_absent_deep_path_is_noop", + "description": "Deleting a deep path that does not exist is a no-op that neither vivifies intermediate containers nor notifies observers.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "watch": [ + "/", + "/nonexistent" + ], + "steps": [ + { + "op": "delete", + "path": "/nonexistent/child/path", + "expect_notified": [] + }, + { + "op": "get", + "path": "/", + "expect": {} + }, + { + "op": "get", + "path": "/nonexistent", + "expect_absent": true + } + ] + }, + { + "name": "test_data_model_sparse_list_write_pads_with_null", + "description": "Writing an index past the current end of a list pads intermediate positions with null/absent values.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "steps": [ + { + "op": "set", + "path": "/data/lists/1/value", + "value": 42 + }, + { + "op": "get", + "path": "/data/lists/1/value", + "expect": 42 + }, + { + "op": "get", + "path": "/data/lists/0", + "expect_absent": true + }, + { + "op": "get", + "path": "/data/lists", + "expect_type": "list" + } + ] + }, + { + "name": "test_data_model_rejects_excessive_list_index", + "description": "List indices exceeding the maximum supported bound (10000) are rejected with DataError.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "set", + "path": "/items/10000", + "value": "max_element" + }, + { + "op": "get", + "path": "/items/10000", + "expect": "max_element" + }, + { + "op": "set", + "path": "/items/10001", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/items/999999999", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/items/10001/name", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/newArray/10000000", + "value": "x", + "expect_error": { + "category": "DataError" + } + }, + { + "op": "set", + "path": "/newArray/10000000/field", + "value": "x", + "expect_error": { + "category": "DataError" + } + } + ] + }, + { + "name": "test_data_model_rejects_leading_zero_list_index", + "description": "Leading-zero list indices (RFC 6901 §4) resolve to absent on read and raise DataError on write.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "items": [ + "a", + "b", + "c" + ] + }, + "steps": [ + { + "op": "get", + "path": "/items/01", + "expect_absent": true + }, + { + "op": "set", + "path": "/items/01", + "value": "value", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + }, + { + "op": "set", + "path": "/items/01/nested", + "value": "value", + "expect_error": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_data_model_rejects_write_through_primitive_root", + "description": "Writing a child path when the root itself holds a primitive (including falsy primitives) raises DataError and leaves the root intact.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "steps": [ + { + "op": "set", + "path": "/", + "value": 1 + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": 1 + }, + { + "op": "set", + "path": "/", + "value": false + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": false + }, + { + "op": "set", + "path": "/", + "value": 0 + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": 0 + }, + { + "op": "set", + "path": "/", + "value": "" + }, + { + "op": "set", + "path": "/a", + "value": 2, + "expect_error": { + "category": "DataError", + "message": "Cannot set path" + } + }, + { + "op": "get", + "path": "/", + "expect": "" + } + ] + }, + { + "name": "test_data_model_set_through_null_placeholder_vivifies", + "description": "Writing through an explicit null value in an object or list replaces the null slot with a container.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": { + "user": null, + "counts": [ + null + ] + }, + "steps": [ + { + "op": "set", + "path": "/user/name", + "value": "Bob" + }, + { + "op": "set", + "path": "/counts/0/total", + "value": 1 + }, + { + "op": "get", + "path": "/user/name", + "expect": "Bob" + }, + { + "op": "get", + "path": "/counts/0/total", + "expect": 1 + } + ] + }, + { + "name": "test_data_model_normalizes_empty_path_segments", + "description": "Multiple or trailing slashes in a path are collapsed into canonical segments on read, write, and watch.", + "catalog": { + "protocolVersion": "v0.9" + }, + "action": "data_model", + "initial": {}, + "watch": [ + "/a/b" + ], + "steps": [ + { + "op": "set", + "path": "//a///b//", + "value": "val", + "expect_notified": [ + "/a/b" + ], + "expect_values": { + "/a/b": "val" + } + }, + { + "op": "get", + "path": "/a/b", + "expect": "val" + }, + { + "op": "get", + "path": "//a///b//", + "expect": "val" + }, + { + "op": "get", + "path": "///", + "expect": { + "a": { + "b": "val" + } + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/data_model_pointers.json b/package/__fixtures__/a2ui-conformance/core/data_model_pointers.json new file mode 100644 index 0000000000..94df6744c4 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/data_model_pointers.json @@ -0,0 +1,358 @@ +[ + { + "name": "test_pointer_escape_characters", + "description": "Verifies RFC 6901 JSON Pointer escape sequence resolution (~0 for ~ and ~1 for /).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "a/b": { + "c~d": "original_value" + }, + "slash/key": 100 + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/a~1b/c~0d", + "value": "escaped_value" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "a/b": { + "c~d": "escaped_value" + }, + "slash/key": 100 + } + } + } + } + }, + { + "name": "test_pointer_array_indexing", + "description": "Verifies numeric array indexing in JSON Pointers (/items/0, /items/1).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "first", + "second", + "third" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/1", + "value": "updated_second" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "items": [ + "first", + "updated_second", + "third" + ] + } + } + } + } + }, + { + "name": "test_pointer_auto_vivification", + "description": "Verifies automatic intermediate object creation when setting deep uncreated JSON Pointer paths.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": {} + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/deeply/nested/structure/key", + "value": "vivified_value" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "deeply": { + "nested": { + "structure": { + "key": "vivified_value" + } + } + } + } + } + } + } + }, + { + "name": "test_pointer_extended_escape_sequences", + "description": "Verifies advanced RFC 6901 escape sequence combinations (~10 for /0, ~00 for ~0, ~11 for /1, and ~01 for ~1).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "m~0": "val0", + "m/1": "val1", + "m~1": "val2", + "m/0": "val3" + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~00", + "value": "updated0" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~11", + "value": "updated1" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~01", + "value": "updated2" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/m~10", + "value": "updated3" + } + } + ] + } + ], + "expect": { + "surfaces": { + "s1": { + "dataModel": { + "m~0": "updated0", + "m/1": "updated1", + "m~1": "updated2", + "m/0": "updated3" + } + } + } + } + }, + { + "name": "test_pointer_reject_leading_zero_array_index", + "description": "Verifies rejection of leading zero array indices (e.g. /items/01) per RFC 6901 §4.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "a", + "b", + "c" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/01", + "value": "invalid" + } + } + ], + "expectError": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + }, + { + "name": "test_pointer_reject_write_through_primitive", + "description": "Verifies DataError when writing nested path through primitive intermediate node.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "user": "Alice" + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/name/first", + "value": "Alice" + } + } + ], + "expectError": { + "category": "DataError", + "message": "Cannot set path" + } + } + ] + }, + { + "name": "test_pointer_reject_non_numeric_array_segment", + "description": "Verifies DataError when setting non-numeric key on array target.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "dataModel": { + "items": [ + "a", + "b", + "c" + ] + } + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/items/invalidKey", + "value": "value" + } + } + ], + "expectError": { + "category": "DataError", + "message": "non-numeric segment" + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/error_responses.json b/package/__fixtures__/a2ui-conformance/core/error_responses.json new file mode 100644 index 0000000000..d8a05e004c --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/error_responses.json @@ -0,0 +1,1093 @@ +[ + { + "name": "test_error_response_create_surface_invalid_theme_property", + "description": "Emits VALIDATION_FAILED with /theme/primaryColor when createSurface theme property has wrong type.", + "action": "validate", + "protocolVersion": "v0.9", + "catalogPaths": [ + "specification/v0_9/catalogs/basic/catalog.json" + ], + "payload": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "theme": { + "primaryColor": 12345 + } + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/theme/primaryColor", + "rendererError": { + "version": "v0.9", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/theme/primaryColor", + "message": "primaryColor" + } + } + } + }, + { + "name": "test_error_response_create_surface_unknown_catalog_id", + "description": "Emits CATALOG_NOT_FOUND when createSurface references an unregistered catalogId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://example.com/unknown_catalog.json" + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "main", + "message": "unknown_catalog" + } + } + } + }, + { + "name": "test_error_response_create_surface_incompatible_catalog_version", + "description": "Emits CATALOG_NOT_FOUND when createSurface binds a v0.9 catalog to a v1.0 surface.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "specification/v0_9/catalogs/basic/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "main", + "message": "v0_9" + } + } + } + }, + { + "name": "test_error_response_create_surface_duplicate_surface_id", + "description": "Emits INTEGRITY_ERROR when createSurface is sent for an already existing surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "already exists" + } + } + } + }, + { + "name": "test_error_response_update_components_unknown_surface", + "description": "Emits INTEGRITY_ERROR when updateComponents targets a non-existent surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "missing_surface", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "missing_surface", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "missing_surface", + "message": "missing_surface" + } + } + } + }, + { + "name": "test_error_response_update_components_duplicate_id_in_batch", + "description": "Emits INTEGRITY_ERROR when updateComponents contains duplicate component IDs in the same batch.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": "First" + }, + { + "id": "root", + "component": "Text", + "text": "Duplicate" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "root" + } + } + } + }, + { + "name": "test_error_response_update_components_unknown_type_strict", + "description": "Emits VALIDATION_FAILED with /components/0/component when component type is unknown in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "NonExistentWidget" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/components/0/component", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/component", + "message": "NonExistentWidget" + } + } + } + }, + { + "name": "test_error_response_update_components_invalid_property_type", + "description": "Emits VALIDATION_FAILED with /components/0/text when a component property has the wrong type.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": 12345 + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/components/0/text", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/text", + "message": "text" + } + } + } + }, + { + "name": "test_error_response_update_components_extra_forbidden_property", + "description": "Emits VALIDATION_FAILED when a component includes an undeclared extra property in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "unknownExtraProp": true + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/unknownExtraProp", + "message": "unknownExtraProp" + } + } + } + }, + { + "name": "test_error_response_update_components_invalid_uax31_id", + "description": "Emits VALIDATION_FAILED with /components/0/id when a v1.0 component ID violates UAX #31.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "123-invalid", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/components/0/id", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/components/0/id", + "message": "123-invalid" + } + } + } + }, + { + "name": "test_error_response_update_components_unallowed_parent", + "description": "Emits UNALLOWED_PARENT with /components/1 when a child component is placed inside a disallowed parent.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "test-comp-catalog", + "protocolVersion": "1.0", + "components": { + "Container": { + "type": "object", + "properties": { + "component": { + "const": "Container" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component" + ] + }, + "RestrictedItem": { + "type": "object", + "allowedParents": [ + "SpecialList" + ], + "properties": { + "component": { + "const": "RestrictedItem" + } + }, + "required": [ + "component" + ] + } + } + }, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "test-comp-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Container", + "children": [ + "item1" + ] + }, + { + "id": "item1", + "component": "RestrictedItem" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/1", + "details": [ + { + "path": "/components/1", + "code": "UNALLOWED_PARENT", + "surfaceId": "main" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/1", + "message": "RestrictedItem" + } + } + } + }, + { + "name": "test_error_response_update_components_unallowed_parent_root", + "description": "Emits UNALLOWED_PARENT with /components/0 when the root component does not allow Surface as parent.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "test-comp-catalog", + "protocolVersion": "1.0", + "components": { + "RestrictedItem": { + "type": "object", + "allowedParents": [ + "SpecialList" + ], + "properties": { + "component": { + "const": "RestrictedItem" + } + }, + "required": [ + "component" + ] + } + } + }, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "test-comp-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "RestrictedItem" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/0", + "details": [ + { + "path": "/components/0", + "code": "UNALLOWED_PARENT", + "surfaceId": "main" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_PARENT", + "surfaceId": "main", + "path": "/components/0", + "message": "RestrictedItem" + } + } + } + }, + { + "name": "test_error_response_update_components_unallowed_child", + "description": "Emits UNALLOWED_CHILD with /components/0/children/0 when a parent references a disallowed child type.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "test-comp-catalog", + "protocolVersion": "1.0", + "components": { + "StrictList": { + "type": "object", + "allowedChildren": [ + "ListItem" + ], + "properties": { + "component": { + "const": "StrictList" + }, + "children": { + "$ref": "common_types.json#/$defs/ChildList" + } + }, + "required": [ + "component" + ] + }, + "OtherItem": { + "type": "object", + "properties": { + "component": { + "const": "OtherItem" + } + }, + "required": [ + "component" + ] + } + } + }, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "test-comp-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "StrictList", + "children": [ + "item1" + ] + }, + { + "id": "item1", + "component": "OtherItem" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "code": "UNALLOWED_CHILD", + "surfaceId": "main", + "path": "/components/0/children/0", + "details": [ + { + "path": "/components/0/children/0", + "code": "UNALLOWED_CHILD", + "surfaceId": "main" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "UNALLOWED_CHILD", + "surfaceId": "main", + "path": "/components/0/children/0", + "message": "OtherItem" + } + } + } + }, + { + "name": "test_error_response_update_components_circular_reference", + "description": "Emits RECURSION_LIMIT_EXCEEDED when components form a cycle in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "child1" + ] + }, + { + "id": "child1", + "component": "Column", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": { + "category": "RecursionError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "RECURSION_LIMIT_EXCEEDED", + "surfaceId": "main", + "message": "root" + } + } + } + }, + { + "name": "test_error_response_update_components_dangling_child_strict", + "description": "Emits INTEGRITY_ERROR when a component references a non-existent child ID in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "missing_child" + ] + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "missing_child" + } + } + } + }, + { + "name": "test_error_response_update_components_orphaned_component_strict", + "description": "Emits INTEGRITY_ERROR when a component is unreachable from root in strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan1", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "main", + "message": "orphan1" + } + } + } + }, + { + "name": "test_error_response_update_components_buffered_streaming_non_strict", + "description": "Accepts dangling child references and orphaned components without error when strictMode is false.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": false, + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "orphan1", + "component": "Text", + "text": "Buffered child" + }, + { + "id": "root", + "component": "Column", + "children": [ + "orphan1", + "dangling_child" + ] + } + ] + } + } + ] + }, + { + "payload": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "dangling_child", + "component": "Text", + "text": "Arrived later" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_error_response_update_data_model_unknown_surface", + "description": "Emits INTEGRITY_ERROR when updateDataModel targets a non-existent surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "missing_surface", + "path": "/user/name", + "value": "Alice" + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "missing_surface", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "missing_surface", + "message": "missing_surface" + } + } + } + }, + { + "name": "test_error_response_update_data_model_prototype_pollution", + "description": "Emits DATA_ERROR when updateDataModel targets a forbidden prototype-pollution path segment.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "main", + "path": "/__proto__/polluted", + "value": true + } + } + ], + "expectError": { + "category": "DataError", + "surfaceId": "main", + "rendererError": { + "version": "1.0", + "error": { + "code": "DATA_ERROR", + "surfaceId": "main", + "message": "__proto__" + } + } + } + }, + { + "name": "test_error_response_update_data_model_invalid_json_pointer", + "description": "Emits VALIDATION_FAILED with /path when updateDataModel path is not a valid JSON Pointer.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "main", + "path": "not_a_pointer", + "value": "Alice" + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "main", + "path": "/path", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "main", + "path": "/path", + "message": "path" + } + } + } + }, + { + "name": "test_error_response_delete_surface_missing_surface_id", + "description": "Emits VALIDATION_FAILED with surfaceId '' and /surfaceId when deleteSurface omits surfaceId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "deleteSurface": {} + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "", + "path": "/surfaceId", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "", + "path": "/surfaceId", + "message": "surfaceId" + } + } + } + }, + { + "name": "test_error_response_call_renderer_function_missing_call_function", + "description": "Emits INVALID_FUNCTION_CALL correlated by functionCallId without surfaceId when callFunction is missing.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-err-1" + } + } + ], + "expectError": { + "category": "ValidationError", + "functionCallId": "call-err-1", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-err-1", + "message": "callFunction" + } + } + } + }, + { + "name": "test_error_response_call_renderer_function_non_object_args", + "description": "Emits INVALID_FUNCTION_CALL correlated by functionCallId when callFunction.args is not an object.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-err-2", + "callFunction": { + "@call": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": "not_an_object" + } + } + } + ], + "expectError": { + "category": "ValidationError", + "functionCallId": "call-err-2", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-err-2", + "message": "args" + } + } + } + }, + { + "name": "test_error_response_call_renderer_function_missing_catalog_id", + "description": "Emits INVALID_FUNCTION_CALL correlated by functionCallId when callFunction omits required catalogId.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "payload": [ + { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-err-3", + "callFunction": { + "@call": "formatString", + "args": { + "value": "hello" + } + } + } + } + ], + "expectError": { + "category": "ValidationError", + "functionCallId": "call-err-3", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-err-3", + "message": "catalogId" + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/expressions.json b/package/__fixtures__/a2ui-conformance/core/expressions.json new file mode 100644 index 0000000000..30733740bb --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/expressions.json @@ -0,0 +1,1270 @@ +[ + { + "name": "test_expr_plain_text_is_one_literal", + "description": "A template with no interpolation yields the input unchanged.", + "action": "parse_expression_template", + "input": "hello world", + "expect": [ + "hello world" + ] + }, + { + "name": "test_expr_empty_template", + "description": "An empty template yields no content.", + "action": "parse_expression_template", + "input": "", + "expect": [] + }, + { + "name": "test_expr_relative_path", + "description": "A bare identifier is a relative data binding.", + "action": "parse_expression_template", + "input": "${user}", + "expect": [ + { + "path": "user" + } + ] + }, + { + "name": "test_expr_absolute_path", + "description": "An identifier starting with a slash is an absolute data binding.", + "action": "parse_expression_template", + "input": "value is ${/user/name}", + "expect": [ + "value is ", + { + "path": "/user/name" + } + ] + }, + { + "name": "test_expr_path_with_punctuation", + "description": "Dots, dashes and underscores are part of a path.", + "action": "parse_expression_template", + "input": "${my-path.with_underscores}", + "expect": [ + { + "path": "my-path.with_underscores" + } + ] + }, + { + "name": "test_expr_non_ascii_path", + "description": "Path segments may use any UAX 31 XID_Continue character.", + "action": "parse_expression_template", + "input": "${/użytkownik/größe}", + "expect": [ + { + "path": "/użytkownik/größe" + } + ] + }, + { + "name": "test_expr_error_non_identifier_character_in_path", + "description": "A character outside XID_Continue, such as ½, ends the path.", + "action": "parse_expression_template", + "input": "${a½}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_combining_marks_and_supplementary_plane_path", + "description": "Path segments accept combining marks in XID_Continue, including right after a slash or a dot, and supplementary-plane XID_Continue code points such as 𠮷.", + "action": "parse_expression_template", + "input": "${/a/́b/grüße/𠮷.́c}", + "expect": [ + { + "path": "/a/́b/grüße/𠮷.́c" + } + ] + }, + { + "name": "test_expr_error_supplementary_plane_non_identifier_in_path", + "description": "A supplementary-plane character outside XID_Continue, such as 🚀, ends the path.", + "action": "parse_expression_template", + "input": "${a🚀}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_literal_between_bindings", + "description": "Literal text surrounding interpolations is preserved.", + "action": "parse_expression_template", + "input": "a ${x} b ${y} c", + "expect": [ + "a ", + { + "path": "x" + }, + " b ", + { + "path": "y" + }, + " c" + ] + }, + { + "name": "test_expr_adjacent_bindings", + "description": "Interpolations with nothing between them stay separate parts.", + "action": "parse_expression_template", + "input": "${x}${y}", + "expect": [ + { + "path": "x" + }, + { + "path": "y" + } + ] + }, + { + "name": "test_expr_single_quoted_string", + "description": "Single-quoted literals parse to their contents.", + "action": "parse_expression_template", + "input": "${'hello world'}", + "expect": [ + "hello world" + ] + }, + { + "name": "test_expr_double_quoted_string", + "description": "Double-quoted literals parse to their contents.", + "action": "parse_expression_template", + "input": "${\"hello world\"}", + "expect": [ + "hello world" + ] + }, + { + "name": "test_expr_string_escapes", + "description": "Backslash escapes inside string literals are unescaped.", + "action": "parse_expression_template", + "input": "${'line\\nbreak'}", + "expect": [ + "line\nbreak" + ] + }, + { + "name": "test_expr_string_escaped_quote", + "description": "An escaped quote does not terminate the literal.", + "action": "parse_expression_template", + "input": "${'it\\'s'}", + "expect": [ + "it's" + ] + }, + { + "name": "test_expr_integer_literal", + "description": "Integer literals parse to numbers.", + "action": "parse_expression_template", + "input": "${42}", + "expect": [ + 42 + ] + }, + { + "name": "test_expr_decimal_literal", + "description": "Decimal literals parse to numbers.", + "action": "parse_expression_template", + "input": "${1.5}", + "expect": [ + 1.5 + ] + }, + { + "name": "test_expr_negative_integer_literal", + "description": "Negative integer literals parse to negative numbers.", + "action": "parse_expression_template", + "input": "${-42}", + "expect": [ + -42 + ] + }, + { + "name": "test_expr_negative_decimal_literal", + "description": "Negative decimal literals parse to negative numbers.", + "action": "parse_expression_template", + "input": "${-3.14}", + "expect": [ + -3.14 + ] + }, + { + "name": "test_expr_exponential_notation_positive", + "description": "Scientific/exponential notation parses to numbers.", + "action": "parse_expression_template", + "input": "${1e5}", + "expect": [ + 100000 + ] + }, + { + "name": "test_expr_exponential_notation_negative_exponent", + "description": "Scientific notation with negative exponent parses to floating point numbers.", + "action": "parse_expression_template", + "input": "${1.5e-3}", + "expect": [ + 0.0015 + ] + }, + { + "name": "test_expr_exponential_notation_capital_e", + "description": "Scientific notation with capital E and plus sign parses correctly.", + "action": "parse_expression_template", + "input": "${2.5E+4}", + "expect": [ + 25000 + ] + }, + { + "name": "test_expr_leading_zeros", + "description": "Leading zeros do not change the value.", + "action": "parse_expression_template", + "input": "${007}", + "expect": [ + 7 + ] + }, + { + "name": "test_expr_boolean_literals", + "description": "The true and false keywords parse to booleans.", + "action": "parse_expression_template", + "input": "${true} ${false}", + "expect": [ + true, + " ", + false + ] + }, + { + "name": "test_expr_keyword_prefix_is_a_path", + "description": "An identifier that merely starts with a keyword is a path.", + "action": "parse_expression_template", + "input": "${trueish}", + "expect": [ + { + "path": "trueish" + } + ] + }, + { + "name": "test_expr_keyword_followed_by_non_ascii_letter_is_a_path", + "description": "A non-ASCII XID_Continue character after a keyword makes it part of a path.", + "action": "parse_expression_template", + "input": "${nullß}", + "expect": [ + { + "path": "nullß" + } + ] + }, + { + "name": "test_expr_error_keyword_followed_by_non_identifier_character", + "description": "A character outside XID_Continue, such as ½, does not extend a keyword.", + "action": "parse_expression_template", + "input": "${true½}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_function_call_no_args", + "description": "A function call with no arguments parses to an empty args map.", + "action": "parse_expression_template", + "input": "${now()}", + "expect": [ + { + "call": "now", + "args": {}, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_function_call_named_args", + "description": "Function arguments are named and comma separated.", + "action": "parse_expression_template", + "input": "sum is ${add(a: 10, b: 20)}", + "expect": [ + "sum is ", + { + "call": "add", + "args": { + "a": 10, + "b": 20 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_non_ascii_function_and_argument_names", + "description": "Function and argument names may use any UAX 31 XID_Continue character.", + "action": "parse_expression_template", + "input": "${größe(wartość: 1, 値: 2)}", + "expect": [ + { + "call": "größe", + "args": { + "wartość": 1, + "値": 2 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_error_non_identifier_character_in_argument_name", + "description": "A character outside XID_Continue, such as ½, is not part of an argument name.", + "action": "parse_expression_template", + "input": "${add(½: 1)}", + "expect_error": { + "category": "ParseError", + "message": "after argument name" + } + }, + { + "name": "test_expr_function_call_negative_args", + "description": "Function arguments may be negative numeric literals.", + "action": "parse_expression_template", + "input": "${add(a: -5, b: 10)}", + "expect": [ + { + "call": "add", + "args": { + "a": -5, + "b": 10 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_function_call_mixed_arg_types", + "description": "Arguments may be literals, paths or nested calls.", + "action": "parse_expression_template", + "input": "${format(value: /amount, suffix: ' USD', fallback: zero())}", + "expect": [ + { + "call": "format", + "args": { + "value": { + "path": "/amount" + }, + "suffix": " USD", + "fallback": { + "call": "zero", + "args": {}, + "returnType": "any" + } + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_nested_function_calls", + "description": "Function arguments may themselves be function calls.", + "action": "parse_expression_template", + "input": "${outer(a: inner(b: 1))}", + "expect": [ + { + "call": "outer", + "args": { + "a": { + "call": "inner", + "args": { + "b": 1 + }, + "returnType": "any" + } + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_nested_interpolation", + "description": "An interpolation inside an interpolation resolves to the inner expression.", + "action": "parse_expression_template", + "input": "${${'hello'}}", + "expect": [ + "hello" + ] + }, + { + "name": "test_expr_whitespace_around_expression", + "description": "Whitespace inside the interpolation braces is ignored.", + "action": "parse_expression_template", + "input": "${ /user/name }", + "expect": [ + { + "path": "/user/name" + } + ] + }, + { + "name": "test_expr_escaped_marker_is_literal", + "description": "An escaped marker resolves to a literal \"${\" and is not interpolated.", + "action": "parse_expression_template", + "input": "\\${not_interpolated}", + "expect": [ + "${not_interpolated}" + ] + }, + { + "name": "test_expr_escaped_marker_between_text", + "description": "An escaped marker inside surrounding text stays literal.", + "action": "parse_expression_template", + "input": "before \\${x} after", + "expect": [ + "before ${x} after" + ] + }, + { + "name": "test_expr_escaped_and_real_marker", + "description": "An escaped marker and a real interpolation can appear in one template.", + "action": "parse_expression_template", + "input": "\\${literal} ${real}", + "expect": [ + "${literal} ", + { + "path": "real" + } + ] + }, + { + "name": "test_expr_error_unclosed_interpolation", + "description": "A template whose interpolation is never closed is rejected.", + "action": "parse_expression_template", + "input": "hello ${world", + "expect_error": { + "category": "ParseError", + "message": "Unclosed interpolation" + } + }, + { + "name": "test_expr_error_missing_closing_paren", + "description": "A function call whose parentheses are unbalanced is rejected.", + "action": "parse_expression_template", + "input": "${add(a: 1, b: 2}", + "expect_error": { + "category": "ParseError", + "message": "after function arguments" + } + }, + { + "name": "test_expr_error_missing_arg_colon", + "description": "A function argument without a colon is rejected.", + "action": "parse_expression_template", + "input": "${add(a 1)}", + "expect_error": { + "category": "ParseError", + "message": "after argument name" + } + }, + { + "name": "test_expr_error_trailing_characters", + "description": "Characters after a complete expression are rejected.", + "action": "parse_expression_template", + "input": "${true false}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_number_with_trailing_point", + "description": "A trailing decimal point is accepted, as every client implementation does today. The grammar is stated in each parser rather than delegated to the platform's number parser, so this stays true.\n", + "action": "parse_expression_template", + "input": "${1.}", + "expect": [ + 1 + ] + }, + { + "name": "test_expr_error_invalid_number_two_points", + "description": "A number literal with two decimal points is rejected as a protocol error.", + "action": "parse_expression_template", + "input": "${1.2.3}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_leading_dot_decimal_literal", + "description": "A decimal literal may omit the digits before the decimal point.", + "action": "parse_expression_template", + "input": "${.5}", + "expect": [ + 0.5 + ] + }, + { + "name": "test_expr_negative_leading_dot_decimal_literal", + "description": "A leading-dot decimal literal may carry a minus sign.", + "action": "parse_expression_template", + "input": "${-.5}", + "expect": [ + -0.5 + ] + }, + { + "name": "test_expr_positive_leading_dot_decimal_literal", + "description": "A leading-dot decimal literal may carry a plus sign.", + "action": "parse_expression_template", + "input": "${+.5}", + "expect": [ + 0.5 + ] + }, + { + "name": "test_expr_leading_dot_exponential_notation", + "description": "A leading-dot decimal literal may carry an exponent.", + "action": "parse_expression_template", + "input": "${.5e2}", + "expect": [ + 50 + ] + }, + { + "name": "test_expr_function_call_leading_dot_args", + "description": "Function arguments may be signed leading-dot decimal literals.", + "action": "parse_expression_template", + "input": "${scale(by: -.5, offset: .25)}", + "expect": [ + { + "call": "scale", + "args": { + "by": -0.5, + "offset": 0.25 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_leading_dot_path_is_not_a_number", + "description": "A leading dot starts a number only when a digit follows it. Otherwise the token stays a path, as does a sign followed by a dot and no digit.\n", + "action": "parse_expression_template", + "input": "${.foo} ${-.}", + "expect": [ + { + "path": ".foo" + }, + " ", + { + "path": "-." + } + ] + }, + { + "name": "test_expr_error_leading_dot_number_two_points", + "description": "A leading-dot number literal with a second decimal point is rejected.", + "action": "parse_expression_template", + "input": "${.5.5}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_leading_dot_number_empty_exponent", + "description": "A signed leading-dot number literal with an empty exponent is rejected.", + "action": "parse_expression_template", + "input": "${-.5e}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_positive_integer_literal", + "description": "An integer literal with a leading plus sign is parsed as a number.", + "action": "parse_expression_template", + "input": "${+1}", + "expect": [ + 1 + ] + }, + { + "name": "test_expr_hyphen_inside_path", + "description": "A hyphen after an identifier character stays part of the path token.", + "action": "parse_expression_template", + "input": "${a-1}", + "expect": [ + { + "path": "a-1" + } + ] + }, + { + "name": "test_expr_hyphen_before_non_digit_is_a_path", + "description": "A leading hyphen not followed by a digit or a dot-and-digit stays a path.", + "action": "parse_expression_template", + "input": "${-a}", + "expect": [ + { + "path": "-a" + } + ] + }, + { + "name": "test_expr_error_exponent_without_digits", + "description": "A number literal whose exponent has no digits is rejected.", + "action": "parse_expression_template", + "input": "${1e}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_max_safe_integer_literals", + "description": "Integer literals at ±(2^53 - 1) are returned with exact value in every engine.", + "action": "parse_expression_template", + "input": "${9007199254740991} ${-9007199254740991}", + "expect": [ + 9007199254740991, + " ", + -9007199254740991 + ] + }, + { + "name": "test_expr_largest_exponent_literal", + "description": "A number literal near the top of the double range is accepted.", + "action": "parse_expression_template", + "input": "${1e308}", + "expect": [ + 1e+308 + ] + }, + { + "name": "test_expr_exponent_underflow_is_zero", + "description": "A number literal that underflows the double range evaluates to zero.", + "action": "parse_expression_template", + "input": "${1e-999}", + "expect": [ + 0 + ] + }, + { + "name": "test_expr_error_exponent_out_of_range", + "description": "A positive exponent literal that overflows the double range is rejected.", + "action": "parse_expression_template", + "input": "${1e999}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expr_error_negative_exponent_out_of_range", + "description": "A negative number literal that overflows the double range is rejected.", + "action": "parse_expression_template", + "input": "${-1e999}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expr_error_integer_out_of_range", + "description": "An integer literal of 10^309 is outside the double range and is rejected.", + "action": "parse_expression_template", + "input": "${1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expression_parser_string_interpolation_multiple_bindings", + "description": "Verifies ${/path} interpolation across multiple variables and static text.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "user": { + "name": "Alice", + "unreadCount": 5 + } + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Hello, ${/user/name}! You have ${/user/unreadCount} unread messages." + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Hello, Alice! You have 5 unread messages." + } + } + } + } + } + }, + { + "name": "test_expression_parser_escaped_interpolation_sequence", + "description": "Verifies \\${...} renders literal ${...} without evaluation.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "amount": 100 + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Price is \\${/amount} USD" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Price is ${/amount} USD" + } + } + } + } + } + }, + { + "name": "test_expression_parser_nested_function_call", + "description": "Verifies evaluating nested function calls with named arguments inside ${...}.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "price": 49.99 + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Total: ${formatCurrency(value: /price, currency: 'USD')}" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Total: $49.99" + } + } + } + } + } + }, + { + "name": "test_expression_parser_data_type_coercion_matrix", + "description": "Verifies expression string coercion for booleans, numbers, and missing values.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "isActive": true, + "score": 98.5 + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Active: ${/isActive}, Score: ${/score}, Missing: ${/nonexistent}" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "Active: true, Score: 98.5, Missing: " + } + } + } + } + } + }, + { + "name": "test_expression_parser_syntax_error_unclosed_brace", + "description": "Verifies error when expression contains unclosed interpolation brace.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Hello ${/unclosed" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unclosed interpolation" + } + } + ] + }, + { + "name": "test_expression_parser_max_depth_exceeded_error", + "description": "Verifies error when expression nesting exceeds the maximum recursion depth. The value nests 101 interpolations, one past the limit of 100 that every engine applies.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${1}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Max recursion depth" + } + } + ] + }, + { + "name": "test_expr_error_max_depth_nested_interpolations", + "description": "An interpolation nested past the recursion limit of 100 is rejected. This template nests 101 deep.", + "action": "parse_expression_template", + "input": "${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${1}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "expect_error": { + "category": "ParseError", + "message": "Max recursion depth" + } + }, + { + "name": "test_expr_error_max_depth_function_arguments", + "description": "A function-call argument counts toward the recursion limit, not just a nested interpolation. This template nests 101 calls, one past the limit.", + "action": "parse_expression_template", + "input": "${f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: f(a: 1)))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))}", + "expect_error": { + "category": "ParseError", + "message": "Max recursion depth" + } + }, + { + "name": "test_expr_null_keyword_produces_no_part", + "description": "The null keyword inside an interpolation resolves to empty content.", + "action": "parse_expression_template", + "input": "a${null}b", + "expect": [ + "ab" + ] + }, + { + "name": "test_expr_null_keyword_argument_is_empty_string", + "description": "The null keyword passed as a function argument is an empty string.", + "action": "parse_expression_template", + "input": "${f(x: null)}", + "expect": [ + { + "call": "f", + "args": { + "x": "" + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_nested_path_interpolation", + "description": "An interpolation nested inside another interpolation resolves to the inner path binding.", + "action": "parse_expression_template", + "input": "${${nested}}", + "expect": [ + { + "path": "nested" + } + ] + }, + { + "name": "test_expr_function_call_double_quoted_arg", + "description": "Function arguments may be double-quoted string literals.", + "action": "parse_expression_template", + "input": "${upper(text: \"hello\")}", + "expect": [ + { + "call": "upper", + "args": { + "text": "hello" + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_string_escapes_tab_cr_backslash", + "description": "Tab, carriage-return, and backslash escapes inside string literals are unescaped.", + "action": "parse_expression_template", + "input": "${'a\\tb\\rc\\\\d'}", + "expect": [ + "a\tb\rc\\d" + ] + }, + { + "name": "test_expr_string_unknown_escape_keeps_character", + "description": "An unrecognized backslash escape inside a string literal yields the escaped character.", + "action": "parse_expression_template", + "input": "${'a\\qb'}", + "expect": [ + "aqb" + ] + }, + { + "name": "test_expr_negative_number_with_trailing_point", + "description": "A negative number literal may end with a trailing decimal point.", + "action": "parse_expression_template", + "input": "${-42.}", + "expect": [ + -42 + ] + }, + { + "name": "test_expr_positive_zero_with_trailing_point", + "description": "A positive zero literal with a trailing decimal point evaluates to zero.", + "action": "parse_expression_template", + "input": "${+0.}", + "expect": [ + 0 + ] + }, + { + "name": "test_expr_negative_zero_literal", + "description": "A negative zero integer literal evaluates to zero.", + "action": "parse_expression_template", + "input": "${-0}", + "expect": [ + 0 + ] + }, + { + "name": "test_expr_negative_exponential_notation", + "description": "A negative number literal in exponential notation parses to a negative number.", + "action": "parse_expression_template", + "input": "${-2e3}", + "expect": [ + -2000 + ] + }, + { + "name": "test_expr_negative_leading_dot_exponential_notation", + "description": "A negative leading-dot decimal literal with a negative exponent parses to a number.", + "action": "parse_expression_template", + "input": "${-.5E-1}", + "expect": [ + -0.05 + ] + }, + { + "name": "test_expr_function_call_signed_and_exponential_args", + "description": "Function arguments may be negative exponential or positive signed number literals.", + "action": "parse_expression_template", + "input": "${clamp(value: -1.5e2, max: +10)}", + "expect": [ + { + "call": "clamp", + "args": { + "value": -150, + "max": 10 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_dot_slash_relative_path", + "description": "A token starting with './' is parsed as a path.", + "action": "parse_expression_template", + "input": "${./x}", + "expect": [ + { + "path": "./x" + } + ] + }, + { + "name": "test_expr_leading_dot_before_exponent_letter_is_a_path", + "description": "A leading dot followed by 'e' and digits is a path, not a number literal.", + "action": "parse_expression_template", + "input": "${.e5}", + "expect": [ + { + "path": ".e5" + } + ] + }, + { + "name": "test_expr_dot_digit_inside_path", + "description": "A dot followed by a digit after an identifier character stays part of the path.", + "action": "parse_expression_template", + "input": "${a.5}", + "expect": [ + { + "path": "a.5" + } + ] + }, + { + "name": "test_expr_slash_dot_digit_inside_path", + "description": "A segment starting with a dot and digit inside a slash-delimited path stays a path.", + "action": "parse_expression_template", + "input": "${/items/.5}", + "expect": [ + { + "path": "/items/.5" + } + ] + }, + { + "name": "test_expr_slash_hyphen_digit_inside_path", + "description": "A segment starting with a hyphen and digit inside a slash-delimited path stays a path.", + "action": "parse_expression_template", + "input": "${/items/-1}", + "expect": [ + { + "path": "/items/-1" + } + ] + }, + { + "name": "test_expr_parent_directory_relative_path", + "description": "A token starting with '../' is parsed as a path.", + "action": "parse_expression_template", + "input": "${../x}", + "expect": [ + { + "path": "../x" + } + ] + }, + { + "name": "test_expr_function_call_trailing_comma", + "description": "A trailing comma after the last function argument is accepted.", + "action": "parse_expression_template", + "input": "${add(a: 1, b: 2,)}", + "expect": [ + { + "call": "add", + "args": { + "a": 1, + "b": 2 + }, + "returnType": "any" + } + ] + }, + { + "name": "test_expr_error_leading_dot_exponent_sign_without_digits", + "description": "A leading-dot number literal whose exponent has a sign but no digits is rejected.", + "action": "parse_expression_template", + "input": "${.5e+}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_exponent_plus_without_digits", + "description": "A number literal whose exponent has a plus sign but no digits is rejected.", + "action": "parse_expression_template", + "input": "${1e+}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_exponent_minus_without_digits", + "description": "A number literal whose exponent has a minus sign but no digits is rejected.", + "action": "parse_expression_template", + "input": "${1E-}", + "expect_error": { + "category": "ParseError", + "message": "Invalid number literal" + } + }, + { + "name": "test_expr_error_trailing_characters_after_number", + "description": "Identifier characters immediately following a number literal are rejected.", + "action": "parse_expression_template", + "input": "${-1x}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_error_mantissa_overflow_out_of_range", + "description": "A number literal whose mantissa overflows the double range at 10^308 is rejected.", + "action": "parse_expression_template", + "input": "${2e308}", + "expect_error": { + "category": "ParseError", + "message": "out of range" + } + }, + { + "name": "test_expr_error_space_separated_identifiers", + "description": "Space-separated identifiers inside an interpolation are rejected.", + "action": "parse_expression_template", + "input": "${a b}", + "expect_error": { + "category": "ParseError", + "message": "Unexpected characters" + } + }, + { + "name": "test_expr_error_positional_function_arg", + "description": "A positional function argument without a name and colon is rejected.", + "action": "parse_expression_template", + "input": "${f(1)}", + "expect_error": { + "category": "ParseError", + "message": "after argument name" + } + }, + { + "name": "test_expr_max_depth_nested_interpolations", + "description": "An interpolation nested right at the recursion limit of 100 is accepted.", + "action": "parse_expression_template", + "input": "${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${${x}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", + "expect": [ + { + "path": "x" + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/functions.json b/package/__fixtures__/a2ui-conformance/core/functions.json new file mode 100644 index 0000000000..9a6d3a17bb --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/functions.json @@ -0,0 +1,1286 @@ +[ + { + "name": "test_function_format_date_tr35_tokens", + "description": "Verifies formatDate TR35 token expansion (yyyy-MM-dd).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatDate", + "args": { + "value": "2026-09-04T12:00:00Z", + "format": "yyyy-MM-dd" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "2026-09-04" + } + } + } + } + } + }, + { + "name": "test_function_format_currency_locale_and_symbol", + "description": "Verifies formatCurrency with currency symbol, decimals, and grouping separators.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatCurrency", + "args": { + "value": 1234.5, + "currency": "USD", + "decimals": 2 + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "$1,234.50" + } + } + } + } + } + }, + { + "name": "test_function_pluralize_categories", + "description": "Verifies pluralize resolving CLDR categories (one, other).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "t1", + "t2" + ] + }, + { + "id": "t1", + "component": "Text", + "text": { + "@call": "pluralize", + "args": { + "value": 1, + "one": "1 item", + "other": "items" + } + } + }, + { + "id": "t2", + "component": "Text", + "text": { + "@call": "pluralize", + "args": { + "value": 5, + "one": "1 item", + "other": "5 items" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "t1": { + "component": "Text", + "text": "1 item" + }, + "t2": { + "component": "Text", + "text": "5 items" + } + } + } + } + } + }, + { + "name": "test_function_logical_and_or_not", + "description": "Verifies and, or, and not logical function evaluations.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "t_and", + "t_or", + "t_not" + ] + }, + { + "id": "t_and", + "component": "Text", + "text": { + "@call": "and", + "args": { + "values": [ + true, + false + ] + } + } + }, + { + "id": "t_or", + "component": "Text", + "text": { + "@call": "or", + "args": { + "values": [ + true, + false + ] + } + } + }, + { + "id": "t_not", + "component": "Text", + "text": { + "@call": "not", + "args": { + "value": false + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "t_and": { + "component": "Text", + "text": false + }, + "t_or": { + "component": "Text", + "text": true + }, + "t_not": { + "component": "Text", + "text": true + } + } + } + } + } + }, + { + "name": "test_function_invalid_argument_schema_error", + "description": "Verifies ValidationError when a function is invoked with an argument of the wrong type.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatNumber", + "args": { + "value": "not_a_number" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_evaluate_function_required_string_valid", + "description": "Verifies required returns true for non-empty string.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": "hello" + }, + "expect": true + }, + { + "name": "test_evaluate_function_required_string_empty", + "description": "Verifies required returns false for empty string.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": "" + }, + "expect": false + }, + { + "name": "test_evaluate_function_required_null", + "description": "Verifies required returns false for null.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": null + }, + "expect": false + }, + { + "name": "test_evaluate_function_required_array_valid", + "description": "Verifies required returns true for non-empty array.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": [ + 1, + 2 + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_required_array_empty", + "description": "Verifies required returns false for empty array.", + "action": "evaluate_function", + "function": "required", + "args": { + "value": [] + }, + "expect": false + }, + { + "name": "test_evaluate_function_required_object_empty", + "description": "Verifies required returns true for empty object (D20).", + "action": "evaluate_function", + "function": "required", + "args": { + "value": {} + }, + "expect": true + }, + { + "name": "test_evaluate_function_regex_match", + "description": "Verifies regex returns true on matching string.", + "action": "evaluate_function", + "function": "regex", + "args": { + "pattern": "^[0-9]+$", + "value": "12345" + }, + "expect": true + }, + { + "name": "test_evaluate_function_regex_mismatch", + "description": "Verifies regex returns false on non-matching string.", + "action": "evaluate_function", + "function": "regex", + "args": { + "pattern": "^[0-9]+$", + "value": "abc" + }, + "expect": false + }, + { + "name": "test_evaluate_function_regex_invalid_pattern", + "description": "Verifies regex raises ExpressionError on invalid regex pattern (D21).", + "action": "evaluate_function", + "function": "regex", + "args": { + "pattern": "[invalid(", + "value": "abc" + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_email_valid", + "description": "Verifies email returns true on standard email address.", + "action": "evaluate_function", + "function": "email", + "args": { + "value": "user@example.com" + }, + "expect": true + }, + { + "name": "test_evaluate_function_email_invalid_no_domain", + "description": "Verifies email returns false on missing domain.", + "action": "evaluate_function", + "function": "email", + "args": { + "value": "invalid-email" + }, + "expect": false + }, + { + "name": "test_evaluate_function_email_invalid_single_char_tld", + "description": "Verifies email returns false on single-character TLD (D22).", + "action": "evaluate_function", + "function": "email", + "args": { + "value": "test@test.c" + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_string_valid", + "description": "Verifies length returns true when string is within bounds.", + "action": "evaluate_function", + "function": "length", + "args": { + "value": "hello", + "min": 3, + "max": 10 + }, + "expect": true + }, + { + "name": "test_evaluate_function_length_string_too_short", + "description": "Verifies length returns false when string is shorter than min.", + "action": "evaluate_function", + "function": "length", + "args": { + "value": "hi", + "min": 3 + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_string_too_long", + "description": "Verifies length returns false when string is longer than max.", + "action": "evaluate_function", + "function": "length", + "args": { + "value": "hello world", + "max": 5 + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_array_count_valid", + "description": "Verifies length counts array elements, not character length (D24).", + "action": "evaluate_function", + "function": "length", + "args": { + "value": [ + "a", + "b", + "c" + ], + "min": 2, + "max": 4 + }, + "expect": true + }, + { + "name": "test_evaluate_function_length_array_count_too_short", + "description": "Verifies length returns false when array has fewer elements than min (D24).", + "action": "evaluate_function", + "function": "length", + "args": { + "value": [ + "a" + ], + "min": 2 + }, + "expect": false + }, + { + "name": "test_evaluate_function_length_null_value", + "description": "Verifies length counts a null value, such as an unset field, as length 0.", + "protocolVersion": "v0.9", + "action": "evaluate_function", + "function": "length", + "args": { + "value": null, + "max": 10 + }, + "expect": true + }, + { + "name": "test_evaluate_function_length_null_value_too_short", + "description": "Verifies length rejects a null value when min is greater than 0.", + "protocolVersion": "v0.9", + "action": "evaluate_function", + "function": "length", + "args": { + "value": null, + "min": 1 + }, + "expect": false + }, + { + "name": "test_evaluate_function_numeric_valid_float", + "description": "Verifies numeric returns true for float within bounds.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": 42.5, + "min": 0, + "max": 100 + }, + "expect": true + }, + { + "name": "test_evaluate_function_numeric_valid_string", + "description": "Verifies numeric returns true for valid numeric string.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": "42.5", + "min": 0, + "max": 100 + }, + "expect": true + }, + { + "name": "test_evaluate_function_numeric_too_small", + "description": "Verifies numeric returns false when value is below min.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": -5, + "min": 0 + }, + "expect": false + }, + { + "name": "test_evaluate_function_numeric_too_large", + "description": "Verifies numeric returns false when value is above max.", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": 150, + "max": 100 + }, + "expect": false + }, + { + "name": "test_evaluate_function_numeric_unparseable_string", + "description": "Verifies numeric returns false for unparseable string without raising (D23).", + "action": "evaluate_function", + "function": "numeric", + "args": { + "value": "not_a_number" + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_all_true", + "description": "Verifies and returns true when all values are true.", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + true, + true + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_and_one_false", + "description": "Verifies and returns false when any value is false.", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + true, + false + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_truthiness_coercion_true", + "description": "Verifies and truthiness coercion on non-boolean values (D26).", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + 1, + "non-empty" + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_and_truthiness_coercion_false", + "description": "Verifies and truthiness coercion with a falsy value (D26).", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + 1, + "" + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_fewer_than_two_values_error", + "description": "Verifies and requires at least 2 values per schema (D25).", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + true + ] + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_or_all_false", + "description": "Verifies or returns false when all values are false.", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + false, + false + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_or_one_true", + "description": "Verifies or returns true when at least one value is true.", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + false, + true + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_or_truthiness_coercion_true", + "description": "Verifies or truthiness coercion on non-boolean values (D26).", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + 0, + "yes" + ] + }, + "expect": true + }, + { + "name": "test_evaluate_function_or_fewer_than_two_values_error", + "description": "Verifies or requires at least 2 values per schema (D25).", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + false + ] + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_not_true", + "description": "Verifies not returns false for true.", + "action": "evaluate_function", + "function": "not", + "args": { + "value": true + }, + "expect": false + }, + { + "name": "test_evaluate_function_not_false", + "description": "Verifies not returns true for false.", + "action": "evaluate_function", + "function": "not", + "args": { + "value": false + }, + "expect": true + }, + { + "name": "test_evaluate_function_not_truthiness_falsy", + "description": "Verifies not truthiness coercion for falsy value (D26).", + "action": "evaluate_function", + "function": "not", + "args": { + "value": "" + }, + "expect": true + }, + { + "name": "test_evaluate_function_not_truthiness_truthy", + "description": "Verifies not truthiness coercion for truthy value (D26).", + "action": "evaluate_function", + "function": "not", + "args": { + "value": "hello" + }, + "expect": false + }, + { + "name": "test_evaluate_function_and_validation_result_operands", + "description": "Verifies and reads a failing ValidationResult operand as false in v1.0.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "and", + "args": { + "values": [ + { + "valid": true + }, + { + "valid": false, + "message": "Required", + "code": "required" + } + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_or_validation_result_operands", + "description": "Verifies or over two failing ValidationResult operands is false in v1.0.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "or", + "args": { + "values": [ + { + "valid": false + }, + { + "valid": false + } + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_not_validation_result_operand", + "description": "Verifies not inverts the validity of a ValidationResult operand in v1.0.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "not", + "args": { + "value": { + "valid": false + } + }, + "expect": true + }, + { + "name": "test_evaluate_function_not_validation_result_operand_v0_9", + "description": "Verifies v0.9, whose validators return booleans, treats a {valid} object as a plain truthy object.", + "protocolVersion": "v0.9", + "action": "evaluate_function", + "function": "not", + "args": { + "value": { + "valid": false + } + }, + "expect": false + }, + { + "name": "test_evaluate_function_pluralize_explicit_zero", + "description": "Verifies pluralize selects explicit zero category over CLDR (D27).", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 0, + "zero": "No items", + "one": "1 item", + "other": "{n} items" + }, + "expect": "No items" + }, + { + "name": "test_evaluate_function_pluralize_explicit_one", + "description": "Verifies pluralize selects explicit one category.", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 1, + "zero": "No items", + "one": "1 item", + "other": "{n} items" + }, + "expect": "1 item" + }, + { + "name": "test_evaluate_function_pluralize_explicit_two", + "description": "Verifies pluralize selects explicit two category (D27).", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 2, + "zero": "No items", + "one": "1 item", + "two": "A couple items", + "other": "{n} items" + }, + "expect": "A couple items" + }, + { + "name": "test_evaluate_function_pluralize_explicit_empty_string", + "description": "Verifies pluralize preserves explicit empty string without falling back to other (D27).", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 0, + "zero": "", + "other": "default" + }, + "expect": "" + }, + { + "name": "test_evaluate_function_pluralize_cldr_other", + "description": "Verifies pluralize falls back to other for unlisted category.", + "action": "evaluate_function", + "function": "pluralize", + "args": { + "value": 5, + "one": "1 item", + "other": "Many items" + }, + "expect": "Many items" + }, + { + "name": "test_evaluate_function_format_number_decimals", + "description": "Verifies formatNumber formats with decimals and grouping.", + "action": "evaluate_function", + "function": "formatNumber", + "args": { + "value": 1234.567, + "decimals": 2, + "grouping": true + }, + "expect": "1,234.57" + }, + { + "name": "test_evaluate_function_format_number_zero_decimals", + "description": "Verifies formatNumber formats with zero decimals and no grouping.", + "action": "evaluate_function", + "function": "formatNumber", + "args": { + "value": 1234.567, + "decimals": 0, + "grouping": false + }, + "expect": "1235" + }, + { + "name": "test_evaluate_function_format_currency_usd", + "description": "Verifies formatCurrency formats USD currency.", + "action": "evaluate_function", + "function": "formatCurrency", + "args": { + "value": 99.99, + "currency": "USD", + "decimals": 2 + }, + "expect": "$99.99" + }, + { + "name": "test_evaluate_function_format_date_custom_pattern", + "description": "Verifies formatDate formats date with custom pattern.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-08-26T12:00:00Z", + "format": "yyyy-MM-dd" + }, + "expect": "2026-08-26" + }, + { + "name": "test_evaluate_function_format_date_iso_normalized", + "description": "Verifies formatDate normalizes ISO output to UTC YYYY-MM-DDTHH:mm:ss.sssZ (D29).", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-08-26T12:00:00.000Z", + "format": "ISO" + }, + "expect": "2026-08-26T12:00:00.000Z" + }, + { + "name": "test_evaluate_function_format_date_impossible_day", + "description": "Verifies formatDate returns an empty string for a day that does not exist, rather than rolling it into the next month.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-02-30", + "format": "yyyy-MM-dd" + }, + "expect": "" + }, + { + "name": "test_evaluate_function_format_date_impossible_month", + "description": "Verifies formatDate returns an empty string for a month that does not exist, rather than rolling it into the next year.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-13-01", + "format": "yyyy-MM-dd" + }, + "expect": "" + }, + { + "name": "test_evaluate_function_format_date_utc_time", + "description": "Verifies formatDate renders the time fields of a UTC timestamp as written, whatever the host time zone.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-09-04T23:30:00Z", + "format": "yyyy-MM-dd HH:mm" + }, + "expect": "2026-09-04 23:30" + }, + { + "name": "test_evaluate_function_format_date_without_offset", + "description": "Verifies formatDate reads a timestamp without an offset as written (as UTC), never as host-local time.", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-09-04T23:30:00", + "format": "yyyy-MM-dd HH:mm" + }, + "expect": "2026-09-04 23:30" + }, + { + "name": "test_evaluate_function_format_date_keeps_written_offset", + "description": "Verifies formatDate renders a timestamp with an offset in that offset, so the written wall-clock date and time are kept.", + "protocolVersion": "1.0", + "action": "evaluate_function", + "function": "formatDate", + "args": { + "value": "2026-09-04T23:30:00-05:00", + "format": "yyyy-MM-dd HH:mm" + }, + "expect": "2026-09-04 23:30" + }, + { + "name": "test_evaluate_function_format_string_basic", + "description": "Verifies formatString evaluates static template string.", + "action": "evaluate_function", + "function": "formatString", + "args": { + "value": "Hello World" + }, + "expect": "Hello World" + }, + { + "name": "test_evaluate_function_format_string_null_coercion", + "description": "Verifies formatString coerces null expression to empty string (D18).", + "action": "evaluate_function", + "function": "formatString", + "args": { + "value": "Greeting: ${null}" + }, + "expect": "Greeting: " + }, + { + "name": "test_evaluate_function_open_url_valid_https", + "description": "Verifies openUrl accepts valid https scheme.", + "action": "evaluate_function", + "function": "openUrl", + "args": { + "url": "https://example.com" + }, + "expect": null + }, + { + "name": "test_evaluate_function_open_url_disallowed_scheme", + "description": "Verifies openUrl rejects disallowed URL scheme (D30).", + "action": "evaluate_function", + "function": "openUrl", + "args": { + "url": "javascript:alert(1)" + }, + "expectError": { + "category": "ExpressionError" + } + }, + { + "name": "test_evaluate_function_v10_runs_in_named_catalog", + "description": "Verifies a v1.0 call that names a catalogId runs in that catalog rather than the surface default. The default is the conformance custom catalog, which declares no formatString; the call names the basic catalog, which does.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "conformance/test_data/catalogs/custom_catalog_v1_0.json" + ], + "surface": { + "catalogId": "conformance/custom" + }, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_runs_in_named_catalog_on_surface_without_default", + "description": "Verifies a v1.0 call that names a catalogId runs on a surface that has no default catalog.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": {}, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_runs_in_surface_default", + "description": "Verifies a v1.0 call that names no catalogId runs in the surface default catalog (the basic catalog), while the conformance custom catalog is also available on the surface.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "conformance/test_data/catalogs/custom_catalog_v1_0.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatString", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_without_catalog_id_on_surface_without_default_errors", + "description": "Verifies a v1.0 call that names no catalogId fails with a CatalogError on a surface that has no default catalog, even though the only available catalog declares the function. There is no fallback to the available catalogs.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": {}, + "function": "formatString", + "args": { + "value": "Hello" + }, + "expectError": { + "category": "CatalogError", + "message": "has no default catalogId" + } + }, + { + "name": "test_evaluate_function_v10_unknown_catalog_id_errors", + "description": "Verifies a v1.0 call that names a catalogId the surface does not have fails with a CatalogError, rather than falling back to the surface default, which declares the function.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatString", + "catalogId": "missing-catalog", + "args": { + "value": "Hello" + }, + "expectError": { + "category": "CatalogError", + "message": "Catalog not found: missing-catalog" + } + }, + { + "name": "test_evaluate_function_v10_catalog_of_other_protocol_version_errors", + "description": "Verifies a v1.0 call that names the v0.9 basic catalog fails with a CatalogError. The client has that catalog, but a v1.0 surface only makes v1.0 catalogs available.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "specification/v0_9/catalogs/basic/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "args": { + "value": "Hello" + }, + "expectError": { + "category": "CatalogError", + "message": "Catalog not found" + } + }, + { + "name": "test_evaluate_function_v10_nested_call_does_not_inherit_catalog", + "description": "Verifies a call nested in the args of a v1.0 call resolves its catalog on its own. The outer call names the basic catalog; the inner call names no catalogId, and the surface has no default catalog, so the inner call fails with a CatalogError.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": {}, + "function": "formatString", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "args": { + "value": { + "@call": "formatString", + "args": { + "value": "hi" + } + } + }, + "expectError": { + "category": "CatalogError", + "message": "has no default catalogId" + } + }, + { + "name": "test_evaluate_function_v09_ignores_call_catalog_id", + "description": "Verifies a catalogId on a call is ignored before v1.0. On a v0.9 surface the call runs in the surface catalog, so naming a catalog the client does not have is not an error.", + "action": "evaluate_function", + "protocolVersion": "v0.9", + "catalogPaths": [ + "specification/v0_9/catalogs/basic/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + }, + "function": "formatString", + "catalogId": "missing-catalog", + "args": { + "value": "Hello" + }, + "expect": "Hello" + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_format_number_grouping", + "description": "Verifies a failing ValidationResult bound to formatNumber's DynamicBoolean grouping argument reads as false, turning grouping off. An object is truthy in most languages, so an engine that does not coerce it groups the digits.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatNumber", + "args": { + "value": 1234, + "decimals": 0, + "grouping": { + "@call": "required", + "args": { + "value": "" + } + } + }, + "expect": "1234" + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_format_number_grouping_valid", + "description": "Verifies a passing ValidationResult bound to formatNumber's DynamicBoolean grouping argument reads as true, keeping grouping on.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "formatNumber", + "args": { + "value": 1234, + "decimals": 0, + "grouping": { + "@call": "required", + "args": { + "value": "x" + } + } + }, + "expect": "1,234" + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_not_operand_on_surface", + "description": "Verifies a ValidationResult resolved from a nested validation call into not's DynamicBoolean operand is read by its validity when the call runs on a surface.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "function": "not", + "args": { + "value": { + "@call": "required", + "args": { + "value": "" + } + } + }, + "expect": true + }, + { + "name": "test_evaluate_function_v10_validation_result_coerced_in_and_operands_on_surface", + "description": "Verifies the ValidationResults resolved from nested validation calls into and's DynamicBoolean operands are read by their validity when the call runs on a surface, as in the specification's enable-when-valid example.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "surface": { + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + }, + "dataModel": { + "email": "a@b.c", + "phone": "" + }, + "function": "and", + "args": { + "values": [ + { + "@call": "required", + "args": { + "value": { + "@path": "/email" + } + } + }, + { + "@call": "required", + "args": { + "value": { + "@path": "/phone" + } + } + } + ] + }, + "expect": false + }, + { + "name": "test_evaluate_function_v10_agent_only_function_errors", + "description": "Verifies a v1.0 call on a surface to a function whose allowedCallers is agentOnly fails with an ExpressionError and does not run.", + "action": "evaluate_function", + "protocolVersion": "1.0", + "catalogPaths": [ + "conformance/test_data/catalogs/allowed_callers_catalog_v1_0.json" + ], + "surface": { + "catalogId": "https://a2ui.org/catalogs/v10/allowed_callers" + }, + "function": "readSessionToken", + "args": {}, + "expectError": { + "category": "ExpressionError", + "message": "cannot be called by renderer" + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/index_function.json b/package/__fixtures__/a2ui-conformance/core/index_function.json new file mode 100644 index 0000000000..283f21106e --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/index_function.json @@ -0,0 +1,270 @@ +[ + { + "name": "test_index_function_in_collection_loop", + "description": "Verifies built-in @index function evaluation during collection template iteration.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "tasks": [ + "Buy Milk", + "Clean Kitchen", + "Pay Bills" + ] + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "list" + ] + }, + { + "id": "list", + "component": "List", + "children": { + "path": "/tasks", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Row", + "children": [ + "item_index", + "item_text" + ] + }, + { + "id": "item_index", + "component": "Text", + "text": { + "@call": "@index", + "args": {} + } + }, + { + "id": "item_text", + "component": "Text", + "text": { + "@path": "" + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "item_index_0": { + "text": "0" + }, + "item_text_0": { + "text": "Buy Milk" + }, + "item_index_1": { + "text": "1" + }, + "item_text_1": { + "text": "Clean Kitchen" + }, + "item_index_2": { + "text": "2" + }, + "item_text_2": { + "text": "Pay Bills" + } + } + } + } + } + }, + { + "name": "test_index_function_with_offset", + "description": "Verifies built-in @index function with offset argument (@index(1) for 1-based indexing).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "players": [ + "Alice", + "Bob" + ] + }, + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/players", + "componentId": "text" + } + }, + { + "id": "text", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "text_0": { + "text": "1" + }, + "text_1": { + "text": "2" + } + } + } + } + } + }, + { + "name": "test_index_function_nested_path", + "description": "Verifies built-in @index function evaluation inside a deeply nested child property path of a template list.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "dataModel": { + "users": [ + { + "name": "Alice", + "address": { + "city": "Seattle" + } + }, + { + "name": "Bob", + "address": { + "city": "Portland" + } + } + ] + }, + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/users", + "componentId": "col" + } + }, + { + "id": "col", + "component": "Column", + "children": [ + "nested_text" + ] + }, + { + "id": "nested_text", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "nested_text_0": { + "text": "1" + }, + "nested_text_1": { + "text": "2" + } + } + } + } + } + }, + { + "name": "test_index_function_outside_loop_error", + "description": "Verifies error thrown when @index function is called outside a collection iteration scope.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "@index", + "args": {} + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "@index function can only be evaluated inside a collection template iteration scope." + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/message_processor_v1_0.json b/package/__fixtures__/a2ui-conformance/core/message_processor_v1_0.json new file mode 100644 index 0000000000..05b297adb9 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/message_processor_v1_0.json @@ -0,0 +1,5222 @@ +[ + { + "name": "test_v10_create_surface_inline_initialization", + "description": "Tests v1.0 createSurface message with inline components and initial dataModel initialization.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10", + "catalogId": "test-catalog-v10", + "sendDataModel": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text1" + ] + }, + { + "id": "text1", + "component": "Text", + "text": "Inline Text" + } + ], + "dataModel": { + "counter": 42 + } + } + } + ], + "expect": { + "surfaces": { + "s_v10": { + "exists": true, + "sendDataModel": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text1" + ] + }, + { + "id": "text1", + "component": "Text", + "text": "Inline Text" + } + ], + "dataModel": { + "counter": 42 + } + } + } + } + }, + { + "name": "test_v10_create_surface_optional_catalog_id", + "description": "Tests that v1.0 createSurface permits omitting catalogId.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_nocat" + } + } + ], + "expect": { + "surfaces": { + "s_v10_nocat": { + "exists": true + } + } + } + }, + { + "name": "test_v10_create_surface_with_metadata_extensions", + "description": "Tests v1.0 createSurface with metadata and extension descriptors.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_meta", + "catalogId": "test-catalog-v10", + "metadata": { + "extensions": { + "a2ui_experimental": { + "streaming": true + } + } + } + } + } + ], + "expect": { + "surfaces": { + "s_v10_meta": { + "exists": true + } + } + } + }, + { + "name": "test_v10_create_surface_metadata_extension_key_must_be_identifier", + "description": "Tests that v1.0 createSurface rejects metadata extensions whose key is not a Unicode identifier (UAX 31).", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_meta_bad_key", + "catalogId": "test-catalog-v10", + "metadata": { + "extensions": { + "a2ui:experimental": { + "streaming": true + } + } + } + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_create_surface_metadata_only_holds_extensions", + "description": "Tests that v1.0 createSurface rejects metadata keys other than extensions.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_meta_bad_field", + "catalogId": "test-catalog-v10", + "metadata": { + "owner": "me" + } + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_component_catalog_override", + "description": "Tests that individual components in v1.0 can specify a catalogId override different from the surface default.", + "action": "process_messages", + "catalogs": [ + { + "catalogId": "main-catalog", + "protocolVersion": "1.0" + }, + { + "catalogId": "charts-catalog", + "protocolVersion": "1.0" + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_multi", + "catalogId": "main-catalog" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_multi", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "PieChart", + "catalogId": "charts-catalog", + "data": [ + 10, + 20, + 30 + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_multi": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "PieChart", + "data": [ + 10, + 20, + 30 + ] + } + ] + } + } + } + }, + { + "name": "test_v10_update_data_model_deletion", + "description": "Tests that updating a path with value null deletes the key from the data model in v1.0.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_del", + "catalogId": "test-catalog-v10", + "dataModel": { + "user": { + "name": "Alice", + "temporaryToken": "abc123xyz" + } + } + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s_v10_del", + "path": "/user/temporaryToken", + "value": null + } + } + ], + "expect": { + "surfaces": { + "s_v10_del": { + "dataModel": { + "user": { + "name": "Alice" + } + } + } + } + } + }, + { + "name": "test_v10_get_renderer_capabilities", + "description": "Tests generating v1.0 renderer capabilities structure.", + "action": "get_renderer_capabilities", + "catalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0" + } + ], + "args": { + "includeInlineCatalogs": false, + "version": "1.0" + }, + "expect": { + "1.0": { + "supportedCatalogIds": [ + "cat-v10" + ] + } + } + }, + { + "name": "test_v10_get_renderer_capabilities_inline_catalogs", + "description": "Tests that v1.0 inline catalogs list each component as a plain schema, without the v0.9 ComponentCommon allOf envelope, because v1.0 applies ComponentCommon at the message envelope.", + "action": "get_renderer_capabilities", + "catalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0", + "components": { + "Button": { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "label": { + "type": "string", + "description": "The button label" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ], + "args": { + "includeInlineCatalogs": true, + "version": "1.0" + }, + "expect": { + "1.0": { + "supportedCatalogIds": [ + "cat-v10" + ], + "inlineCatalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0", + "components": { + "Button": { + "type": "object", + "properties": { + "component": { + "const": "Button" + }, + "label": { + "type": "string", + "description": "The button label" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ] + } + } + }, + { + "name": "test_multiple_update_types_error", + "description": "Tests that sending a single message envelope containing multiple update actions raises a ValidationError.", + "action": "process_messages", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "test-catalog-v10" + }, + "deleteSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Message contains multiple conflicting update actions" + } + }, + { + "name": "test_batch_message_array_and_wrapper", + "description": "Tests that message processor correctly processes message list arrays and wrapped container payloads.", + "action": "process_messages", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "s2", + "catalogId": "test-catalog-v10" + } + } + ] + }, + "expect": { + "surfaces": { + "s1": { + "exists": true + }, + "s2": { + "exists": true + } + } + } + }, + { + "name": "test_v10_rejects_v08_begin_rendering_action", + "description": "Tests that v1.0 MessageProcessor rejects v0.8 beginRendering action when protocolVersion is v1.0.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "beginRendering": { + "surfaceId": "s_v10_invalid", + "catalogId": "test-catalog-v10" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "action 'beginRendering' is not supported in protocol version 1.0" + } + }, + { + "name": "test_v10_non_string_surface_id_error", + "description": "Tests that a v1.0 action with a non-string surfaceId raises a ValidationError.", + "action": "process_messages", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": 12345, + "catalogId": "test-catalog-v10" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "surfaceId must be a string" + } + }, + { + "name": "test_v10_update_components_mismatched_catalog_protocol_version_error", + "description": "Tests that updating a component with a catalog of a different protocol version raises CatalogError.", + "action": "process_messages", + "catalogs": [ + { + "catalogId": "cat-v10", + "protocolVersion": "1.0" + }, + { + "catalogId": "cat-v09", + "protocolVersion": "v0.9" + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_mismatch", + "catalogId": "cat-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_mismatch", + "components": [ + { + "id": "c1", + "component": "Button", + "catalogId": "cat-v09" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s_mismatch", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s_mismatch", + "message": "cat-v09" + } + } + } + }, + { + "name": "test_unsupported_protocol_version_error", + "description": "Verifies rejecting unsupported protocol version strings (v99.0).", + "action": "process_messages", + "messages": [ + { + "version": "v99.0", + "createSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unsupported protocol version 'v99.0'" + } + }, + { + "name": "test_v091_version_string_compatibility", + "description": "Verifies processing messages with version v0.9.1.", + "action": "process_messages", + "protocolVersion": "v0.9.1", + "messages": [ + { + "version": "v0.9.1", + "createSurface": { + "surfaceId": "s_v091", + "catalogId": "basic" + } + } + ], + "expect": { + "surfaces": { + "s_v091": { + "exists": true + } + } + } + }, + { + "name": "test_missing_version_field_in_v10_process_messages_error", + "description": "Verifies ValidationError on missing version field during process_messages.", + "action": "process_messages", + "messages": [ + { + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Missing required version field" + } + }, + { + "name": "test_batch_multi_stage_lifecycle_pipeline", + "description": "Verifies create, component update, data update, and component mutation within a single batch array.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_pipeline", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_pipeline", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text1" + ] + }, + { + "id": "text1", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s_pipeline", + "path": "/status", + "value": "ready" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_pipeline", + "components": [ + { + "id": "text1", + "component": "Text", + "text": "Updated" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_pipeline": { + "exists": true, + "dataModel": { + "status": "ready" + }, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "text1", + "component": "Text" + } + ] + } + } + } + }, + { + "name": "test_batch_duplicate_component_ids_in_same_message_error", + "description": "Verifies IntegrityError on duplicate component IDs within a single updateComponents message.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_dup", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_dup", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "Text", + "text": "First" + }, + { + "id": "c1", + "component": "Text", + "text": "Duplicate" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "s_dup", + "rendererError": { + "version": "1.0", + "error": { + "code": "INTEGRITY_ERROR", + "surfaceId": "s_dup", + "message": "c1" + } + } + } + }, + { + "name": "test_batch_atomic_rollback_on_candidate_topology_cycle", + "description": "Verifies candidate topology pre-flight cycle check aborts without mutating existing valid component tree.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_cycle", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "Text", + "text": "Valid Text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_cycle": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "c1", + "component": "Text" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_cycle", + "components": [ + { + "id": "c1", + "component": "Column", + "children": [ + "c2" + ] + }, + { + "id": "c2", + "component": "Column", + "children": [ + "c1" + ] + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError", + "surfaceId": "s_cycle", + "rendererError": { + "version": "1.0", + "error": { + "code": "RECURSION_LIMIT_EXCEEDED", + "surfaceId": "s_cycle", + "message": "Circular" + } + } + } + } + ] + }, + { + "name": "test_update_components_on_deleted_surface_error", + "description": "Verifies updating components on a deleted surface raises IntegrityError.", + "action": "process_messages", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_del", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "deleteSurface": { + "surfaceId": "s_del" + } + } + ], + "expect": { + "surfaces": { + "s_del": { + "exists": false + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_del", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Fail" + } + ] + } + } + ], + "expectError": { + "category": "IntegrityError" + } + } + ] + }, + { + "name": "test_update_data_model_on_deleted_surface_error", + "description": "Verifies updating data model on a deleted surface raises IntegrityError.", + "action": "process_messages", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_del_data", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "deleteSurface": { + "surfaceId": "s_del_data" + } + } + ], + "expect": { + "surfaces": { + "s_del_data": { + "exists": false + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s_del_data", + "path": "/key", + "value": "value" + } + } + ], + "expectError": { + "category": "IntegrityError" + } + } + ] + }, + { + "name": "test_delete_non_existent_surface_is_safe_noop", + "description": "Verifies deleting a non-existent surface ID executes cleanly as a no-op.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "deleteSurface": { + "surfaceId": "non_existent_surface_id" + } + } + ], + "expect": { + "surfaces": { + "non_existent_surface_id": { + "exists": false + } + } + } + }, + { + "name": "test_permissive_mode_allows_orphan_components", + "description": "Verifies strictMode false accepts components unreachable from root.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_permissive_orphan", + "catalogId": "test-catalog-v10", + "components": [ + { + "id": "root", + "component": "Column", + "children": [] + }, + { + "id": "orphan_comp", + "component": "Text", + "text": "Unreachable Text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_permissive_orphan": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "orphan_comp", + "component": "Text" + } + ] + } + } + } + }, + { + "name": "test_permissive_mode_allows_dangling_references", + "description": "Verifies strictMode false accepts containers referencing uncreated child IDs.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_permissive_dangling", + "catalogId": "test-catalog-v10", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "uncreated_child_node" + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_permissive_dangling": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + } + ] + } + } + } + }, + { + "name": "test_v10_call_validation_config_relaxes_strict_processor", + "description": "A strict processor accepts an orphan component in a call whose validationConfig allows orphans, and rejects one in the next call, which has no validationConfig.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "validationConfig": { + "allowOrphanComponents": true + }, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_relaxed_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_relaxed_call": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text" + }, + { + "id": "orphan", + "component": "Text" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_strict_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": { + "message": "is not reachable from" + } + } + ] + }, + { + "name": "test_v10_call_validation_config_tightens_permissive_processor", + "description": "A processor created without validation rules rejects an orphan component in a call whose validationConfig is strict, and accepts one in the next call, which has no validationConfig.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": false, + "steps": [ + { + "validationConfig": {}, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_strict_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": { + "message": "is not reachable from" + } + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_permissive_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_permissive_call": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text" + }, + { + "id": "orphan", + "component": "Text" + } + ] + } + } + } + } + ] + }, + { + "name": "test_v10_call_validation_config_leaves_out_rules_as_strict", + "description": "A call whose validationConfig allows only orphans still rejects a dangling child reference, since the rules it leaves out are strict, even on a processor created without validation rules.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": false, + "steps": [ + { + "validationConfig": { + "allowOrphanComponents": true + }, + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_dangling_call", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "missing_child" + ] + } + ] + } + } + ], + "expectError": { + "message": "Dangling reference" + } + } + ] + }, + { + "name": "test_composition_constraints_preserved_on_partial_parent_update", + "description": "Verifies updating container property without redefining children preserves existing child relationships.", + "action": "process_messages", + "protocolVersion": "1.0", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_partial_parent", + "catalogId": "test-catalog-v10", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "text_child" + ] + }, + { + "id": "text_child", + "component": "Text", + "text": "Child Text" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_partial_parent", + "components": [ + { + "id": "text_child", + "component": "Text", + "text": "Updated Child Text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_partial_parent": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column" + }, + { + "id": "text_child", + "component": "Text" + } + ] + } + } + } + }, + { + "name": "test_global_recursion_depth_limit_exceeded_error", + "description": "Verifies object nesting exceeding limit (>50) raises RecursionError.", + "action": "validate", + "protocolVersion": "1.0", + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_rec", + "catalogId": "basic" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_rec", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Deep", + "meta": { + "l1": { + "l2": { + "l3": { + "l4": { + "l5": { + "l6": { + "l7": { + "l8": { + "l9": { + "l10": { + "l11": { + "l12": { + "l13": { + "l14": { + "l15": { + "l16": { + "l17": { + "l18": { + "l19": { + "l20": { + "l21": { + "l22": { + "l23": { + "l24": { + "l25": { + "l26": { + "l27": { + "l28": { + "l29": { + "l30": { + "l31": { + "l32": { + "l33": { + "l34": { + "l35": { + "l36": { + "l37": { + "l38": { + "l39": { + "l40": { + "l41": { + "l42": { + "l43": { + "l44": { + "l45": { + "l46": { + "l47": { + "l48": { + "l49": { + "l50": { + "l51": { + "l52": "too deep" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "RecursionError", + "message": "Global recursion limit exceeded" + } + } + ] + }, + { + "name": "test_v10_component_without_catalog_id_on_surface_without_default_errors", + "description": "A component that names no catalogId on a surface that names no default catalog cannot be resolved, so the update fails instead of falling back to the first registered catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_components_naming_catalogs_on_surface_without_default", + "description": "A surface without a default catalog accepts components that each name their catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Column", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "children": [ + "greeting", + "badge" + ] + }, + { + "id": "greeting", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": "Hello" + }, + { + "id": "badge", + "component": "Badge", + "catalogId": "custom-v10", + "label": "New" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "greeting", + "badge" + ] + }, + { + "id": "greeting", + "component": "Text", + "text": "Hello" + }, + { + "id": "badge", + "component": "Badge", + "label": "New" + } + ] + } + } + } + }, + { + "name": "test_v10_component_from_named_catalog_with_closed_schema_accepted", + "description": "A component's catalogId belongs to the component envelope (ComponentCommon), not to its properties. A catalog whose component schema is closed and declares no catalogId accepts a component that names that catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogs": [ + { + "catalogId": "closed-v10", + "protocolVersion": "1.0", + "components": { + "Tag": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "functions": {} + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tag", + "catalogId": "closed-v10", + "label": "Hello" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Tag", + "label": "Hello" + } + ] + } + } + } + }, + { + "name": "test_v10_function_call_naming_catalog_without_that_function_errors", + "description": "Under strictMode: true (allow_unknown_elements=False), a function call that names a catalogId is validated against that catalog's function schema, which does not declare formatString. With strictMode: false, the call is accepted at processing time so a renderer can route it via callAgentFunction.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "custom-v10", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s", + "message": "Unrecognized function 'formatString'", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s", + "path": "/components/0/text", + "message": "Unrecognized function 'formatString'" + } + } + } + }, + { + "name": "test_v10_function_call_naming_catalog_with_invalid_args_errors", + "description": "A function call that names a catalogId has its args validated against that catalog's function definition.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "phrase": { + "type": "string" + } + }, + "required": [ + "phrase" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "phrase": 3 + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s", + "message": "phrase", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s", + "path": "/components/0/text/args/phrase", + "message": "phrase" + } + } + } + }, + { + "name": "test_v10_function_call_naming_catalog_with_valid_args", + "description": "A component from the surface default catalog may call a function from another catalog by naming its catalogId.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_function_call_naming_unknown_catalog_errors", + "description": "A function call that names a catalogId the client does not have is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "shout", + "catalogId": "missing-catalog", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "Catalog not found: missing-catalog" + } + }, + { + "name": "test_v10_function_call_with_empty_catalog_id_errors", + "description": "A function call whose catalogId is an empty string names a catalog, so it does not fall back to the surface default. No catalog has an empty ID, so the call is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "Catalog not found" + } + }, + { + "name": "test_v10_component_with_non_string_catalog_id_errors", + "description": "A component's catalogId must be a string. A number is rejected as invalid instead of being ignored, which would resolve the component to the surface default.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": 123, + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "catalogId" + } + }, + { + "name": "test_v10_function_call_without_catalog_id_uses_surface_default", + "description": "A function call that names no catalogId resolves against the surface default catalog, not the catalog of the component that contains it. formatString is declared by the basic catalog only.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": { + "@call": "formatString", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_function_call_without_catalog_id_to_unknown_function_errors", + "description": "Under strictMode: true (allow_unknown_elements=False), a function call that names no catalogId is validated against the surface default catalog's function schema, which does not declare notAFunction. With strictMode: false, the call is accepted at processing time so a renderer can route it via callAgentFunction.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "notAFunction", + "args": {} + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'notAFunction'" + } + }, + { + "name": "test_v10_function_call_without_catalog_id_on_surface_without_default_errors", + "description": "A function call that names no catalogId on a surface without a default catalog cannot be resolved, even when the component's own catalog declares the function.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": { + "@call": "shout", + "args": { + "value": "Hello" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_index_function_on_surface_without_default", + "description": "@index is a universal system function that belongs to no catalog, so a template may call it without a catalogId on a surface that has no default catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_index_function_on_surface_with_default", + "description": "@index is accepted on a surface whose default catalog is the basic catalog; it is not looked up as a catalog function.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_component_update_without_catalog_id_on_surface_without_default_errors", + "description": "An update that names no catalogId resolves against the surface default, not the catalog of the component's earlier version, so it fails on a surface without a default catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": "Hello" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Goodbye" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_component_update_without_catalog_id_uses_surface_default", + "description": "An update that names no catalogId resolves against the surface default catalog even when the component's earlier version named another catalog. The basic catalog declares no Badge, so the update is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": "First" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Badge" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "label": "Second" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "'Badge'" + } + } + ] + }, + { + "name": "test_v10_nested_call_in_args_resolves_its_own_catalog", + "description": "A call nested in the args of another call resolves its catalog on its own and does not inherit the catalog of the call around it. The outer wrap names custom-v10; the inner shout names no catalogId, so it resolves to the surface default (the basic catalog), which under strictMode: true rejects shout because the basic catalog does not declare it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "wrap", + "catalogId": "custom-v10", + "args": { + "value": { + "@call": "shout", + "args": { + "value": "hi" + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'shout'" + } + }, + { + "name": "test_v10_nested_call_in_args_naming_its_catalog", + "description": "A call nested in the args of another call is accepted when it names the catalog that declares it, even though the surface default (the basic catalog) does not declare it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "wrap", + "catalogId": "custom-v10", + "args": { + "value": { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "hi" + } + } + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_nested_call_in_args_on_surface_without_default_errors", + "description": "On a surface without a default catalog, a call nested in the args of a call that names custom-v10 cannot be resolved when it names no catalogId itself, even though custom-v10 declares it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Badge", + "catalogId": "custom-v10", + "label": { + "@call": "wrap", + "catalogId": "custom-v10", + "args": { + "value": { + "@call": "shout", + "args": { + "value": "hi" + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_call_in_index_args_is_checked", + "description": "@index needs no catalog, but a call in its args is still resolved and checked. Here the offset calls notAFunction, which under strictMode: true is rejected because the surface default (the basic catalog) does not declare it.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": { + "@call": "notAFunction", + "args": {} + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'notAFunction'" + } + }, + { + "name": "test_v10_call_in_index_args_on_surface_without_default_errors", + "description": "On a surface without a default catalog, @index itself is accepted, but a call in its args that names no catalogId cannot be resolved.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "List", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "children": { + "path": "/items", + "componentId": "row" + } + }, + { + "id": "row", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": { + "@call": "@index", + "args": { + "offset": { + "@call": "formatNumber", + "args": { + "value": 1 + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_calls_in_lists_naming_their_catalog", + "description": "Calls inside a list property, and inside objects nested in that list, are resolved one by one; each names custom-v10, which declares shout.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tags", + "catalogId": "custom-v10", + "items": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "a" + } + }, + { + "nested": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "b" + } + } + ] + } + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_call_in_list_without_catalog_id_uses_surface_default", + "description": "A call inside an object nested in a list property is resolved like any other call: it names no catalogId, so it resolves to the surface default (the basic catalog), which under strictMode: true rejects shout because the basic catalog does not declare it, even though the component and the list's first call use custom-v10.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tags", + "catalogId": "custom-v10", + "items": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "value": "a" + } + }, + { + "nested": [ + { + "@call": "shout", + "args": { + "value": "b" + } + } + ] + } + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Unrecognized function 'shout'" + } + }, + { + "name": "test_v10_call_in_list_with_invalid_args_errors", + "description": "A call inside an object nested in a list property has its args checked against the catalog it names; custom-v10's shout requires a string phrase.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "phrase": { + "type": "string" + } + }, + "required": [ + "phrase" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Tags", + "catalogId": "custom-v10", + "items": [ + { + "nested": [ + { + "@call": "shout", + "catalogId": "custom-v10", + "args": { + "phrase": 2 + } + } + ] + } + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "phrase" + } + }, + { + "name": "test_v10_undefined_system_function_errors", + "description": "The @ namespace is reserved for system functions, and v1.0 defines only @index. A call to @foo is rejected rather than looked up in a catalog.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "@foo" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "@foo" + } + }, + { + "name": "test_v10_function_call_with_empty_name_errors", + "description": "A function call's name must be a UAX #31 identifier. An empty name is not one, so the call is rejected rather than skipped.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "valid UAX #31 identifier" + } + }, + { + "name": "test_v10_call_without_catalog_id_on_surface_without_default_errors_without_strict_mode", + "description": "Catalog resolution does not depend on strict validation. With strictMode off, a call that names no catalogId on a surface without a default catalog is still rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json", + "text": { + "@call": "formatString", + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_call_naming_unknown_catalog_errors_without_strict_mode", + "description": "Catalog resolution does not depend on strict validation. With strictMode off, a call that names a catalogId the client does not have is still rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "missing-catalog", + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "Catalog not found: missing-catalog" + } + }, + { + "name": "test_v10_call_to_undeclared_function_without_strict_mode_accepted", + "description": "With strictMode off (default renderer processing), a function call whose catalog resolves is accepted even when that local catalog does not declare the function, so the renderer can forward it to the agent via callAgentFunction.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "notAFunction", + "args": {} + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s": { + "exists": true + } + } + } + }, + { + "name": "test_v10_call_naming_catalog_of_other_protocol_version_errors", + "description": "Every catalog a v1.0 surface uses must be built for v1.0. A call that names legacy-v09, a v0.9 catalog the client has, cannot be resolved on a v1.0 surface. The message check only requires the error to name the catalog, since SDKs word the reason differently.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + }, + { + "catalogId": "legacy-v09", + "protocolVersion": "v0.9", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": "legacy-v09", + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "message": "legacy-v09" + } + }, + { + "name": "test_v10_call_with_non_string_catalog_id_errors", + "description": "A call's catalogId must be a string. A number is rejected as invalid instead of being ignored, which would resolve the call to the surface default.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "catalogId": 3, + "args": { + "value": "hi" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_component_naming_catalog_is_checked_against_that_catalog", + "description": "A component that names a catalogId is validated against that catalog, not the surface default. custom-v10 declares no Text, although the default (the basic catalog) does.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "catalogs": [ + { + "catalogId": "custom-v10", + "protocolVersion": "1.0", + "components": { + "Badge": { + "type": "object", + "properties": { + "component": { + "const": "Badge" + }, + "label": { + "$ref": "common_types.json#/$defs/DynamicString" + } + }, + "required": [ + "component", + "label" + ] + }, + "Tags": { + "type": "object", + "properties": { + "component": { + "const": "Tags" + }, + "items": { + "type": "array" + } + }, + "required": [ + "component", + "items" + ] + } + }, + "functions": { + "shout": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "shout" + }, + "args": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + }, + "wrap": { + "type": "object", + "returnType": "string", + "properties": { + "@call": { + "const": "wrap" + }, + "args": { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "@call", + "args" + ] + } + } + } + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "catalogId": "custom-v10", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Text" + } + }, + { + "name": "test_v10_component_without_catalog_id_errors_with_a_single_catalog", + "description": "There is no fallback to the catalogs the client supports, even when the case supplies only one. A component that names no catalogId on a surface without a default catalog is rejected.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": true, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expectError": { + "category": "CatalogError", + "surfaceId": "s", + "message": "has no default catalogId", + "rendererError": { + "version": "1.0", + "error": { + "code": "CATALOG_NOT_FOUND", + "surfaceId": "s", + "message": "has no default catalogId" + } + } + } + }, + { + "name": "test_v10_get_renderer_data_model_includes_surface_without_default", + "description": "A v1.0 surface created without a default catalog keeps the protocol version of the message that created it, so it is part of a v1.0 data model request. Its data model is empty.", + "action": "get_renderer_data_model", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "sendDataModel": true + } + } + ], + "args": { + "version": "1.0" + }, + "expect": { + "version": "1.0", + "surfaces": { + "s": {} + } + } + }, + { + "name": "test_v10_get_renderer_data_model_excludes_surface_without_default_from_v09", + "description": "A v1.0 surface created without a default catalog is not part of a v0.9 data model request, so the request finds no surface to report.", + "action": "get_renderer_data_model", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "sendDataModel": true + } + } + ], + "args": { + "version": "v0.9" + }, + "expect": null + }, + { + "name": "test_v10_get_renderer_data_model_filters_by_surface_protocol_version", + "description": "With a v1.0 surface that has no default catalog and a v0.9 surface side by side, a v0.9 data model request reports only the v0.9 surface. The surfaces are created in separate steps, one batch per protocol version.", + "action": "get_renderer_data_model", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json", + "specification/v0_9/catalogs/basic/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s", + "sendDataModel": true + } + } + ] + }, + { + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "t", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "sendDataModel": true + } + } + ] + } + ], + "args": { + "version": "v0.9" + }, + "expect": { + "version": "v0.9", + "surfaces": { + "t": {} + } + } + }, + { + "name": "test_v10_update_components_stores_component_metadata", + "description": "Tests that v1.0 updateComponents keeps ComponentCommon.metadata apart from component properties and exposes it as component metadata.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta", + "catalogId": "test-catalog-v10", + "metadata": { + "extensions": { + "com_example_session": "abc" + } + } + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_comp_meta": { + "exists": true, + "metadata": { + "extensions": { + "com_example_session": "abc" + } + }, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + } + } + }, + { + "name": "test_v10_update_components_replaces_component_metadata", + "description": "Tests that a later v1.0 updateComponents entry replaces a component's metadata in full rather than merging it.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_replace", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_replace", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1", + "com_example_state": "draft" + } + } + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_replace", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_state": "final" + } + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_comp_meta_replace": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_state": "final" + } + } + } + ] + } + } + } + }, + { + "name": "test_v10_update_components_omitted_metadata_clears_component_metadata", + "description": "Tests that a v1.0 updateComponents entry without metadata clears the component's previously stored metadata, because an update replaces the definition in full.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_clear", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_clear", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_clear", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_v10_comp_meta_clear": { + "exists": true, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": null + } + ] + } + } + } + }, + { + "name": "test_v10_update_components_metadata_extension_key_must_be_identifier", + "description": "Tests that v1.0 updateComponents rejects component metadata extensions whose key is not a Unicode identifier (UAX 31) when the component is first created.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_bad_key", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_bad_key", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com.example.trace": "t1" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_update_components_metadata_extension_key_must_be_identifier_on_update", + "description": "Tests that v1.0 updateComponents rejects an invalid UAX 31 extension key on an existing component and leaves the previously stored metadata untouched.", + "action": "process_messages", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_v10_comp_meta_bad_key_update", + "catalogId": "test-catalog-v10" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_bad_key_update", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com_example_trace": "t1" + } + } + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_v10_comp_meta_bad_key_update", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "metadata": { + "extensions": { + "com-example-trace": "t2" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_v10_permissive_incremental_streaming_out_of_order_before_root", + "description": "Multi-step strictMode: false incremental streaming case verifying that out-of-order child components arriving before root emit zero errors and assemble cleanly once root arrives.", + "action": "process_messages", + "protocolVersion": "1.0", + "strictMode": false, + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_stream", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_stream", + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_stream": { + "exists": true, + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_stream", + "components": [ + { + "id": "mid_card", + "component": "Card", + "child": "leaf_text" + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_stream": { + "exists": true, + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + }, + { + "id": "mid_card", + "component": "Card", + "child": "leaf_text" + } + ] + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s_stream", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "mid_card" + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "s_stream": { + "exists": true, + "components": [ + { + "id": "leaf_text", + "component": "Text", + "text": "Streamed Leaf" + }, + { + "id": "mid_card", + "component": "Card", + "child": "leaf_text" + }, + { + "id": "root", + "component": "Column", + "children": [ + "mid_card" + ] + } + ] + } + } + } + } + ] + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/multi_catalog.json b/package/__fixtures__/a2ui-conformance/core/multi_catalog.json new file mode 100644 index 0000000000..d12fcec8f1 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/multi_catalog.json @@ -0,0 +1,237 @@ +[ + { + "name": "test_multi_catalog_explicit_catalog_id_resolution", + "description": "Verifies resolving component definition using explicit catalogId specified on component instance.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "chart_catalog" + ] + }, + "components": { + "c1": { + "component": "Text", + "catalogId": "basic", + "text": "Default Catalog Component" + }, + "c2": { + "component": "LineChart", + "catalogId": "chart_catalog", + "data": "/series/line1" + } + } + }, + "expectSelected": "chart_catalog" + }, + { + "name": "test_multi_catalog_surface_default_fallback", + "description": "Verifies component falling back to surface defaultCatalogId when catalogId is omitted.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "chart_catalog" + ] + }, + "components": { + "c1": { + "component": "Button", + "title": "Click Me" + } + } + }, + "expectSelected": "basic" + }, + { + "name": "test_multi_catalog_unregistered_catalog_error", + "description": "Verifies error thrown when component specifies catalogId not listed in surface supportedCatalogIds.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic" + ] + }, + "components": { + "c1": { + "component": "CustomWidget", + "catalogId": "unregistered_vendor_catalog" + } + } + }, + "expectError": { + "category": "CatalogError", + "message": "Catalog 'unregistered_vendor_catalog' is not supported by surface 'main_surface'." + } + }, + { + "name": "test_multi_catalog_version_mismatch_error", + "description": "Verifies error thrown when mixed catalogs specify conflicting protocol versions.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic_v1_0", + "supportedCatalogIds": [ + "basic_v1_0", + "legacy_v0_9_catalog" + ] + }, + "catalogs": { + "basic_v1_0": { + "protocolVersion": "1.0" + }, + "legacy_v0_9_catalog": { + "protocolVersion": "v0.9" + } + } + }, + "expectError": { + "category": "CatalogError", + "message": "Protocol version mismatch: cannot mix catalog 'legacy_v0_9_catalog' (v0.9) with surface version 1.0." + } + }, + { + "name": "test_multi_catalog_function_resolution_precedence", + "description": "Verifies function calls with explicit catalogId resolve to the specified catalog.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "custom_math_catalog" + ] + }, + "functionCall": { + "call": "add", + "catalogId": "custom_math_catalog", + "args": { + "a": 5, + "b": 10 + } + } + }, + "expectSelected": "custom_math_catalog" + }, + { + "name": "test_multi_catalog_cross_catalog_component_composition", + "description": "Verifies creating a surface with multiple supportedCatalogIds and rendering components from distinct catalogs in a single hierarchy.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "select_catalog", + "args": { + "surface": { + "id": "main_surface", + "defaultCatalogId": "basic", + "supportedCatalogIds": [ + "basic", + "chart_catalog", + "map_catalog" + ] + }, + "components": { + "c1": { + "component": "Text", + "catalogId": "basic", + "text": "Dashboard" + }, + "c2": { + "component": "BarChart", + "catalogId": "chart_catalog" + }, + "c3": { + "component": "GeoMap", + "catalogId": "map_catalog" + } + } + }, + "expectSelected": "map_catalog" + }, + { + "name": "test_multi_catalog_function_call_catalog_id_override", + "description": "Verifies a function call naming a non-default catalogId is evaluated by that catalog.", + "action": "validate", + "catalogs": [ + { + "catalogId": "app-cat", + "protocolVersion": "1.0", + "components": { + "Text": { + "type": "object", + "properties": { + "text": { + "$ref": "#/$defs/DynamicString" + } + } + } + } + } + ], + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "main", + "catalogId": "app-cat", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatDate", + "catalogId": "basic", + "args": { + "value": "2026-09-04T12:00:00Z", + "format": "yyyy-MM-dd" + } + } + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "main": { + "components": { + "root": { + "component": "Text", + "text": "2026-09-04" + } + } + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/reserved_keys.json b/package/__fixtures__/a2ui-conformance/core/reserved_keys.json new file mode 100644 index 0000000000..44b13ef899 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/reserved_keys.json @@ -0,0 +1,303 @@ +[ + { + "name": "test_reserved_at_path_valid", + "description": "Valid v1.0 DataBinding using the reserved @path prefix.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@path": "/user/displayName" + } + } + ] + } + } + ] + }, + { + "name": "test_reserved_at_call_valid", + "description": "Valid v1.0 FunctionCall using the reserved @call prefix.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatString", + "args": { + "value": "Hello world" + } + } + } + ] + } + } + ] + }, + { + "name": "test_reserved_at_call_index", + "description": "Valid v1.0 FunctionCall invoking the @index system context function within a List template.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "basic", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "itemTemplate", + "path": "/items" + } + }, + { + "id": "itemTemplate", + "component": "Text", + "text": { + "@call": "@index", + "args": { + "offset": 1 + } + } + } + ] + } + } + ] + }, + { + "name": "test_plain_object_with_literal_path_and_call", + "description": "A plain object containing 'path' and 'call' keys is valid in v1.0 and is not intercepted as a dynamic DataBinding or FunctionCall directive.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "path": "/var/log/system.log", + "call": "audit" + } + } + ] + } + } + ] + }, + { + "name": "test_plain_object_escaped_doubled_at_key", + "description": "Literal @ keys escaped with prefix doubling (@@path, @@type) validate successfully as plain object keys.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@@path": "/static/file", + "@@type": "Resource" + } + } + ] + } + } + ] + }, + { + "name": "test_disallowed_unknown_single_at_key", + "description": "Dynamic objects containing unknown single-@ keys (^@([^@]|$)) are disallowed to preserve backward compatibility for future directives.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@if": true, + "then": "active" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_disallowed_bare_at_key", + "description": "A key consisting of a single @ with no following character is also reserved and disallowed. It must be escaped as \"@@\" to appear as literal data.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "validate", + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@": "reserved" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + }, + { + "name": "test_escaped_doubled_at_unescaping", + "description": "Verifies that doubled-@ keys ('@@path') are unescaped to '@path' during dynamic evaluation in v1.0.", + "catalog": { + "catalogId": "custom", + "protocolVersion": "1.0", + "components": { + "CustomCard": { + "type": "object", + "properties": { + "details": { + "$ref": "common_types.json#/$defs/DynamicValue" + } + } + } + } + }, + "action": "process_messages", + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom", + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@@path": "/static/file" + } + } + ] + } + } + ], + "expect": { + "surfaces": { + "s1": { + "components": [ + { + "id": "root", + "component": "CustomCard", + "details": { + "@path": "/static/file" + } + } + ] + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/rpc_functions.json b/package/__fixtures__/a2ui-conformance/core/rpc_functions.json new file mode 100644 index 0000000000..873b662f45 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/rpc_functions.json @@ -0,0 +1,908 @@ +[ + { + "name": "test_rpc_call_renderer_function_success", + "description": "Verifies agent-initiated callRendererFunction on a function with allowedCallers=rendererOrAgent.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-101", + "callFunction": { + "@call": "playMedia", + "catalogId": "media_catalog", + "args": { + "mediaId": "video-789", + "autoplay": true + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-101", + "value": { + "playing": true, + "timestamp": 0 + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unauthorized_caller", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when allowedCallers is rendererOnly.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-102", + "callFunction": { + "@call": "internalStateReset", + "catalogId": "system_catalog", + "args": {} + } + } + }, + "functionMetadata": { + "internalStateReset": { + "allowedCallers": "rendererOnly", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-102", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Function 'internalStateReset' cannot be called by agent (allowedCallers is rendererOnly)." + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_requires_user_activation", + "description": "Verifies callRendererFunction execution when requiresUserActivation is true and user gesture is present.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "userActivationPresent": true, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-103", + "callFunction": { + "@call": "openExternalUrl", + "catalogId": "basic", + "args": { + "url": "https://example.com/details" + } + } + } + }, + "functionMetadata": { + "openExternalUrl": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": true + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-103", + "value": { + "opened": true + } + } + } + } + }, + { + "name": "test_rpc_call_agent_function_dispatch", + "description": "Verifies renderer-initiated callAgentFunction message generation and response correlation via functionCallId.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "outboundCall": { + "surfaceId": "main_surface", + "functionCallId": "agent-call-55", + "callFunction": { + "@call": "queryInventory", + "catalogId": "store_catalog", + "args": { + "sku": "ITEM-404" + } + } + }, + "inboundResponse": { + "version": "1.0", + "agentFunctionResponse": { + "functionCallId": "agent-call-55", + "value": { + "inStock": 12, + "price": "$29.99" + } + } + } + }, + "expect": { + "correlatedCallId": "agent-call-55", + "result": { + "inStock": 12, + "price": "$29.99" + } + } + }, + { + "name": "test_rpc_call_renderer_function_user_activation_denied", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when requiresUserActivation is true but user gesture context is absent.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "userActivationPresent": false, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-104", + "callFunction": { + "@call": "openExternalUrl", + "catalogId": "basic", + "args": { + "url": "https://example.com/details" + } + } + } + }, + "functionMetadata": { + "openExternalUrl": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": true + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-104", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Function 'openExternalUrl' requires user activation context to execute." + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_execution_exception", + "description": "Verifies callRendererFunction handles runtime function execution exceptions and returns EXECUTION_ERROR.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-105", + "callFunction": { + "@call": "failingFunction", + "catalogId": "basic", + "args": {} + } + } + }, + "functionMetadata": { + "failingFunction": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-105", + "error": { + "code": "EXECUTION_ERROR", + "message": "An error occurred during function execution." + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_agent_only_boundary", + "description": "Verifies agent-initiated callRendererFunction on a function with allowedCallers=agentOnly.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-106", + "callFunction": { + "@call": "syncState", + "catalogId": "basic", + "args": {} + } + } + }, + "functionMetadata": { + "syncState": { + "allowedCallers": "agentOnly", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-106", + "value": null + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unregistered_function", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when calling an unregistered function.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-107", + "callFunction": { + "@call": "unknownFunc", + "catalogId": "basic", + "args": {} + } + } + }, + "functionMetadata": {} + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-107", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Function not found: unknownFunc" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unregistered_catalog", + "description": "Verifies callRendererFunction fails with INVALID_FUNCTION_CALL when specifying an unregistered catalogId.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-108", + "callFunction": { + "@call": "playMedia", + "catalogId": "non_existent_catalog", + "args": {} + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent" + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-108", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Catalog not found: non_existent_catalog" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_payload_missing_call_function", + "description": "Verifies envelope/schema validation error when callRendererFunction is missing required callFunction property.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-109" + } + } + }, + "expect": { + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-109", + "message": "callFunction" + } + }, + "expectError": { + "category": "ValidationError", + "functionCallId": "call-109", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-109", + "message": "callFunction" + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_payload_non_object_args", + "description": "Verifies envelope/schema validation error when callRendererFunction has non-object args.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-110", + "callFunction": { + "@call": "playMedia", + "catalogId": "media_catalog", + "args": "not-an-object" + } + } + } + }, + "expect": { + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-110", + "message": "args" + } + }, + "expectError": { + "category": "ValidationError", + "functionCallId": "call-110", + "rendererError": { + "version": "1.0", + "error": { + "code": "INVALID_FUNCTION_CALL", + "functionCallId": "call-110", + "message": "args" + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_unknown_surface_id", + "description": "Verifies callRendererFunction targeting an unknown surfaceId returns rendererFunctionResponse with INVALID_FUNCTION_CALL.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-111", + "surfaceId": "unknown_surface", + "callFunction": { + "@call": "playMedia", + "catalogId": "media_catalog", + "args": { + "mediaId": "video-789" + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent" + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-111", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "unknown_surface" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_payload_missing_catalog_id", + "description": "Verifies a callRendererFunction whose callFunction has no catalogId fails schema validation, even when a held catalog defines the function.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-110", + "callFunction": { + "@call": "playMedia", + "args": { + "mediaId": "video-789" + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "error": { + "message": "catalogId" + } + } + }, + { + "name": "test_rpc_agent_function_response_inbound", + "description": "Verifies inbound agentFunctionResponse message processing by MessageProcessor.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "agentFunctionResponse": { + "functionCallId": "call-201", + "value": { + "status": "verified" + } + } + } + }, + "expect": { + "response": null + } + }, + { + "name": "test_rpc_call_renderer_function_invalid_argument_schema", + "description": "Verifies callRendererFunction returns INVALID_FUNCTION_CALL when argument schema validation fails.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-301", + "callFunction": { + "@call": "calculateTax", + "catalogId": "basic", + "args": { + "amount": "invalid_string_amount" + } + } + } + }, + "functionMetadata": { + "calculateTax": { + "allowedCallers": "rendererOrAgent", + "parameters": { + "type": "object", + "properties": { + "amount": { + "type": "number" + } + }, + "required": [ + "amount" + ] + } + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-301", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "Invalid" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_protocol_version_mismatch", + "description": "Verifies callRendererFunction returns INVALID_FUNCTION_CALL when catalog protocol version mismatches message protocol version.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "catalogId": "legacy_cat", + "catalogVersion": "v0.9", + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-302", + "callFunction": { + "@call": "playMedia", + "catalogId": "legacy_cat", + "args": { + "mediaId": "vid1" + } + } + } + }, + "functionMetadata": { + "playMedia": { + "allowedCallers": "rendererOrAgent" + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-302", + "error": { + "code": "INVALID_FUNCTION_CALL", + "message": "does not match message protocol version" + } + } + } + } + }, + { + "name": "test_rpc_call_agent_function_timeout_handling", + "description": "Verifies callAgentFunction timeout emitting TIMEOUT error code.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "outboundCall": { + "surfaceId": "main_surface", + "functionCallId": "call-timeout-1", + "timeoutMs": 10, + "callFunction": { + "@call": "longRunningTask", + "catalogId": "basic", + "args": {} + } + } + }, + "expectError": { + "code": "TIMEOUT" + } + }, + { + "name": "test_rpc_call_agent_function_duplicate_call_id_error", + "description": "Verifies callAgentFunction rejecting duplicate pending functionCallId with DUPLICATE error code.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "outboundCall": { + "surfaceId": "main_surface", + "functionCallId": "call-dup-1", + "callFunction": { + "@call": "fetchData", + "catalogId": "basic", + "args": {} + } + }, + "secondOutboundCall": { + "surfaceId": "main_surface", + "functionCallId": "call-dup-1", + "callFunction": { + "@call": "fetchData", + "catalogId": "basic", + "args": {} + } + } + }, + "expectError": { + "code": "DUPLICATE" + } + }, + { + "name": "test_rpc_call_renderer_function_with_surface_id_and_dynamic_args", + "description": "Verifies callRendererFunction with surfaceId resolves @path and @call in callFunction.args against the target surface's root DataContext (/).", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "surfaceId": "media_surface", + "dataModel": { + "media": { + "id": "video-456" + }, + "prefix": "now-" + }, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-401", + "surfaceId": "media_surface", + "callFunction": { + "@call": "echoArgs", + "catalogId": "media_catalog", + "args": { + "mediaId": { + "@path": "/media/id" + }, + "formatted": { + "@call": "formatString", + "catalogId": "basic", + "args": { + "value": "${/prefix}${/media/id}" + } + } + } + } + } + }, + "functionMetadata": { + "echoArgs": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-401", + "value": { + "mediaId": "video-456", + "formatted": "now-video-456" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_without_surface_id_isolated_data_model", + "description": "Verifies that when surfaceId is omitted on callRendererFunction, @path in callFunction.args resolves against an isolated empty {} data model even when a surface with data exists.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "surfaceId": "existing_surface", + "dataModel": { + "secret": "should-not-leak" + }, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-402", + "callFunction": { + "@call": "echoArgs", + "catalogId": "media_catalog", + "args": { + "leaked": { + "@path": "/secret" + }, + "constant": "safe" + } + } + } + }, + "functionMetadata": { + "echoArgs": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-402", + "value": { + "leaked": null, + "constant": "safe" + } + } + } + } + }, + { + "name": "test_rpc_call_renderer_function_index_at_root_execution_error", + "description": "Verifies that evaluating @index at the root DataContext in callRendererFunction fails with EXECUTION_ERROR.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "surfaceId": "main_surface", + "dataModel": {}, + "message": { + "version": "1.0", + "callRendererFunction": { + "functionCallId": "call-403", + "surfaceId": "main_surface", + "callFunction": { + "@call": "echoArgs", + "catalogId": "basic", + "args": { + "idx": { + "@call": "@index" + } + } + } + } + }, + "functionMetadata": { + "echoArgs": { + "allowedCallers": "rendererOrAgent", + "requiresUserActivation": false + } + } + }, + "expect": { + "response": { + "version": "1.0", + "rendererFunctionResponse": { + "functionCallId": "call-403", + "error": { + "code": "EXECUTION_ERROR", + "message": "index" + } + } + } + } + }, + { + "name": "test_rpc_ui_binding_triggers_call_agent_function_async_resolution", + "description": "Verifies a component property bound to an agent function initially resolves to null/undefined while emitting outbound callAgentFunction, and updates reactively when agentFunctionResponse arrives.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "uiBindingScenario": { + "surfaceId": "main_surface", + "catalogId": "store_catalog", + "component": { + "id": "root", + "component": "Text", + "text": { + "@call": "fetchRemoteGreeting", + "catalogId": "store_catalog", + "args": { + "user": "Ada" + } + } + }, + "inboundResponseValue": "Hello from agent, Ada!" + } + }, + "expect": { + "initialValue": null, + "outboundCall": { + "version": "1.0", + "callAgentFunction": { + "surfaceId": "main_surface", + "callFunction": { + "@call": "fetchRemoteGreeting", + "catalogId": "store_catalog", + "args": { + "user": "Ada" + } + } + } + }, + "resolvedValue": "Hello from agent, Ada!" + } + }, + { + "name": "test_rpc_ui_action_missing_local_function_fallback_to_agent", + "description": "Verifies an undeclared function invoked from a UI action in relaxed mode falls back to dispatching callAgentFunction to the agent.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "handle_rpc", + "args": { + "uiActionFallbackScenario": { + "surfaceId": "main_surface", + "catalogId": "store_catalog", + "actionPayload": { + "functionCall": { + "@call": "undeclaredAgentAction", + "catalogId": "store_catalog", + "args": { + "orderId": "ord-99" + } + } + }, + "inboundResponseValue": { + "accepted": true + } + } + }, + "expect": { + "outboundCall": { + "version": "1.0", + "callAgentFunction": { + "surfaceId": "main_surface", + "callFunction": { + "@call": "undeclaredAgentAction", + "catalogId": "store_catalog", + "args": { + "orderId": "ord-99" + } + } + } + }, + "result": { + "accepted": true + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/validation_result.json b/package/__fixtures__/a2ui-conformance/core/validation_result.json new file mode 100644 index 0000000000..14ebd7c363 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/validation_result.json @@ -0,0 +1,599 @@ +[ + { + "name": "test_validation_result_dynamic_object_return", + "description": "Verifies CheckRule evaluation capturing dynamic ValidationResult object returned from custom validation function.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "invalid-email-address" + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Email", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + } + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Please enter a valid email address (e.g. user@example.com)." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Please enter a valid email address (e.g. user@example.com).", + "severity": "error" + } + ] + }, + "validationResult": { + "root": { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Please enter a valid email address (e.g. user@example.com).", + "severity": "error" + } + } + } + } + } + }, + { + "name": "test_validation_result_boolean_fallback", + "description": "Verifies CheckRule evaluation when validation function returns primitive boolean, falling back to CheckRule message.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "age": 15 + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Age", + "value": { + "@path": "/age" + }, + "checks": [ + { + "condition": { + "@call": "numeric", + "args": { + "value": { + "@path": "/age" + }, + "min": 18 + } + }, + "message": "Must be 18 or older." + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Must be 18 or older." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "message": "Must be 18 or older.", + "severity": "error" + } + ] + }, + "validationResult": { + "root": { + "valid": false, + "message": "Must be 18 or older." + } + } + } + } + } + }, + { + "name": "test_validation_result_warning_and_info_non_blocking", + "description": "Verifies that CheckRules returning severity warning or info populate validationResults while keeping isValid true and validationErrors empty.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "password": "secret", + "warningCheck": { + "valid": false, + "code": "WEAK_PASSWORD", + "message": "Password could be stronger.", + "severity": "warning" + }, + "infoCheck": { + "valid": false, + "code": "PASSWORD_HINT", + "message": "Consider adding symbols.", + "severity": "info" + } + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Password", + "value": { + "@path": "/password" + }, + "checks": [ + { + "condition": { + "@path": "/warningCheck" + } + }, + { + "condition": { + "@path": "/infoCheck" + } + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": true + }, + "validationErrors": { + "root": [] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "WEAK_PASSWORD", + "message": "Password could be stronger.", + "severity": "warning" + }, + { + "valid": false, + "code": "PASSWORD_HINT", + "message": "Consider adding symbols.", + "severity": "info" + } + ] + } + } + } + } + }, + { + "name": "test_validation_result_multiple_rules_preserve_order", + "description": "Verifies that a component with two failing error rules and one failing warning rule preserves declaration order in validationErrors and validationResults.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "bad", + "warningCheck": { + "valid": false, + "code": "DOMAIN_WARNING", + "message": "External domain requires approval.", + "severity": "warning" + } + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Corporate Email", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + }, + "message": "Must be a valid email address." + }, + { + "condition": { + "@path": "/warningCheck" + } + }, + { + "condition": { + "@call": "length", + "args": { + "value": { + "@path": "/email" + }, + "min": 10 + } + }, + "message": "Email must be at least 10 characters." + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Must be a valid email address.", + "Email must be at least 10 characters." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Must be a valid email address.", + "severity": "error" + }, + { + "valid": false, + "code": "DOMAIN_WARNING", + "message": "External domain requires approval.", + "severity": "warning" + }, + { + "valid": false, + "message": "Email must be at least 10 characters.", + "severity": "error" + } + ] + } + } + } + } + }, + { + "name": "test_validation_result_reactive_recovery_on_data_update", + "description": "Verifies that failing validation recovers reactively when updateDataModel provides a valid value in a subsequent step.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "not-an-email" + }, + "components": [ + { + "id": "root", + "component": "TextField", + "label": "Email", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + } + } + ] + } + ] + } + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": false + }, + "validationErrors": { + "root": [ + "Please enter a valid email address (e.g. user@example.com)." + ] + }, + "validationResults": { + "root": [ + { + "valid": false, + "code": "INVALID_EMAIL", + "message": "Please enter a valid email address (e.g. user@example.com).", + "severity": "error" + } + ] + } + } + } + } + }, + { + "payload": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "form_surface", + "path": "/email", + "value": "user@example.com" + } + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "root": true + }, + "validationErrors": { + "root": [] + }, + "validationResults": { + "root": [] + } + } + } + } + } + ] + }, + { + "name": "test_validation_result_coercion_in_logical_and_or_not", + "description": "Verifies that ValidationResult objects returned by functions inside logical and, or, and not expressions are coerced via their .valid boolean property.", + "catalog": { + "protocolVersion": "1.0" + }, + "action": "validate", + "steps": [ + { + "payload": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "form_surface", + "catalogId": "basic", + "dataModel": { + "email": "invalid-email", + "allowOverride": true + }, + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "and_field", + "or_field", + "not_field" + ] + }, + { + "id": "and_field", + "component": "TextField", + "label": "And Field", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "and", + "args": { + "values": [ + { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + }, + { + "@path": "/allowOverride" + } + ] + } + }, + "message": "And check failed." + } + ] + }, + { + "id": "or_field", + "component": "TextField", + "label": "Or Field", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "or", + "args": { + "values": [ + { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + }, + { + "@path": "/allowOverride" + } + ] + } + }, + "message": "Or check failed." + } + ] + }, + { + "id": "not_field", + "component": "TextField", + "label": "Not Field", + "value": { + "@path": "/email" + }, + "checks": [ + { + "condition": { + "@call": "not", + "args": { + "value": { + "@call": "email", + "args": { + "value": { + "@path": "/email" + } + } + } + } + }, + "message": "Not check failed." + } + ] + } + ] + } + } + ] + } + ], + "expect": { + "surfaces": { + "form_surface": { + "isValid": { + "and_field": false, + "or_field": true, + "not_field": true + }, + "validationErrors": { + "and_field": [ + "And check failed." + ], + "or_field": [], + "not_field": [] + }, + "validationResults": { + "and_field": [ + { + "valid": false, + "message": "And check failed.", + "severity": "error" + } + ], + "or_field": [], + "not_field": [] + } + } + } + } + } +] \ No newline at end of file diff --git a/package/__fixtures__/a2ui-conformance/core/validator_v1_0.json b/package/__fixtures__/a2ui-conformance/core/validator_v1_0.json new file mode 100644 index 0000000000..611c15a926 --- /dev/null +++ b/package/__fixtures__/a2ui-conformance/core/validator_v1_0.json @@ -0,0 +1,2792 @@ +[ + { + "name": "test_validator_1_0", + "description": "Tests schema validation of v1.0 message envelopes (inline components, optional catalogId, data deletion) against the basic v1.0 catalog schema.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "sendDataModel": true, + "components": [ + { + "id": "root", + "component": "Text", + "text": "Inline Text" + } + ], + "dataModel": { + "counter": 100 + } + } + } + ] + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s_nocat" + } + } + ] + }, + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/token", + "value": null + } + } + ] + }, + { + "messages": [ + { + "createSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError", + "details": [ + { + "path": "messages.0.version", + "code": "missing_field" + } + ] + } + }, + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": 123 + } + } + ], + "expectError": { + "category": "ValidationError", + "details": [ + { + "path": "messages.0.createSurface.surfaceId", + "code": "type_mismatch" + } + ] + } + } + ] + }, + { + "name": "test_custom_catalog_1_0", + "description": "Tests component property schema validation in v1.0 against custom catalog schema.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Canvas": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Canvas" + }, + "children": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "children" + ] + }, + "Chart": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Chart" + }, + "chartType": { + "enum": [ + "doughnut", + "pie" + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "component" + ] + } + } + }, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Canvas", + "children": [ + "c1", + "c2" + ] + }, + { + "id": "c1", + "component": "Chart", + "chartType": "doughnut", + "title": "Chart 1" + }, + { + "id": "c2", + "component": "Chart", + "chartType": "pie", + "title": "Chart 2" + } + ] + } + } + ] + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "Chart", + "chartType": "bar" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/chartType", + "details": [ + { + "path": "/chartType", + "code": "invalid_value", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/chartType", + "message": "chartType" + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "Canvas" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/children", + "details": [ + { + "path": "/children", + "code": "missing_field", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/children", + "message": "children" + } + } + } + }, + { + "messages": [ + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "UnknownComponent" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/component", + "details": [ + { + "path": "/component", + "code": "invalid_value", + "surfaceId": "s1" + } + ], + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/component", + "message": "UnknownComponent" + } + } + } + } + ] + }, + { + "name": "test_validator_theme_schema", + "description": "Tests schema validation of surface theme properties against catalog theme schema.", + "action": "validate", + "catalog": { + "catalogId": "theme_cat", + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + } + }, + "required": [ + "primaryColor" + ], + "additionalProperties": false + } + }, + "steps": [ + { + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "s1", + "catalogId": "theme_cat", + "theme": { + "primaryColor": "#ff0000" + } + } + } + ] + }, + { + "messages": [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "s1", + "catalogId": "theme_cat", + "theme": { + "primaryColor": "not-a-hex" + } + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_topology_circular_reference_error", + "description": "Tests that circular component references fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "c1" + ] + }, + { + "id": "c1", + "component": "Column", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": "Circular component reference" + } + ] + }, + { + "name": "test_v10_topology_dangling_child_reference_error", + "description": "Tests that dangling child references fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "missing_child" + ] + } + ] + } + } + ], + "expectError": "Dangling reference" + } + ] + }, + { + "name": "test_v10_topology_self_reference_error", + "description": "Tests that self-referencing components fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "root" + ] + } + ] + } + } + ], + "expectError": "Self-reference detected" + } + ] + }, + { + "name": "test_v10_topology_orphaned_component_error", + "description": "Tests that unreachable orphaned components fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Root" + }, + { + "id": "orphan", + "component": "Text", + "text": "Orphan" + } + ] + } + } + ], + "expectError": "is not reachable from" + } + ] + }, + { + "name": "test_v10_topology_missing_root_component_error", + "description": "Tests that component updates missing root component fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "c1", + "component": "Text", + "text": "No Root" + } + ] + } + } + ], + "expectError": "Missing root component" + } + ] + }, + { + "name": "test_v10_unknown_component_type", + "description": "Tests that unknown component types fail schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "UnknownWidget", + "foo": "bar" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/component", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/component", + "message": "UnknownWidget" + } + } + } + } + ] + }, + { + "name": "test_v10_unknown_component_property", + "description": "Tests that extra unknown component properties fail schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Button", + "label": "Click Me", + "unknownAttribute": "someValue" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0", + "message": "Button" + } + } + } + } + ] + }, + { + "name": "test_v10_unknown_nested_function", + "description": "Tests that unknown nested functions fail schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Button", + "child": "t1", + "action": { + "functionCall": { + "@call": "unknownFunc", + "args": { + "arg": "val" + } + } + } + }, + { + "id": "t1", + "component": "Text", + "text": "Click Me" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/action/functionCall", + "message": "unknownFunc" + } + } + } + } + ] + }, + { + "name": "test_v10_multiple_action_envelope_error", + "description": "Tests that having multiple action keys in a single message envelope fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1" + }, + "deleteSurface": { + "surfaceId": "s1" + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_invalid_json_pointer_path_error", + "description": "Tests that invalid JSON Pointer path syntax fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "steps": [ + { + "messages": [ + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "invalid path [0]", + "value": "data" + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_uax31_invalid_identifier_error", + "description": "Tests that component IDs violating UAX#31 identifier syntax fail validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/catalogs/basic/v1/catalog.json", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "123invalid_id" + ] + }, + { + "id": "123invalid_id", + "component": "Text", + "text": "Invalid ID" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/1/id", + "message": "Component id '123invalid_id' must be a valid", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/1/id", + "message": "123invalid_id" + } + } + } + } + ] + }, + { + "name": "test_v10_topology_template_child_reachable", + "description": "Tests that a component referenced only as a list template is reachable from the root in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "item", + "path": "/items" + } + }, + { + "id": "item", + "component": "Text", + "text": "Reachable" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_topology_template_dangling_reference_error", + "description": "Tests that a list template naming a component that does not exist fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "missing_id", + "path": "/items" + } + }, + { + "id": "item", + "component": "Text", + "text": "Unused" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "references non-existent component 'missing_id'" + } + } + ] + }, + { + "name": "test_v10_invalid_binding_path_error", + "description": "Tests that a data binding path with an invalid JSON Pointer escape ('~2') fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@path": "/invalid/escape/~2" + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Invalid path syntax" + } + } + ] + }, + { + "name": "test_v10_component_nesting_depth_limit_exceeded_error", + "description": "Tests that a chain of nested components deeper than the global limit (50) fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Card", + "child": "c0" + }, + { + "id": "c0", + "component": "Card", + "child": "c1" + }, + { + "id": "c1", + "component": "Card", + "child": "c2" + }, + { + "id": "c2", + "component": "Card", + "child": "c3" + }, + { + "id": "c3", + "component": "Card", + "child": "c4" + }, + { + "id": "c4", + "component": "Card", + "child": "c5" + }, + { + "id": "c5", + "component": "Card", + "child": "c6" + }, + { + "id": "c6", + "component": "Card", + "child": "c7" + }, + { + "id": "c7", + "component": "Card", + "child": "c8" + }, + { + "id": "c8", + "component": "Card", + "child": "c9" + }, + { + "id": "c9", + "component": "Card", + "child": "c10" + }, + { + "id": "c10", + "component": "Card", + "child": "c11" + }, + { + "id": "c11", + "component": "Card", + "child": "c12" + }, + { + "id": "c12", + "component": "Card", + "child": "c13" + }, + { + "id": "c13", + "component": "Card", + "child": "c14" + }, + { + "id": "c14", + "component": "Card", + "child": "c15" + }, + { + "id": "c15", + "component": "Card", + "child": "c16" + }, + { + "id": "c16", + "component": "Card", + "child": "c17" + }, + { + "id": "c17", + "component": "Card", + "child": "c18" + }, + { + "id": "c18", + "component": "Card", + "child": "c19" + }, + { + "id": "c19", + "component": "Card", + "child": "c20" + }, + { + "id": "c20", + "component": "Card", + "child": "c21" + }, + { + "id": "c21", + "component": "Card", + "child": "c22" + }, + { + "id": "c22", + "component": "Card", + "child": "c23" + }, + { + "id": "c23", + "component": "Card", + "child": "c24" + }, + { + "id": "c24", + "component": "Card", + "child": "c25" + }, + { + "id": "c25", + "component": "Card", + "child": "c26" + }, + { + "id": "c26", + "component": "Card", + "child": "c27" + }, + { + "id": "c27", + "component": "Card", + "child": "c28" + }, + { + "id": "c28", + "component": "Card", + "child": "c29" + }, + { + "id": "c29", + "component": "Card", + "child": "c30" + }, + { + "id": "c30", + "component": "Card", + "child": "c31" + }, + { + "id": "c31", + "component": "Card", + "child": "c32" + }, + { + "id": "c32", + "component": "Card", + "child": "c33" + }, + { + "id": "c33", + "component": "Card", + "child": "c34" + }, + { + "id": "c34", + "component": "Card", + "child": "c35" + }, + { + "id": "c35", + "component": "Card", + "child": "c36" + }, + { + "id": "c36", + "component": "Card", + "child": "c37" + }, + { + "id": "c37", + "component": "Card", + "child": "c38" + }, + { + "id": "c38", + "component": "Card", + "child": "c39" + }, + { + "id": "c39", + "component": "Card", + "child": "c40" + }, + { + "id": "c40", + "component": "Card", + "child": "c41" + }, + { + "id": "c41", + "component": "Card", + "child": "c42" + }, + { + "id": "c42", + "component": "Card", + "child": "c43" + }, + { + "id": "c43", + "component": "Card", + "child": "c44" + }, + { + "id": "c44", + "component": "Card", + "child": "c45" + }, + { + "id": "c45", + "component": "Card", + "child": "c46" + }, + { + "id": "c46", + "component": "Card", + "child": "c47" + }, + { + "id": "c47", + "component": "Card", + "child": "c48" + }, + { + "id": "c48", + "component": "Card", + "child": "c49" + }, + { + "id": "c49", + "component": "Card", + "child": "c50" + }, + { + "id": "c50", + "component": "Card", + "child": "c51" + }, + { + "id": "c51", + "component": "Card", + "child": "c52" + }, + { + "id": "c52", + "component": "Card", + "child": "c53" + }, + { + "id": "c53", + "component": "Card", + "child": "c54" + }, + { + "id": "c54", + "component": "Card", + "child": "c55" + }, + { + "id": "c55", + "component": "Text", + "text": "Leaf" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Global recursion limit exceeded" + } + } + ] + }, + { + "name": "test_v10_component_nesting_depth_within_limit", + "description": "Tests that a chain of 41 nested components, within the global limit (50), passes validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Card", + "child": "c0" + }, + { + "id": "c0", + "component": "Card", + "child": "c1" + }, + { + "id": "c1", + "component": "Card", + "child": "c2" + }, + { + "id": "c2", + "component": "Card", + "child": "c3" + }, + { + "id": "c3", + "component": "Card", + "child": "c4" + }, + { + "id": "c4", + "component": "Card", + "child": "c5" + }, + { + "id": "c5", + "component": "Card", + "child": "c6" + }, + { + "id": "c6", + "component": "Card", + "child": "c7" + }, + { + "id": "c7", + "component": "Card", + "child": "c8" + }, + { + "id": "c8", + "component": "Card", + "child": "c9" + }, + { + "id": "c9", + "component": "Card", + "child": "c10" + }, + { + "id": "c10", + "component": "Card", + "child": "c11" + }, + { + "id": "c11", + "component": "Card", + "child": "c12" + }, + { + "id": "c12", + "component": "Card", + "child": "c13" + }, + { + "id": "c13", + "component": "Card", + "child": "c14" + }, + { + "id": "c14", + "component": "Card", + "child": "c15" + }, + { + "id": "c15", + "component": "Card", + "child": "c16" + }, + { + "id": "c16", + "component": "Card", + "child": "c17" + }, + { + "id": "c17", + "component": "Card", + "child": "c18" + }, + { + "id": "c18", + "component": "Card", + "child": "c19" + }, + { + "id": "c19", + "component": "Card", + "child": "c20" + }, + { + "id": "c20", + "component": "Card", + "child": "c21" + }, + { + "id": "c21", + "component": "Card", + "child": "c22" + }, + { + "id": "c22", + "component": "Card", + "child": "c23" + }, + { + "id": "c23", + "component": "Card", + "child": "c24" + }, + { + "id": "c24", + "component": "Card", + "child": "c25" + }, + { + "id": "c25", + "component": "Card", + "child": "c26" + }, + { + "id": "c26", + "component": "Card", + "child": "c27" + }, + { + "id": "c27", + "component": "Card", + "child": "c28" + }, + { + "id": "c28", + "component": "Card", + "child": "c29" + }, + { + "id": "c29", + "component": "Card", + "child": "c30" + }, + { + "id": "c30", + "component": "Card", + "child": "c31" + }, + { + "id": "c31", + "component": "Card", + "child": "c32" + }, + { + "id": "c32", + "component": "Card", + "child": "c33" + }, + { + "id": "c33", + "component": "Card", + "child": "c34" + }, + { + "id": "c34", + "component": "Card", + "child": "c35" + }, + { + "id": "c35", + "component": "Card", + "child": "c36" + }, + { + "id": "c36", + "component": "Card", + "child": "c37" + }, + { + "id": "c37", + "component": "Card", + "child": "c38" + }, + { + "id": "c38", + "component": "Card", + "child": "c39" + }, + { + "id": "c39", + "component": "Card", + "child": "c40" + }, + { + "id": "c40", + "component": "Text", + "text": "Leaf" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_multi_surface_independent_roots", + "description": "Tests that two surfaces are each validated against their own component tree in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_a", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_b", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_a", + "components": [ + { + "id": "root", + "component": "Card", + "child": "child_a" + }, + { + "id": "child_a", + "component": "Text", + "text": "Hello A" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_b", + "components": [ + { + "id": "root", + "component": "Card", + "child": "child_b" + }, + { + "id": "child_b", + "component": "Text", + "text": "Hello B" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_multi_surface_missing_root_error", + "description": "Tests that a missing root on one of two surfaces fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_a", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "createSurface": { + "surfaceId": "surface_b", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_a", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello A" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "surface_b", + "components": [ + { + "id": "not_root", + "component": "Text", + "text": "Hello B" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Missing root component" + } + } + ] + }, + { + "name": "test_v10_incremental_update_without_root", + "description": "Tests that an updateComponents message that omits the root component passes once the root has been sent, in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Updated" + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_incremental_update_self_reference_error", + "description": "Tests that an incremental updateComponents message introducing a self-reference fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "card", + "component": "Card", + "child": "card" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Self-reference detected" + } + } + ] + }, + { + "name": "test_v10_incremental_update_circular_reference_error", + "description": "Tests that an incremental updateComponents message introducing a cycle fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "card", + "component": "Card", + "child": "loop" + }, + { + "id": "loop", + "component": "Card", + "child": "card" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Circular reference detected" + } + } + ] + }, + { + "name": "test_v10_incremental_update_duplicate_component_id_error", + "description": "Tests that an incremental updateComponents message listing the same component ID twice fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "card" + ] + }, + { + "id": "card", + "component": "Card", + "child": "text" + }, + { + "id": "text", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "text", + "component": "Text", + "text": "A" + }, + { + "id": "text", + "component": "Text", + "text": "B" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Duplicate component ID" + } + } + ] + }, + { + "name": "test_v10_recursion_limit_function_call_depth_exceeded_error", + "description": "Tests that nested function calls exceeding the maximum depth (5) fail validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "f1", + "args": { + "v": { + "@call": "f2", + "args": { + "v": { + "@call": "f3", + "args": { + "v": { + "@call": "f4", + "args": { + "v": { + "@call": "f5", + "args": { + "v": { + "@call": "f6", + "args": {} + } + } + } + } + } + } + } + } + } + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "functionCall depth" + } + } + ] + }, + { + "name": "test_v10_data_model_nesting_depth_limit_exceeded_error", + "description": "Tests that an updateDataModel value nested deeper than the global limit (50) fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello" + } + ] + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/", + "value": { + "level": 0, + "next": { + "level": 1, + "next": { + "level": 2, + "next": { + "level": 3, + "next": { + "level": 4, + "next": { + "level": 5, + "next": { + "level": 6, + "next": { + "level": 7, + "next": { + "level": 8, + "next": { + "level": 9, + "next": { + "level": 10, + "next": { + "level": 11, + "next": { + "level": 12, + "next": { + "level": 13, + "next": { + "level": 14, + "next": { + "level": 15, + "next": { + "level": 16, + "next": { + "level": 17, + "next": { + "level": 18, + "next": { + "level": 19, + "next": { + "level": 20, + "next": { + "level": 21, + "next": { + "level": 22, + "next": { + "level": 23, + "next": { + "level": 24, + "next": { + "level": 25, + "next": { + "level": 26, + "next": { + "level": 27, + "next": { + "level": 28, + "next": { + "level": 29, + "next": { + "level": 30, + "next": { + "level": 31, + "next": { + "level": 32, + "next": { + "level": 33, + "next": { + "level": 34, + "next": { + "level": 35, + "next": { + "level": 36, + "next": { + "level": 37, + "next": { + "level": 38, + "next": { + "level": 39, + "next": { + "level": 40, + "next": { + "level": 41, + "next": { + "level": 42, + "next": { + "level": 43, + "next": { + "level": 44, + "next": { + "level": 45, + "next": { + "level": 46, + "next": { + "level": 47, + "next": { + "level": 48, + "next": { + "level": 49, + "next": { + "level": 50, + "next": { + "level": 51, + "next": { + "level": 52, + "next": { + "level": 53, + "next": { + "level": 54, + "next": { + "level": 55 + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Global recursion limit exceeded" + } + } + ] + }, + { + "name": "test_v10_topology_structured_array_item_dangling_child_error", + "description": "Tests that a dangling child reference inside a structured array item (Tabs.tabs[].child) fails validation in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Tabs", + "tabs": [ + { + "title": "Overview", + "child": "tab_one" + }, + { + "title": "Details", + "child": "missing_tab" + } + ] + }, + { + "id": "tab_one", + "component": "Text", + "text": "First" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "missing_tab" + } + } + ] + }, + { + "name": "test_v10_topology_plain_string_property_not_treated_as_child_ref", + "description": "Tests that a plain string property whose value happens to match another component's ID does not make that component reachable in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "orphan_id" + }, + { + "id": "orphan_id", + "component": "Text", + "text": "Still orphaned" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "orphan_id" + } + } + ] + }, + { + "name": "test_v10_topology_component_id_array_dangling_reference_error", + "description": "Tests that every item of an array property whose items are ComponentId is a child reference, whatever the property is called, so an item naming no component fails validation in v1.0 strict mode even when no other item names one.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "missing_slot" + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "missing_slot" + } + } + ] + }, + { + "name": "test_v10_topology_component_id_array_child_reachable", + "description": "Tests that a component named in an array property whose items are ComponentId is reachable from the root in v1.0 strict mode, whatever the property is called.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "first" + ] + }, + { + "id": "first", + "component": "Stack", + "slots": [] + } + ] + } + } + ] + } + ] + }, + { + "name": "test_v10_topology_component_id_array_partial_dangling_reference_error", + "description": "A property declared as an array of ComponentId directly (an array whose items are a ComponentId $ref, not a ChildList) is read as a list of child references. With one held and one missing item, the missing item is reported as a dangling reference at its index in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + }, + "Label": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Label" + }, + "text": { + "type": "string" + } + }, + "required": [ + "component", + "text" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "held", + "missing" + ] + }, + { + "id": "held", + "component": "Label", + "text": "held" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "references non-existent component 'missing' in field 'slots[1]'" + } + } + ] + }, + { + "name": "test_v10_topology_component_id_array_circular_reference_error", + "description": "A property declared as an array of ComponentId directly (an array whose items are a ComponentId $ref, not a ChildList) is read as a list of child references, so a cycle through it is detected in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalog": { + "catalogId": "custom", + "components": { + "Stack": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Stack" + }, + "slots": { + "type": "array", + "items": { + "$ref": "common_types.json#/$defs/ComponentId" + } + } + }, + "required": [ + "component", + "slots" + ] + }, + "Label": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "component": { + "const": "Label" + }, + "text": { + "type": "string" + } + }, + "required": [ + "component", + "text" + ] + } + } + }, + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "custom" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Stack", + "slots": [ + "loop_a" + ] + }, + { + "id": "loop_a", + "component": "Stack", + "slots": [ + "loop_b" + ] + }, + { + "id": "loop_b", + "component": "Stack", + "slots": [ + "loop_a" + ] + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Circular reference detected" + } + } + ] + }, + { + "name": "test_v10_child_list_template_missing_path_error", + "description": "Tests that a ChildList template object missing its required 'path' property fails schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "List", + "children": { + "componentId": "item" + } + }, + { + "id": "item", + "component": "Text", + "text": "Item" + } + ] + } + } + ], + "expectError": { + "category": "ValidationError" + } + } + ] + }, + { + "name": "test_v10_update_data_model_invalid_path_error", + "description": "Tests that an updateDataModel message with an invalid JSON Pointer escape ('~2') fails validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/~2invalid", + "value": "Alice" + } + } + ], + "expectError": { + "category": "ValidationError", + "message": "Invalid path syntax" + } + } + ] + }, + { + "name": "test_v10_function_argument_schema_mismatch_error", + "description": "Tests that a function call passing an argument of the wrong type fails schema validation in v1.0.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": { + "@call": "formatNumber", + "args": { + "value": "not_a_number" + } + } + } + ] + } + } + ], + "expectError": { + "category": "ValidationError", + "surfaceId": "s1", + "path": "/components/0/text/args/value", + "rendererError": { + "version": "1.0", + "error": { + "code": "VALIDATION_FAILED", + "surfaceId": "s1", + "path": "/components/0/text/args/value", + "message": "value" + } + } + } + } + ] + }, + { + "name": "test_v10_incremental_update_same_component_id_across_messages", + "description": "Tests that updating the same component ID across separate updateComponents messages succeeds in v1.0 strict mode.", + "action": "validate", + "protocolVersion": "1.0", + "catalogPaths": [ + "catalogs/basic/v1/catalog.json" + ], + "strictMode": true, + "steps": [ + { + "messages": [ + { + "version": "1.0", + "createSurface": { + "surfaceId": "s1", + "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json" + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Initial" + } + ] + } + }, + { + "version": "1.0", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Updated" + } + ] + } + } + ] + } + ] + } +] \ No newline at end of file diff --git a/package/a2ui/package.json b/package/a2ui/package.json new file mode 100644 index 0000000000..7f6827791c --- /dev/null +++ b/package/a2ui/package.json @@ -0,0 +1,6 @@ +{ + "main": "../lib/commonjs/a2ui/index.js", + "module": "../lib/module/a2ui/index.js", + "react-native": "../src/a2ui/index.ts", + "types": "../lib/typescript/a2ui/index.d.ts" +} diff --git a/package/ai-components/conversation/package.json b/package/ai-components/conversation/package.json new file mode 100644 index 0000000000..2c5920b22a --- /dev/null +++ b/package/ai-components/conversation/package.json @@ -0,0 +1,6 @@ +{ + "main": "../../lib/commonjs/ai-components/conversation/index.js", + "module": "../../lib/module/ai-components/conversation/index.js", + "react-native": "../../src/ai-components/conversation/index.ts", + "types": "../../lib/typescript/ai-components/conversation/index.d.ts" +} diff --git a/package/bin/validate-entry-isolation.js b/package/bin/validate-entry-isolation.js index d69e885890..ac2d9e881f 100644 --- a/package/bin/validate-entry-isolation.js +++ b/package/bin/validate-entry-isolation.js @@ -9,7 +9,13 @@ * the AI components or their dependencies. Apps that don't use AI must not bundle any of it. * 2. The AI entry points (core `src/ai-components/index.ts` and the wrappers' * `ai-components/index.js`) may only reach the AI sources plus an explicit allowlist of other - * files. Apps that only use the AI components must not bundle the chat SDK. + * files. Apps that only use the AI components must not bundle the chat SDK. Nor the A2UI + * renderer: the `a2ui` entry points (core `src/a2ui/index.ts` and the wrappers' + * `a2ui/index.js`) reach it and `@a2ui/web_core`, besides what the AI entry points reach. Nor + * the conversation, which needs react-native-keyboard-controller: the `ai-components/conversation` + * entry points (core `src/ai-components/conversation/index.ts` and the wrappers' + * `ai-components/conversation/index.js`) reach it, and the other AI entry points load + * react-native-keyboard-controller only through a guarded `require`. * 3. The codegen spec copies the wrappers need (codegen only reads a package's own `jsSrcsDir`) stay * byte-identical to the core source of truth. * @@ -23,22 +29,49 @@ const ts = require('typescript'); const PACKAGE_DIR = path.resolve(__dirname, '..'); const CORE_SRC = path.join(PACKAGE_DIR, 'src'); const AI_SRC = path.join(CORE_SRC, 'ai-components'); +const A2UI_SRC = path.join(CORE_SRC, 'a2ui'); +const CONVERSATION_SRC = path.join(AI_SRC, 'conversation'); const WRAPPERS = ['native-package', 'expo-package'].map((name) => path.join(PACKAGE_DIR, name)); // Third-party packages only the AI components may use. (`lodash` and `@stream-io/state-store` are // not among them: the main entry point imports both too.) const AI_ONLY_PACKAGES = [ - '@khanacademy/simple-markdown', + '@legendapp/list', '@shopify/react-native-skia', + 'react-native-keyboard-controller', 'react-syntax-highlighter', 'victory-native', 'zod', ]; +// Third-party packages only the A2UI renderer may use: its own entry point keeps them out of apps +// that use the AI components without it. +const A2UI_ONLY_PACKAGES = ['@a2ui/web_core']; + +// Optional peers the AI components load with a guarded `require`, each from one file only, so apps +// that don't install them still bundle. +const GUARDED_PACKAGES = { + 'react-native-keyboard-controller': path.join(AI_SRC, 'internal/keyboard.tsx'), +}; + +// Dependencies only the conversation entry points may reach: they require +// react-native-keyboard-controller unguarded. +const CONVERSATION_ONLY_SPECIFIERS = ['@legendapp/list/keyboard']; + // Files outside `src/ai-components` that the AI entry points are allowed to reach. const AI_ALLOWED_FILES = [ path.join(CORE_SRC, 'native.ts'), path.join(CORE_SRC, 'components/UIComponents/NativeShimmerView.tsx'), + // The chat SDK's translation context, which the AI components translate with (not Streami18n). + path.join(CORE_SRC, 'contexts/translationContext/TranslationContext.tsx'), + path.join(CORE_SRC, 'contexts/translationContext/types.ts'), + path.join(CORE_SRC, 'contexts/utils/isTestEnvironment.ts'), + path.join(CORE_SRC, 'i18n/utils.ts'), + // The design tokens (plain data, no imports), which the AI components size their text with. Metro + // picks the platform's file; this check resolves the fallback, which re-exports the web one. + ...['', '.ios', '.android', '.web'].map((platform) => + path.join(CORE_SRC, `theme/generated/light/StreamTokens${platform}.ts`), + ), ...WRAPPERS.flatMap((wrapper) => [ path.join(wrapper, 'ai-components/index.js'), path.join(wrapper, 'src/optionalDependencies/NativeShimmerView.ts'), @@ -79,6 +112,12 @@ const resolveSpecifier = (specifier, fromFile) => { if (specifier === 'stream-chat-react-native-core/ai-components') { return { file: path.join(AI_SRC, 'index.ts') }; } + if (specifier === 'stream-chat-react-native-core/ai-components/conversation') { + return { file: path.join(CONVERSATION_SRC, 'index.ts') }; + } + if (specifier === 'stream-chat-react-native-core/a2ui') { + return { file: path.join(A2UI_SRC, 'index.ts') }; + } return { pkg: packageNameOf(specifier), specifier }; }; @@ -155,6 +194,32 @@ const errors = []; const report = (message, chain) => errors.push(`${message}\n via: ${chain.map(relative).join('\n -> ')}`); +/** Checks that an entry point outside the conversation reaches neither it nor what it needs. */ +const checkWithoutConversation = (entry, { files, packages }) => { + for (const [file, chain] of files) { + if (file.startsWith(CONVERSATION_SRC + path.sep)) { + report(`${relative(entry)} reaches the AI conversation (${relative(file)})`, chain); + } + for (const specifier of runtimeSpecifiers(file)) { + const guardedFile = GUARDED_PACKAGES[packageNameOf(specifier)]; + if (guardedFile && file !== guardedFile) { + report( + `${relative(file)} imports the optional "${specifier}"; load it through ${relative(guardedFile)}`, + chain, + ); + } + } + } + for (const [specifier, chain] of packages) { + if (CONVERSATION_ONLY_SPECIFIERS.includes(specifier)) { + report( + `${relative(entry)} reaches "${specifier}", which only the AI conversation may`, + chain, + ); + } + } +}; + // 1. Main entry points must not reach the AI components. for (const entry of [ path.join(CORE_SRC, 'index.ts'), @@ -164,13 +229,15 @@ for (const entry of [ for (const [file, chain] of files) { if ( file.startsWith(AI_SRC + path.sep) || - file.includes(`${path.sep}ai-components${path.sep}`) + file.startsWith(A2UI_SRC + path.sep) || + file.includes(`${path.sep}ai-components${path.sep}`) || + file.includes(`${path.sep}a2ui${path.sep}`) ) { report(`${relative(entry)} reaches the AI components (${relative(file)})`, chain); } } for (const [specifier, chain] of packages) { - if (AI_ONLY_PACKAGES.includes(packageNameOf(specifier))) { + if ([...AI_ONLY_PACKAGES, ...A2UI_ONLY_PACKAGES].includes(packageNameOf(specifier))) { report(`${relative(entry)} reaches the AI-only dependency "${specifier}"`, chain); } } @@ -181,7 +248,9 @@ for (const entry of [ path.join(AI_SRC, 'index.ts'), ...WRAPPERS.map((w) => path.join(w, 'ai-components/index.js')), ]) { - const { files, packages } = walk(entry); + const reached = walk(entry); + const { files, packages } = reached; + checkWithoutConversation(entry, reached); for (const [file, chain] of files) { if (!file.startsWith(AI_SRC + path.sep) && !AI_ALLOWED_FILES.includes(file)) { report( @@ -190,6 +259,35 @@ for (const entry of [ ); } } + for (const [specifier, chain] of packages) { + if (AI_FORBIDDEN_PACKAGES.includes(specifier)) { + report(`${relative(entry)} reaches "${specifier}"`, chain); + } + if (A2UI_ONLY_PACKAGES.includes(packageNameOf(specifier))) { + report(`${relative(entry)} reaches the A2UI-only dependency "${specifier}"`, chain); + } + } +} + +// 2b. The A2UI entry points reach the AI components and the A2UI renderer only. +const A2UI_WRAPPER_ENTRIES = WRAPPERS.map((w) => path.join(w, 'a2ui/index.js')); +for (const entry of [path.join(A2UI_SRC, 'index.ts'), ...A2UI_WRAPPER_ENTRIES]) { + const reached = walk(entry); + const { files, packages } = reached; + checkWithoutConversation(entry, reached); + for (const [file, chain] of files) { + if ( + !file.startsWith(AI_SRC + path.sep) && + !file.startsWith(A2UI_SRC + path.sep) && + !AI_ALLOWED_FILES.includes(file) && + !A2UI_WRAPPER_ENTRIES.includes(file) + ) { + report( + `${relative(entry)} reaches ${relative(file)}, which is not in the AI allowlist`, + chain, + ); + } + } for (const [specifier, chain] of packages) { if (AI_FORBIDDEN_PACKAGES.includes(specifier)) { report(`${relative(entry)} reaches "${specifier}"`, chain); @@ -197,12 +295,44 @@ for (const entry of [ } } +// 2c. The conversation entry points reach the AI components only. +const CONVERSATION_WRAPPER_ENTRIES = WRAPPERS.map((w) => + path.join(w, 'ai-components/conversation/index.js'), +); +for (const entry of [path.join(CONVERSATION_SRC, 'index.ts'), ...CONVERSATION_WRAPPER_ENTRIES]) { + const { files, packages } = walk(entry); + for (const [file, chain] of files) { + if ( + !file.startsWith(AI_SRC + path.sep) && + !AI_ALLOWED_FILES.includes(file) && + !CONVERSATION_WRAPPER_ENTRIES.includes(file) + ) { + report( + `${relative(entry)} reaches ${relative(file)}, which is not in the AI allowlist`, + chain, + ); + } + } + for (const [specifier, chain] of packages) { + if (AI_FORBIDDEN_PACKAGES.includes(specifier)) { + report(`${relative(entry)} reaches "${specifier}"`, chain); + } + if (A2UI_ONLY_PACKAGES.includes(packageNameOf(specifier))) { + report(`${relative(entry)} reaches the A2UI-only dependency "${specifier}"`, chain); + } + } +} + // 3. Codegen spec copies must not drift. const copies = [ [ path.join(AI_SRC, 'native-specs/NativeAIDictation.ts'), ...WRAPPERS.map((w) => path.join(w, 'src/native/NativeAIDictation.ts')), ], + [ + path.join(AI_SRC, 'native-specs/NativeAIAgent.ts'), + ...WRAPPERS.map((w) => path.join(w, 'src/native/NativeAIAgent.ts')), + ], WRAPPERS.map((w) => path.join(w, 'src/native/PerfTextNativeComponent.ts')), ]; for (const [source, ...targets] of copies) { diff --git a/package/expo-package/.gitignore b/package/expo-package/.gitignore index 81e0235dce..c70b7e72ca 100644 --- a/package/expo-package/.gitignore +++ b/package/expo-package/.gitignore @@ -1,6 +1,7 @@ # android android/build android/src/main/java/com/streamchatreactnative/shared +android/src/agent-sign-in # ios ios/build diff --git a/package/expo-package/a2ui/index.d.ts b/package/expo-package/a2ui/index.d.ts new file mode 100644 index 0000000000..7c6e147c82 --- /dev/null +++ b/package/expo-package/a2ui/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/expo-package/a2ui/index.js b/package/expo-package/a2ui/index.js new file mode 100644 index 0000000000..ca0f6bd382 --- /dev/null +++ b/package/expo-package/a2ui/index.js @@ -0,0 +1,6 @@ +// The A2UI renderer for the AI components' generated UI, an entry point of its own so an app whose +// agent never writes A2UI doesn't bundle the A2UI core. It draws with the AI components, whose +// entry point registers the native pieces they need. +import '../ai-components'; + +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/expo-package/ai-components/conversation/index.d.ts b/package/expo-package/ai-components/conversation/index.d.ts new file mode 100644 index 0000000000..8296958f70 --- /dev/null +++ b/package/expo-package/ai-components/conversation/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/expo-package/ai-components/conversation/index.js b/package/expo-package/ai-components/conversation/index.js new file mode 100644 index 0000000000..260a4673dd --- /dev/null +++ b/package/expo-package/ai-components/conversation/index.js @@ -0,0 +1,6 @@ +// The AI conversation, an entry point of its own because it needs react-native-keyboard-controller, +// which the rest of the AI components don't. It draws with the AI components, whose entry point +// registers the native pieces they need. +import '..'; + +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/expo-package/android/build.gradle b/package/expo-package/android/build.gradle index 5e231ac5dc..a63828b6cd 100644 --- a/package/expo-package/android/build.gradle +++ b/package/expo-package/android/build.gradle @@ -42,6 +42,26 @@ def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } +// The agent components' sign-ins open in the browser's Auth Tab, which needs androidx.browser +// 1.9.0, and that needs the app to build with compileSdk 36 and AGP 8.9.1. An app below either +// gets 1.8.0 and opens them in a Custom Tab instead, so the SDK asks neither of anyone. +def versionAtLeast = { String version, List wanted -> + def parts = version.tokenize('.-').collect { it.replaceAll(/\D.*$/, '') }.findAll { it }.collect { it.toInteger() } + for (int i = 0; i < wanted.size(); i++) { + def part = i < parts.size() ? parts[i] : 0 + if (part != wanted[i]) return part > wanted[i] + } + return true +} +def agentSignInWithAuthTab = + getExtOrIntegerDefault("compileSdkVersion").toString().toInteger() >= 36 && + versionAtLeast(com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION, [8, 9, 1]) +def agentSignInVariant = agentSignInWithAuthTab ? "authtab" : "customtabs" +def sharedAgentSignInDir = new File(canonicalProjectDir, "../../shared-native/android-${agentSignInVariant}") +def agentSignInDir = hasNativeSources(sharedAgentSignInDir) + ? sharedAgentSignInDir + : new File(projectDir, "src/agent-sign-in/${agentSignInVariant}") + android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION @@ -82,6 +102,7 @@ android { java.srcDirs += [ "src/newarch", "src/main/java/com/streamchatreactnative/shared", + agentSignInDir, "${project.buildDir}/generated/source/codegen/java" ] } @@ -140,6 +161,9 @@ repositories { dependencies { implementation "com.facebook.react:react-native:+" + // The agent components' tool-call sign-ins: an Auth Tab where the app's build allows 1.9.0, + // else a Custom Tab (see `agentSignInWithAuthTab`). + implementation "androidx.browser:browser:${agentSignInWithAuthTab ? '1.9.0' : '1.8.0'}" } if (isNewArchitectureEnabled()) { diff --git a/package/expo-package/android/src/main/AndroidManifest.xml b/package/expo-package/android/src/main/AndroidManifest.xml index 17df9bf89c..b71eebea63 100644 --- a/package/expo-package/android/src/main/AndroidManifest.xml +++ b/package/expo-package/android/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ + + + + + + + diff --git a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java index 4442d860eb..9336dd4546 100644 --- a/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +++ b/package/expo-package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java @@ -18,6 +18,7 @@ public class StreamChatExpoPackage extends TurboReactPackage { private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; private static final String AI_DICTATION_MODULE = "AIDictation"; + private static final String AI_AGENT_MODULE = "AIAgent"; @Nullable @Override @@ -34,6 +35,10 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) return createNewArchModule("com.streamchatexpo.AIDictationModule", reactContext); } + if (name.equals(AI_AGENT_MODULE)) { + return createNewArchModule("com.streamchatexpo.AIAgentModule", reactContext); + } + return null; } @@ -75,6 +80,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule true // isTurboModule )); + moduleInfos.put( + AI_AGENT_MODULE, + new ReactModuleInfo( + AI_AGENT_MODULE, + AI_AGENT_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); return moduleInfos; }; } diff --git a/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt b/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt new file mode 100644 index 0000000000..3a3490e723 --- /dev/null +++ b/package/expo-package/android/src/newarch/com/streamchatexpo/AIAgentModule.kt @@ -0,0 +1,37 @@ +package com.streamchatexpo + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.streamchatreactnative.shared.agent.AIAgentController + +/** + * The `AIAgent` TurboModule used by the `ai-components` agent views: this install's identifier + * and the sign-ins of tool calls. The implementation is shared with the other SDK package; see + * [AIAgentController]. + */ +class AIAgentModule( + reactContext: ReactApplicationContext, +) : NativeAIAgentSpec(reactContext) { + private val controller = AIAgentController(reactContext) + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + controller.initialize() + } + + override fun invalidate() { + super.invalidate() + controller.invalidate() + } + + override fun installId(): String = controller.installId() + + override fun openAuthSession(url: String, callbackScheme: String, promise: Promise) = + controller.openAuthSession(url, callbackScheme, promise) + + companion object { + const val NAME = AIAgentController.NAME + } +} diff --git a/package/expo-package/package.json b/package/expo-package/package.json index 24ae5050b8..47c47f4440 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -22,6 +22,7 @@ "react-native.config.js", "package.json", "ai-components", + "a2ui", "app.plugin.js", "plugin" ], @@ -47,6 +48,7 @@ "expo-sharing": "*", "expo-video": "*", "react-native": ">=0.79.0", + "react-native-keyboard-controller": ">=1.21.7", "victory-native": ">=41.0.0" }, "peerDependenciesMeta": { @@ -86,6 +88,9 @@ "expo-video": { "optional": true }, + "react-native-keyboard-controller": { + "optional": true + }, "victory-native": { "optional": true } @@ -111,6 +116,7 @@ }, "ios": { "modulesProvider": { + "AIAgent": "AIAgent", "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" }, diff --git a/package/expo-package/src/native/NativeAIAgent.ts b/package/expo-package/src/native/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/expo-package/src/native/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/expo-package/stream-chat-expo.podspec b/package/expo-package/stream-chat-expo.podspec index ead4ddac26..b9e842bef4 100644 --- a/package/expo-package/stream-chat-expo.podspec +++ b/package/expo-package/stream-chat-expo.podspec @@ -42,9 +42,12 @@ Pod::Spec.new do |s| false end + # `AIAgent` (the agent components' install id and tool-call sign-ins) opens sign-ins in + # `ASWebAuthenticationSession`, which needs no usage description, so it is always compiled. if dictation_enabled - s.frameworks = "Speech" + s.frameworks = ["AuthenticationServices", "Speech"] else + s.frameworks = "AuthenticationServices" s.exclude_files = ["ios/shared/AIDictation.h", "ios/shared/AIDictation.mm", "ios/shared/AIDictationModule.swift"] end diff --git a/package/jest-setup.tsx b/package/jest-setup.tsx index 806594ca18..fda7443b95 100644 --- a/package/jest-setup.tsx +++ b/package/jest-setup.tsx @@ -53,6 +53,18 @@ jest.mock('react-native-gesture-handler/lib/module/GestureHandlerRootViewContext const React = require('react'); return { __esModule: true, default: React.createContext(true) }; }); +// The AI conversation rides the keyboard with react-native-keyboard-controller, which ships a mock. +jest.mock('react-native-keyboard-controller', () => + require('react-native-keyboard-controller/jest'), +); +// The keyboard-aware list waits for its scroll view to report its insets before it shows its rows, +// which the mocked scroll view never does. Under Jest it is the list it wraps; how it rides the +// keyboard is checked on devices. +jest.mock('@legendapp/list/keyboard', () => ({ + ...jest.requireActual('@legendapp/list/keyboard'), + KeyboardAwareLegendList: jest.requireActual('@legendapp/list/reanimated').AnimatedLegendList, +})); + jest.mock('react-native-reanimated', () => { const RNReanimatedmock = require('react-native-reanimated/mock'); // RNGH (>= 3.3) subscribes to SharedValue-backed gesture config on the UI runtime via diff --git a/package/jest.config.js b/package/jest.config.js index 1ac5fcc627..8cf2405ff6 100644 --- a/package/jest.config.js +++ b/package/jest.config.js @@ -5,6 +5,10 @@ module.exports = { // cap worker count on CI ...(process.env.CI ? { maxWorkers: 2 } : {}), moduleNameMapper: { + // LegendList's exports map has only a `react-native` condition for its native build, which + // Jest's node conditions skip for the web build (react-dom). + '^@legendapp/list/(keyboard|react-native|reanimated)$': + '/node_modules/@legendapp/list/$1.js', 'mock-builders(.*)$': '/src/mock-builders$1', // See __mocks__/SvgTouchableMixin.js - react-native-svg reads `Touchable.Mixin` off the // react-native root, and RN 0.87's late, non-enumerable re-export of it is lost through diff --git a/package/native-package/.gitignore b/package/native-package/.gitignore index 81e0235dce..c70b7e72ca 100644 --- a/package/native-package/.gitignore +++ b/package/native-package/.gitignore @@ -1,6 +1,7 @@ # android android/build android/src/main/java/com/streamchatreactnative/shared +android/src/agent-sign-in # ios ios/build diff --git a/package/native-package/a2ui/index.d.ts b/package/native-package/a2ui/index.d.ts new file mode 100644 index 0000000000..7c6e147c82 --- /dev/null +++ b/package/native-package/a2ui/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/native-package/a2ui/index.js b/package/native-package/a2ui/index.js new file mode 100644 index 0000000000..ca0f6bd382 --- /dev/null +++ b/package/native-package/a2ui/index.js @@ -0,0 +1,6 @@ +// The A2UI renderer for the AI components' generated UI, an entry point of its own so an app whose +// agent never writes A2UI doesn't bundle the A2UI core. It draws with the AI components, whose +// entry point registers the native pieces they need. +import '../ai-components'; + +export * from 'stream-chat-react-native-core/a2ui'; diff --git a/package/native-package/ai-components/conversation/index.d.ts b/package/native-package/ai-components/conversation/index.d.ts new file mode 100644 index 0000000000..8296958f70 --- /dev/null +++ b/package/native-package/ai-components/conversation/index.d.ts @@ -0,0 +1 @@ +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/native-package/ai-components/conversation/index.js b/package/native-package/ai-components/conversation/index.js new file mode 100644 index 0000000000..260a4673dd --- /dev/null +++ b/package/native-package/ai-components/conversation/index.js @@ -0,0 +1,6 @@ +// The AI conversation, an entry point of its own because it needs react-native-keyboard-controller, +// which the rest of the AI components don't. It draws with the AI components, whose entry point +// registers the native pieces they need. +import '..'; + +export * from 'stream-chat-react-native-core/ai-components/conversation'; diff --git a/package/native-package/android/build.gradle b/package/native-package/android/build.gradle index 11d3af6763..187eef47e3 100644 --- a/package/native-package/android/build.gradle +++ b/package/native-package/android/build.gradle @@ -49,6 +49,26 @@ def hasNativeSources = { File dir -> dir.exists() && !fileTree(dir).matching { include "**/*.kt"; include "**/*.java" }.files.isEmpty() } +// The agent components' sign-ins open in the browser's Auth Tab, which needs androidx.browser +// 1.9.0, and that needs the app to build with compileSdk 36 and AGP 8.9.1. An app below either +// gets 1.8.0 and opens them in a Custom Tab instead, so the SDK asks neither of anyone. +def versionAtLeast = { String version, List wanted -> + def parts = version.tokenize('.-').collect { it.replaceAll(/\D.*$/, '') }.findAll { it }.collect { it.toInteger() } + for (int i = 0; i < wanted.size(); i++) { + def part = i < parts.size() ? parts[i] : 0 + if (part != wanted[i]) return part > wanted[i] + } + return true +} +def agentSignInWithAuthTab = + getExtOrIntegerDefault("compileSdkVersion").toString().toInteger() >= 36 && + versionAtLeast(com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION, [8, 9, 1]) +def agentSignInVariant = agentSignInWithAuthTab ? "authtab" : "customtabs" +def sharedAgentSignInDir = new File(canonicalProjectDir, "../../shared-native/android-${agentSignInVariant}") +def agentSignInDir = hasNativeSources(sharedAgentSignInDir) + ? sharedAgentSignInDir + : new File(projectDir, "src/agent-sign-in/${agentSignInVariant}") + android { compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION @@ -92,6 +112,7 @@ android { java.srcDirs += [ "src/newarch", "src/main/java/com/streamchatreactnative/shared", + agentSignInDir, // This is needed to build Kotlin project with NewArch enabled "${project.buildDir}/generated/source/codegen/java" ] @@ -158,6 +179,9 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" implementation "androidx.exifinterface:exifinterface:1.3.2" + // The agent components' tool-call sign-ins: an Auth Tab where the app's build allows 1.9.0, + // else a Custom Tab (see `agentSignInWithAuthTab`). + implementation "androidx.browser:browser:${agentSignInWithAuthTab ? '1.9.0' : '1.8.0'}" } if (isNewArchitectureEnabled()) { diff --git a/package/native-package/android/src/main/AndroidManifest.xml b/package/native-package/android/src/main/AndroidManifest.xml index 95bb2ff52c..df471ba82c 100644 --- a/package/native-package/android/src/main/AndroidManifest.xml +++ b/package/native-package/android/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ - \ No newline at end of file + + + + + + + + diff --git a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java index 0a07a2c2b4..aa8e7be391 100644 --- a/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +++ b/package/native-package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java @@ -17,6 +17,7 @@ public class StreamChatReactNativePackage extends TurboReactPackage { private static final String STREAM_MULTIPART_UPLOADER_MODULE = "StreamMultipartUploader"; private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail"; private static final String AI_DICTATION_MODULE = "AIDictation"; + private static final String AI_AGENT_MODULE = "AIAgent"; @Nullable @Override @@ -38,6 +39,11 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) "com.streamchatreactnative.AIDictationModule", reactContext ); + } else if (name.equals(AI_AGENT_MODULE)) { + return createNewArchModule( + "com.streamchatreactnative.AIAgentModule", + reactContext + ); } else { return null; } @@ -92,6 +98,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { false, // isCxxModule true // isTurboModule )); + moduleInfos.put( + AI_AGENT_MODULE, + new ReactModuleInfo( + AI_AGENT_MODULE, + AI_AGENT_MODULE, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + )); return moduleInfos; }; } diff --git a/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt b/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt new file mode 100644 index 0000000000..35ac1c99d6 --- /dev/null +++ b/package/native-package/android/src/newarch/com/streamchatreactnative/AIAgentModule.kt @@ -0,0 +1,37 @@ +package com.streamchatreactnative + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.streamchatreactnative.shared.agent.AIAgentController + +/** + * The `AIAgent` TurboModule used by the `ai-components` agent views: this install's identifier + * and the sign-ins of tool calls. The implementation is shared with the other SDK package; see + * [AIAgentController]. + */ +class AIAgentModule( + reactContext: ReactApplicationContext, +) : NativeAIAgentSpec(reactContext) { + private val controller = AIAgentController(reactContext) + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + controller.initialize() + } + + override fun invalidate() { + super.invalidate() + controller.invalidate() + } + + override fun installId(): String = controller.installId() + + override fun openAuthSession(url: String, callbackScheme: String, promise: Promise) = + controller.openAuthSession(url, callbackScheme, promise) + + companion object { + const val NAME = AIAgentController.NAME + } +} diff --git a/package/native-package/package.json b/package/native-package/package.json index c533617fd5..2c848c039c 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -23,7 +23,8 @@ "*.podspec", "react-native.config.js", "package.json", - "ai-components" + "ai-components", + "a2ui" ], "license": "SEE LICENSE IN LICENSE", "main": "src/index.js", @@ -43,6 +44,7 @@ "react-native-blob-util": ">=0.22.0", "react-native-haptic-feedback": ">=2.3.0", "react-native-image-picker": ">=7.1.2", + "react-native-keyboard-controller": ">=1.21.7", "react-native-nitro-sound": ">=0.2.9", "react-native-share": ">=11.0.0", "react-native-video": ">=6.18.0", @@ -73,6 +75,9 @@ "react-native-image-picker": { "optional": true }, + "react-native-keyboard-controller": { + "optional": true + }, "react-native-nitro-sound": { "optional": true }, @@ -105,6 +110,7 @@ }, "ios": { "modulesProvider": { + "AIAgent": "AIAgent", "StreamChatReactNative": "StreamChatReactNative", "StreamMultipartUploader": "StreamMultipartUploader", "StreamVideoThumbnail": "StreamVideoThumbnail" diff --git a/package/native-package/src/native/NativeAIAgent.ts b/package/native-package/src/native/NativeAIAgent.ts new file mode 100644 index 0000000000..7e22ca992e --- /dev/null +++ b/package/native-package/src/native/NativeAIAgent.ts @@ -0,0 +1,25 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Codegen spec of the native module behind the agent components: this install's identifier and + * the sign-ins of tool calls. + * + * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIAgent.ts` + * and `package/expo-package/src/native/NativeAIAgent.ts`, which are the copies codegen reads. + * `yarn validate-entry-isolation` fails if they drift apart. + */ +export interface Spec extends TurboModule { + /** This install's identifier, created on first use and kept in storage that isn't backed up. */ + installId(): string; + /** + * Opens `url` in the platform's web authentication session and resolves with the URL the page + * handed back to `callbackScheme`, or an empty string when the person closed it. Rejects when + * the session couldn't be opened. + */ + openAuthSession(url: string, callbackScheme: string): Promise; +} + +// `get` rather than `getEnforcing`, so importing the AI entry point never throws in an app built +// without the module, such as one on an older version of the SDK's native code. +export default TurboModuleRegistry.get('AIAgent'); diff --git a/package/native-package/stream-chat-react-native.podspec b/package/native-package/stream-chat-react-native.podspec index aef09c54f1..c584dcd81e 100644 --- a/package/native-package/stream-chat-react-native.podspec +++ b/package/native-package/stream-chat-react-native.podspec @@ -42,9 +42,12 @@ Pod::Spec.new do |s| false end + # `AIAgent` (the agent components' install id and tool-call sign-ins) opens sign-ins in + # `ASWebAuthenticationSession`, which needs no usage description, so it is always compiled. if dictation_enabled - s.frameworks = "Speech" + s.frameworks = ["AuthenticationServices", "Speech"] else + s.frameworks = "AuthenticationServices" s.exclude_files = ["ios/shared/AIDictation.h", "ios/shared/AIDictation.mm", "ios/shared/AIDictationModule.swift"] end diff --git a/package/package.json b/package/package.json index 8549bbf57f..aaa8ae4937 100644 --- a/package/package.json +++ b/package/package.json @@ -21,7 +21,8 @@ "lib", "package.json", "README.md", - "ai-components" + "ai-components", + "a2ui" ], "scripts": { "shared-native:sync": "bash ./scripts/sync-shared-native.sh all", @@ -69,8 +70,9 @@ ] }, "dependencies": { + "@a2ui/web_core": "~0.15.0", "@gorhom/bottom-sheet": "^5.2.14", - "@khanacademy/simple-markdown": "^2.1.0", + "@legendapp/list": "^3.7.1", "@stream-io/i18n": "^1.1.0", "@stream-io/state-store": "^1.2.0", "@ungap/structured-clone": "^1.3.0", @@ -85,6 +87,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", + "simple-markdown": "^0.7.3", "stream-chat": "^10.0.1", "use-sync-external-store": "^1.7.0", "zod": "^4.1.12" @@ -98,6 +101,7 @@ "emoji-mart": ">=5.4.0", "react-native": ">=0.79.0", "react-native-gesture-handler": ">=2.18.0", + "react-native-keyboard-controller": ">=1.21.7", "react-native-reanimated": ">=3.16.0", "react-native-safe-area-context": ">=5.4.1", "react-native-svg": ">=15.8.0", @@ -120,6 +124,9 @@ "emoji-mart": { "optional": true }, + "react-native-keyboard-controller": { + "optional": true + }, "victory-native": { "optional": true } @@ -159,6 +166,7 @@ "react-native": "0.88.0", "react-native-builder-bob": "0.40.11", "react-native-gesture-handler": "^3.4.0", + "react-native-keyboard-controller": "^1.22.7", "react-native-reanimated": "^4.7.1", "react-native-safe-area-context": "^5.10.1", "react-native-svg": "15.15.5", diff --git a/package/scripts/clean-shared-native-copies.sh b/package/scripts/clean-shared-native-copies.sh index 4b183454ce..1b96e1fc3b 100644 --- a/package/scripts/clean-shared-native-copies.sh +++ b/package/scripts/clean-shared-native-copies.sh @@ -11,8 +11,9 @@ clean_package() { local package_name="$1" local android_dir="$ROOT_DIR/$package_name/android/src/main/java/com/streamchatreactnative/shared" local ios_dir="$ROOT_DIR/$package_name/ios/shared" + local agent_sign_in_dir="$ROOT_DIR/$package_name/android/src/agent-sign-in" - rm -rf "$android_dir" "$ios_dir" + rm -rf "$android_dir" "$ios_dir" "$agent_sign_in_dir" rm -f \ "$STATE_DIR/${package_name}_android.manifest" \ "$STATE_DIR/${package_name}_ios.manifest" diff --git a/package/scripts/sync-shared-native.sh b/package/scripts/sync-shared-native.sh index 8d09ded9d5..06d0acb396 100644 --- a/package/scripts/sync-shared-native.sh +++ b/package/scripts/sync-shared-native.sh @@ -7,6 +7,9 @@ TARGET="${1:-all}" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SHARED_ANDROID_DIR="$ROOT_DIR/shared-native/android" SHARED_IOS_DIR="$ROOT_DIR/shared-native/ios" +# The agent sign-in's two Android builds: with Auth Tab (androidx.browser 1.9.0) and without. +# Each package's build.gradle compiles the one the app's build allows. +AGENT_SIGN_IN_VARIANTS=(authtab customtabs) sync_dir_contents() { local src_dir="$1" @@ -33,6 +36,13 @@ copy_to_package() { echo "Skipping Android sync: missing $SHARED_ANDROID_DIR" fi + for variant in "${AGENT_SIGN_IN_VARIANTS[@]}"; do + local variant_src="$ROOT_DIR/shared-native/android-$variant" + if [ -d "$variant_src" ]; then + sync_dir_contents "$variant_src" "$ROOT_DIR/$package_name/android/src/agent-sign-in/$variant" + fi + done + if [ -d "$SHARED_IOS_DIR" ]; then sync_dir_contents "$SHARED_IOS_DIR" "$ios_dst_dir" else diff --git a/package/shared-native/android-authtab/AgentAuthTab.kt b/package/shared-native/android-authtab/AgentAuthTab.kt new file mode 100644 index 0000000000..2092f9d21c --- /dev/null +++ b/package/shared-native/android-authtab/AgentAuthTab.kt @@ -0,0 +1,24 @@ +package com.streamchatreactnative.shared.agent + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient + +/** + * The browser's Auth Tab, from `androidx.browser` 1.9.0. Compiled in when the app builds with + * compileSdk 36 and AGP 8.9.1 or newer, which that version needs; otherwise the build takes the + * `android-customtabs` copy of this file, and sign-ins open in a Custom Tab. + */ +internal object AgentAuthTab { + fun isSupported(context: Context, browser: String): Boolean = + CustomTabsClient.isAuthTabSupported(context, browser) + + fun launch(launcher: ActivityResultLauncher, browser: String, uri: Uri, callbackScheme: String) { + AuthTabIntent.Builder().build() + .apply { intent.setPackage(browser) } + .launch(launcher, uri, callbackScheme) + } +} diff --git a/package/shared-native/android-customtabs/AgentAuthTab.kt b/package/shared-native/android-customtabs/AgentAuthTab.kt new file mode 100644 index 0000000000..4e6d9fa0bc --- /dev/null +++ b/package/shared-native/android-customtabs/AgentAuthTab.kt @@ -0,0 +1,20 @@ +package com.streamchatreactnative.shared.agent + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher + +/** + * No Auth Tab: the app builds below compileSdk 36 or AGP 8.9.1, which `androidx.browser` 1.9.0 + * needs, so sign-ins open in a Custom Tab. The `android-authtab` copy of this file has the Auth Tab. + */ +internal object AgentAuthTab { + @Suppress("UNUSED_PARAMETER") + fun isSupported(context: Context, browser: String): Boolean = false + + @Suppress("UNUSED_PARAMETER") + fun launch(launcher: ActivityResultLauncher, browser: String, uri: Uri, callbackScheme: String) { + throw UnsupportedOperationException("Auth Tabs need androidx.browser 1.9.0.") + } +} diff --git a/package/shared-native/android/PerfTextViewManager.kt b/package/shared-native/android/PerfTextViewManager.kt index 76a03d5df8..6aa7c7963d 100644 --- a/package/shared-native/android/PerfTextViewManager.kt +++ b/package/shared-native/android/PerfTextViewManager.kt @@ -83,7 +83,7 @@ class PerfTextViewManager : SimpleViewManager() { ): Long { val view = PerfTextView(context) - // Apply props that influence size (text, fontSize, lineHeight) + // Apply props that influence size (text, fontSize, fontFamily, lineHeight) props?.let { p -> if (p.hasKey("fontSize") && !p.isNull("fontSize")) { val fs = p.getDouble("fontSize") @@ -92,9 +92,13 @@ class PerfTextViewManager : SimpleViewManager() { } } - if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) { - view.setFontFamilyCompat(p.getString("fontFamily")) - } + view.setFontFamilyCompat( + if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) { + p.getString("fontFamily")?.takeIf { it.isNotEmpty() } + } else { + null + }, + ) if (p.hasKey("lineHeight") && !p.isNull("lineHeight")) { val lh = p.getDouble("lineHeight") @@ -137,7 +141,7 @@ class PerfTextViewManager : SimpleViewManager() { val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat())) val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat())) - return YogaMeasureOutput.make(measuredWidthDp * 1.6f, measuredHeightDp * 1.0f) + return YogaMeasureOutput.make(measuredWidthDp, measuredHeightDp) } } diff --git a/package/shared-native/android/agent/AIAgentController.kt b/package/shared-native/android/agent/AIAgentController.kt new file mode 100644 index 0000000000..ae8e276a22 --- /dev/null +++ b/package/shared-native/android/agent/AIAgentController.kt @@ -0,0 +1,197 @@ +package com.streamchatreactnative.shared.agent + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import java.io.File +import java.lang.ref.WeakReference +import java.util.Locale +import java.util.UUID + +/** + * This install's identifier and the sign-ins of tool calls, for the `AIAgent` TurboModule of both + * SDK packages (each package's codegen generates its own `NativeAIAgentSpec`), which forward + * their calls here. + * + * A sign-in opens in the browser's Auth Tab where the app is built with one (see + * `AgentAuthTab`) and the browser has it: the tab hands the page's redirect to the callback scheme + * straight back. Otherwise it opens in a Custom Tab: the redirect then reaches the app as a new + * intent, and coming back to the app without one means the person closed the tab. + */ +class AIAgentController(private val reactContext: ReactApplicationContext) : + ActivityEventListener, + LifecycleEventListener { + companion object { + const val NAME = "AIAgent" + private const val INSTALL_ID_FILE = "io.getstream.ai.client-id" + private const val ERROR = "ai_agent_auth_session" + } + + /** The sign-in open now. One runs at a time; opening another ends this one. */ + private class SignIn( + val callbackScheme: String, + val promise: Promise, + /** The key the Auth Tab's result comes back under, or null for a Custom Tab. */ + val resultKey: String?, + ) { + /** Where the Auth Tab's result is received, and the activity it is registered on. */ + var launcher: ActivityResultLauncher? = null + var host: WeakReference? = null + + /** Whether the person left the app for the Custom Tab, so coming back means it was closed. */ + var left = false + } + + private var signIn: SignIn? = null + private var launches = 0 + + fun initialize() { + reactContext.addActivityEventListener(this) + reactContext.addLifecycleEventListener(this) + } + + fun invalidate() { + reactContext.removeActivityEventListener(this) + reactContext.removeLifecycleEventListener(this) + finish("") + } + + /** + * This install's identifier, created on first use and kept until the app's data is cleared. + * It is not backed up, so a device restored from a backup gets its own. + */ + @Synchronized + fun installId(): String { + // noBackupFilesDir: a restored device must not answer calls meant for the old one. + val file = File(reactContext.applicationContext.noBackupFilesDir, INSTALL_ID_FILE) + if (file.exists()) file.readText().takeIf { it.isNotBlank() }?.let { return it } + val created = "android-" + UUID.randomUUID().toString().uppercase(Locale.ROOT) + file.writeText(created) + return created + } + + fun openAuthSession(url: String, callbackScheme: String, promise: Promise) { + val activity = reactContext.currentActivity + if (activity == null) { + promise.reject(ERROR, "There's no activity to open the sign-in from.") + return + } + activity.runOnUiThread { + finish("") + val uri = Uri.parse(url) + val browser = CustomTabsClient.getPackageName(activity, null) + try { + if (activity is ComponentActivity && + browser != null && + AgentAuthTab.isSupported(activity, browser) + ) { + openAuthTab(activity, browser, uri, callbackScheme, promise) + } else { + signIn = SignIn(callbackScheme, promise, resultKey = null) + CustomTabsIntent.Builder().build().launchUrl(activity, uri) + } + } catch (error: Exception) { + signIn = null + promise.reject(ERROR, error.message ?: "The sign-in couldn't be opened.", error) + } + } + } + + private fun openAuthTab( + activity: ComponentActivity, + browser: String, + uri: Uri, + callbackScheme: String, + promise: Promise, + ) { + val current = SignIn(callbackScheme, promise, resultKey = "stream-chat-ai-sign-in-${launches++}") + signIn = current + val launcher = receiveResult(current, activity) + AgentAuthTab.launch(launcher, browser, uri, callbackScheme) + } + + // Registers where `current`'s Auth Tab result is received, on `activity`. It is registered for + // this sign-in alone and dropped once it answers, since a TurboModule can't register before the + // activity starts, as `AuthTabIntent.registerActivityResultLauncher` requires. Auth Tab's own + // contract only passes the intent through and reads the result's data, which is what + // `StartActivityForResult` does. An activity recreated while the tab is open, such as one + // rotated or reclaimed in the background, keeps the result under the same key until it is + // registered on it again, which `onHostResume` does. + private fun receiveResult(current: SignIn, activity: ComponentActivity): ActivityResultLauncher { + current.launcher?.unregister() + current.host = WeakReference(activity) + val launcher = activity.activityResultRegistry.register( + current.resultKey!!, + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (signIn !== current) return@register + when (result.resultCode) { + // Auth Tab's RESULT_OK and RESULT_CANCELED are the activity's own. + Activity.RESULT_OK -> finish(result.data?.data?.toString() ?: "") + Activity.RESULT_CANCELED -> finish("") + else -> fail("The sign-in didn't complete.") + } + } + current.launcher = launcher + return launcher + } + + private fun finish(callback: String) { + val current = signIn ?: return + signIn = null + current.launcher?.unregister() + current.promise.resolve(callback) + } + + private fun fail(message: String) { + val current = signIn ?: return + signIn = null + current.launcher?.unregister() + current.promise.reject(ERROR, message) + } + + // The Custom Tab's redirect to the callback scheme reaches the app as a new intent, which + // arrives before the activity resumes. + override fun onNewIntent(intent: Intent) { + val current = signIn ?: return + val data = intent.data ?: return + if (current.resultKey == null && data.scheme.equals(current.callbackScheme, ignoreCase = true)) { + finish(data.toString()) + } + } + + override fun onActivityResult( + activity: Activity, + requestCode: Int, + resultCode: Int, + data: Intent?, + ) = Unit + + override fun onHostPause() { + signIn?.takeIf { it.resultKey == null }?.left = true + } + + override fun onHostResume() { + val current = signIn ?: return + if (current.resultKey != null) { + // The activity was recreated while the Auth Tab was open: its result waits on this one. + val activity = reactContext.currentActivity + if (activity is ComponentActivity && current.host?.get() !== activity) { + receiveResult(current, activity) + } + return + } + if (current.left) finish("") + } + + override fun onHostDestroy() = Unit +} diff --git a/package/shared-native/android/jni/PerfTextMeasurementManager.cpp b/package/shared-native/android/jni/PerfTextMeasurementManager.cpp index d0a5fb5324..f79a44d9a1 100644 --- a/package/shared-native/android/jni/PerfTextMeasurementManager.cpp +++ b/package/shared-native/android/jni/PerfTextMeasurementManager.cpp @@ -42,6 +42,7 @@ Size PerfTextMeasurementManager::measure( serializedProps["text"] = props.text; serializedProps["fontSize"] = props.fontSize; serializedProps["lineHeight"] = props.lineHeight; + serializedProps["fontFamily"] = props.fontFamily; auto propsRNM = ReadableNativeMap::newObjectCxxArgs(serializedProps); local_ref propsRM = diff --git a/package/shared-native/ios/AIAgent.h b/package/shared-native/ios/AIAgent.h new file mode 100644 index 0000000000..e9c50ba2b9 --- /dev/null +++ b/package/shared-native/ios/AIAgent.h @@ -0,0 +1,16 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include("StreamChatReactNativeSpec.h") +#import "StreamChatReactNativeSpec.h" +#elif __has_include("StreamChatExpoSpec.h") +#import "StreamChatExpoSpec.h" +#else +#error "Unable to find generated codegen spec header for AIAgent." +#endif + +// This install's identifier and the sign-ins of tool calls, for the `ai-components` agent views. +// The implementation lives in AIAgentModule.swift. +@interface AIAgent : NSObject +@end + +#endif diff --git a/package/shared-native/ios/AIAgent.mm b/package/shared-native/ios/AIAgent.mm new file mode 100644 index 0000000000..1ae5578491 --- /dev/null +++ b/package/shared-native/ios/AIAgent.mm @@ -0,0 +1,65 @@ +#import "AIAgent.h" + +#ifdef RCT_NEW_ARCH_ENABLED + +#if __has_include() +#import +#elif __has_include() +#import +#elif __has_include("stream_chat_react_native-Swift.h") +#import "stream_chat_react_native-Swift.h" +#elif __has_include("stream_chat_expo-Swift.h") +#import "stream_chat_expo-Swift.h" +#else +#error "Unable to import generated Swift header for AIAgent." +#endif + +@implementation AIAgent { + AIAgentModule *moduleInstance; +} + +RCT_EXPORT_MODULE(AIAgent) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (instancetype)init +{ + if (self = [super init]) { + moduleInstance = [AIAgentModule new]; + } + return self; +} + +- (NSString *)installId +{ + return [AIAgentModule installId]; +} + +- (void)openAuthSession:(NSString *)url + callbackScheme:(NSString *)callbackScheme + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + [moduleInstance openAuthSessionWithUrl:url + callbackScheme:callbackScheme + completion:^(NSString *_Nullable callback, NSString *_Nullable error) { + if (error) { + reject(@"ai_agent_auth_session", error, nil); + } else { + resolve(callback ?: @""); + } + }]; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +@end + +#endif diff --git a/package/shared-native/ios/AIAgentModule.swift b/package/shared-native/ios/AIAgentModule.swift new file mode 100644 index 0000000000..b308793116 --- /dev/null +++ b/package/shared-native/ios/AIAgentModule.swift @@ -0,0 +1,126 @@ +import AuthenticationServices +import Foundation +import UIKit + +/// This install's identifier and the sign-ins of tool calls, for the `AIAgent` TurboModule +/// (`AIAgent.mm`). +@objc(AIAgentModule) +public class AIAgentModule: NSObject { + private static let installIdKey = "io.getstream.ai.client-id" + + /// The session open now. One sign-in runs at a time; opening another ends this one. + private var session: ASWebAuthenticationSession? + private var completion: ((String?, String?) -> Void)? + private let anchor = PresentationAnchor() + + /// This install's identifier, created on first use. It is kept in a file left out of backups, + /// as on Android, so a phone restored from another's backup never answers tool calls meant + /// for that one. An id already kept in UserDefaults under the same key carries over, so a + /// native app moving to React Native keeps its id. + @objc public static func installId() -> String { + let file = installIdFile() + if let file, let saved = try? String(contentsOf: file, encoding: .utf8), !saved.isEmpty { + return saved + } + let id = UserDefaults.standard.string(forKey: installIdKey) ?? "ios-" + UUID().uuidString + guard let file, keep(id, in: file) else { + // Without the file, UserDefaults keeps it, backed up as it is. + UserDefaults.standard.set(id, forKey: installIdKey) + return id + } + // The backups from now on don't carry it. + UserDefaults.standard.removeObject(forKey: installIdKey) + return id + } + + // Application Support/io.getstream.ai/install-id. + private static func installIdFile() -> URL? { + guard let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return support.appendingPathComponent("io.getstream.ai", isDirectory: true) + .appendingPathComponent("install-id") + } + + // Writes `id` to `file`, left out of backups. Whether it did. + private static func keep(_ id: String, in file: URL) -> Bool { + do { + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try id.write(to: file, atomically: true, encoding: .utf8) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var excluded = file + try excluded.setResourceValues(values) + return true + } catch { + try? FileManager.default.removeItem(at: file) + return false + } + } + + /// Opens `url` in a web authentication session. `completion` gets the URL the page handed + /// back to `callbackScheme`, nothing when the person closed the sheet, or an error when the + /// session couldn't be opened. + @objc public func openAuthSession( + url: String, + callbackScheme: String, + completion: @escaping (String?, String?) -> Void + ) { + DispatchQueue.main.async { + guard let target = URL(string: url) else { + completion(nil, "The sign-in URL isn't valid.") + return + } + // A sign-in still open is ended as closed; its session's own answer is then ignored. + let previous = self.session + self.finish(nil, error: nil) + previous?.cancel() + + // Weak, so the session and its own callback don't hold each other. + weak var opened: ASWebAuthenticationSession? + let session = ASWebAuthenticationSession(url: target, callbackURLScheme: callbackScheme) { callback, error in + let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin + DispatchQueue.main.async { + guard let opened, self.session === opened else { return } + if let callback { + self.finish(callback.absoluteString, error: nil) + } else if cancelled { + self.finish(nil, error: nil) + } else { + self.finish(nil, error: error?.localizedDescription ?? "The sign-in couldn't be opened.") + } + } + } + opened = session + session.presentationContextProvider = self.anchor + self.session = session + self.completion = completion + if !session.start() { + self.finish(nil, error: "The sign-in couldn't be opened.") + } + } + } + + // The session may answer after a start that failed, so each sign-in is answered once. + private func finish(_ callback: String?, error: String?) { + guard let completion else { return } + self.completion = nil + session = nil + completion(callback, error) + } +} + +// Kept off `AIAgentModule`, whose conformances go in the generated Swift header that every +// Objective-C file of the pod imports without AuthenticationServices. +private final class PresentationAnchor: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.flatMap(\.windows).first(where: \.isKeyWindow) ?? scenes.first?.windows.first ?? ASPresentationAnchor() + } +} diff --git a/package/src/a2ui/A2UIContexts.ts b/package/src/a2ui/A2UIContexts.ts new file mode 100644 index 0000000000..767f22ac66 --- /dev/null +++ b/package/src/a2ui/A2UIContexts.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +import type { A2UIEngine } from './A2UIRenderer'; + +/** The engine of the surfaces below, for what in them reaches the host, such as links. */ +export const A2UIEngineContext = createContext(undefined); + +/** The reply the surfaces below belong to, and whether the agent is still writing it. */ +export const A2UIReplyContext = createContext< + | { + isStreaming: boolean; + messageId: string; + } + | undefined +>(undefined); diff --git a/package/src/a2ui/A2UIInstructions.ts b/package/src/a2ui/A2UIInstructions.ts new file mode 100644 index 0000000000..90c9b6f71c --- /dev/null +++ b/package/src/a2ui/A2UIInstructions.ts @@ -0,0 +1,102 @@ +// What an agent is told to write A2UI with, and what checks it. +import { A2UI_VERSION } from './A2UIStream'; + +import type { AIUIComponent } from '../ai-components/generative-ui/AIUIComponent'; +import type { AIUIComponentDefinition } from '../ai-components/generative-ui/AIUIComponentDefinition'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; +import type { AIClientToolDefinition } from '../ai-components/tool-calls/AIClientToolRunner'; + +/** + * What an agent is told to write replies' UI in A2UI with: the basic catalog, or the part of it + * `keep` names, with the app's components, and what the app adds. + */ +export type A2UIInstructionsConfig = { + /** The id of the catalog with the app's components, as the renderer names it. */ + catalogId?: string; + /** The app's components, or their portable definitions, such as a `UIComponents.json`. */ + components?: (AIUIComponent | AIUIComponentDefinition)[]; + /** + * Replies with their UI, as the agent should write them. Each block must render with the + * catalog, or no instructions are written: a broken example teaches the model broken UI. + */ + examples?: string[]; + /** The info string of a reply's UI block, as the agents router reads it. */ + fence?: string; + /** The basic catalog's components and functions to describe; all of them when absent. */ + keep?: string[]; + /** + * The basic catalog's components and functions to leave out, such as those the app's renderer + * leaves out with its own `removing`, after `keep`. + */ + removing?: string[]; + /** Guidance on using the app's components. */ + notes?: string[]; + /** What the model is told first, such as which answers to write as UI. */ + preamble?: string; + /** Rules added after the protocol's. */ + rules?: string[]; + /** The app's tools the agent can call from the UI it writes, as functions of the catalog. */ + tools?: AIClientToolDefinition[]; +}; + +/** The A2UI an example holds that would not render, and why. */ +export class A2UIInstructionsError extends Error { + readonly problems: string[]; + + constructor(problems: string[]) { + super(`An example's A2UI would not render:\n${problems.join('\n')}`); + this.name = 'A2UIInstructionsError'; + this.problems = problems; + } +} + +/** A description of the A2UI v1.0 messages and the rules that keep what they make valid. */ +export const A2UI_PROTOCOL_OVERVIEW = `# Generating user interfaces with A2UI v1.0 + +You render user interfaces by emitting A2UI messages. Every message is a JSON object with a \`"version": "1.0"\` field plus exactly one of these keys: + +- \`createSurface\`: creates a surface. Requires \`surfaceId\`, and accepts \`catalogId\`, \`sendDataModel\`, \`components\` and \`dataModel\` so a complete UI can be sent in one message. +- \`updateComponents\`: adds or replaces components of an existing surface. Requires \`surfaceId\` and \`components\`. +- \`updateDataModel\`: writes to the surface data model. Requires \`surfaceId\` and \`value\`, and accepts a JSON Pointer \`path\`. Setting \`value\` to \`null\` deletes the entry at \`path\`. +- \`deleteSurface\`: removes a surface. Requires \`surfaceId\`. +- \`callRendererFunction\`: asks the renderer to run one of its functions. +- \`agentFunctionResponse\`: answers a \`callAgentFunction\` request from the renderer. + +Rules: + +1. Components are a flat adjacency list. Each component has a unique \`id\`, a \`component\` type, and its properties inline. Exactly one component must have the id \`root\`. An id is an identifier: letters, digits and underscores, not starting with a digit, such as \`saturday_plan\` (never \`saturday-plan\`). +2. Children are referenced by id, never nested inline. Use an array of ids, or \`{"componentId": "...", "path": "/items"}\` to repeat a template for every item of a data model list. +3. Any property may be a literal, a data binding \`{"@path": "/a/b"}\` or a function call \`{"@call": "name", "args": {...}}\`. Inside a list template, paths without a leading slash resolve relative to the current item, and \`{"@call": "@index"}\` returns the item's position. A literal object key that starts with \`@\` is written with \`@@\`, so \`"@@type"\` means \`"@type"\`. +4. Put content that changes in the data model and bind to it, so you can update values with \`updateDataModel\` instead of resending components. +5. Interactive components declare an \`action\`, which either dispatches an event to you (\`{"event": {"name": "...", "context": {...}}}\`) or runs a renderer function (\`{"functionCall": {"@call": "openUrl", "args": {"url": "..."}}}\`). +6. Surface ids must be unique for the lifetime of the renderer. Creating a surface that already exists is an error: delete it first, or send \`updateComponents\` and \`updateDataModel\` to change it.`; + +export const writing = (fence: string, catalogId: string) => `## Writing UI in A2UI + +Write a reply's UI in one fenced block whose info string is \`${fence}\`, after one short sentence saying what it shows, which is what people read wherever the UI can't show. In the block, write A2UI messages, one JSON object per line, and nothing else: + +1. First a \`createSurface\` with a \`surfaceId\` new to this reply, the \`catalogId\` \`${catalogId}\`, and the \`dataModel\` the UI's inputs start from. +2. Then \`updateComponents\` messages for that surface with a few components each, \`root\` first, so the UI shows as you write it. + +A reply's UI is shown as you wrote it: never write \`callRendererFunction\` or \`agentFunctionResponse\`, and never change an earlier reply's UI, but write a new one. What the person does in it comes back as their next message, which says what the button they tapped says. Give every event action a \`userMessage\` saying what it asks of you, such as "Update the plan", which comes back with it, and a \`context\` with what you need to know, bound to the data model.`; + +// A JSON value with its object keys sorted, so the same catalog always reads the same. +export const sorted = (value: unknown): unknown => + Array.isArray(value) + ? value.map(sorted) + : isRecord(value) + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]), + ) + : value; + +export const fenceOf = (fence?: string) => fence ?? `ui:a2ui@${A2UI_VERSION}`; + +// The A2UI blocks of an example reply. +export const blocksIn = (example: string, fence: string) => + example + .split(`\`\`\`${fence}\n`) + .slice(1) + .map((block) => block.split('\n```')[0]); diff --git a/package/src/a2ui/A2UIPlaceholder.tsx b/package/src/a2ui/A2UIPlaceholder.tsx new file mode 100644 index 0000000000..2f10223cb3 --- /dev/null +++ b/package/src/a2ui/A2UIPlaceholder.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../ai-components/contexts'; +import { Shimmer } from '../ai-components/internal/Shimmer'; +import { primitives } from '../ai-components/internal/tokens'; + +/** What shows while a reply's first surface is being written. */ +export const A2UIPlaceholder = () => { + const { + theme: { colors }, + } = useTheme(); + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + placeholder: { borderRadius: primitives.radiusXl, height: 120 }, +}); diff --git a/package/src/a2ui/A2UIRenderer.tsx b/package/src/a2ui/A2UIRenderer.tsx new file mode 100644 index 0000000000..7ec153f93f --- /dev/null +++ b/package/src/a2ui/A2UIRenderer.tsx @@ -0,0 +1,504 @@ +import React, { useLayoutEffect, useMemo, useSyncExternalStore } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + type ActionPayload, + type Catalog, + type ComponentApi, + MessageProcessor, + type SurfaceModel, +} from '@a2ui/web_core'; + +import { A2UIEngineContext, A2UIReplyContext } from './A2UIContexts'; +import { A2UIPlaceholder } from './A2UIPlaceholder'; +import { + A2UI_VERSION, + a2uiMessagesIn, + a2uiSourceOf, + normalizeA2UIMessage, + supportsA2UIVersion, +} from './A2UIStream'; +import { A2UISurfaceView } from './A2UISurface'; +import { A2UI_DEFAULT_CATALOG_ID, A2UI_WEB_CATALOG_ID, a2uiTypeName } from './catalogDocument'; +import { a2uiCatalogsOf } from './catalogs'; +import { A2UI_BASIC_COMPONENTS } from './components/basicComponents'; +import type { A2UIComponents, A2UIIcons } from './components/types'; +import { a2uiInstructions } from './instructions'; +import { + secretContextKeysOf, + secretPathsOf, + secretValuesOf, + withoutPaths, + withoutSecrets, +} from './secrets'; + +import { useTheme } from '../ai-components/contexts'; +import type { AIUIDocument } from '../ai-components/generative-ui/AIUIDocument'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; +import type { AIUIHost } from '../ai-components/generative-ui/AIUIHost'; +import { + aiUIMediaSource, + type AIUIMediaURL, + aiUIPublicMediaURL, +} from '../ai-components/generative-ui/AIUIMedia'; +import type { AIUIEngine, AIUIRenderer } from '../ai-components/generative-ui/AIUIRenderer'; +import { primitives } from '../ai-components/internal/tokens'; + +export { A2UI_DEFAULT_CATALOG_ID, A2UI_WEB_CATALOG_ID }; + +/** What went wrong in a reply's A2UI, as the A2UI specification reports it to an agent. */ +export type A2UIError = { + /** A code such as `VALIDATION_FAILED` or `EXPRESSION_ERROR`. */ + code: string; + message: string; + /** Where in the message it went wrong, as a JSON pointer. */ + path?: string; + surfaceId?: string; +}; + +export type A2UIRendererOptions = { + /** + * The id the agent names the catalog with the app's components by. A surface naming the + * basic catalog shows them too, and a surface naming none is made with this one. + */ + catalogId?: string; + /** + * The views of the basic catalog's components to use instead of the default ones, by their + * A2UI type, such as `{ Card: MyCard }`. Each gets what the agent wrote for it, resolved. + */ + components?: A2UIComponents; + /** + * The app's icons for the names A2UI's Icon takes, such as `{ shoppingCart: MyCartIcon }`, over + * the Material icons it draws by default. An app's own names work too, when its agent is told + * them. + */ + icons?: A2UIIcons; + /** + * What the app tells the model before A2UI's own instructions in `capabilities`, such as which + * answers to write as UI. + */ + instructions?: string; + /** The locale numbers and dates are formatted in. The device's by default. */ + locale?: string; + /** + * Decides what an Image, Video or AudioPlayer loads for the link the agent wrote: the address + * to load, or `null` not to load it. By default only public web links load, never addresses on + * the person's network, such as `192.168.1.1` or `localhost`. + */ + mediaUrl?: AIUIMediaURL; + /** + * The basic catalog's components and functions this renderer leaves out, such as `['Video']`. + * The agent's instructions leave them out too, and a reply that places one shows the rest. + */ + removing?: string[]; + /** + * Tells the app what went wrong in a reply's A2UI, such as a message that isn't JSON or a + * component the catalog doesn't have, with the reply's id. The rest of the reply's UI still + * shows. What is wrong with the renderer rather than a reply, such as an app's component that + * can't join the catalog, comes without an id. Logged by default. + */ + onError?: (error: A2UIError, messageId?: string) => void; +}; + +const reportA2UIError = (options: A2UIRendererOptions, error: A2UIError, messageId?: string) => { + if (options.onError) options.onError(error, messageId); + else console.error(`A reply's A2UI can't show: ${error.code} ${error.message}`, error); +}; + +type RendererError = { + code?: unknown; + message?: unknown; + path?: unknown; + surfaceId?: unknown; +}; + +const errorOf = (error: RendererError): A2UIError => ({ + code: typeof error.code === 'string' ? error.code : 'EXECUTION_ERROR', + message: typeof error.message === 'string' ? error.message : String(error.message), + ...(typeof error.path === 'string' ? { path: error.path } : {}), + ...(typeof error.surfaceId === 'string' ? { surfaceId: error.surfaceId } : {}), +}); + +// An action's name in words, such as `Submit form` for `submit_form`, for an action whose button +// says nothing. +const wordsOf = (name: string) => { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[_\-\s]+/) + .filter(Boolean) + .join(' ') + .toLowerCase(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : name; +}; + +/** + * The surfaces of one reply: its own processor, fed the messages of its document as they + * complete. Messages that changed, rather than grew, start its surfaces again. + */ +export class A2UIReplyState { + isStreaming = false; + + private applied: string[] = []; + private readonly engine: A2UIEngine; + private listeners = new Set<() => void>(); + private readonly messageId: string; + private processor: MessageProcessor; + private reported = new Set(); + private shown: SurfaceModel[] = []; + private showedNothing = false; + private stopWatching: () => void; + + constructor(engine: A2UIEngine, messageId: string) { + this.engine = engine; + this.messageId = messageId; + this.processor = this.makeProcessor(); + this.stopWatching = this.watch(); + } + + /** The reply's surfaces, in the order the agent made them. */ + surfaces = (): SurfaceModel[] => this.shown; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** Applies the messages of `source` not applied yet. */ + update(source: string, isStreaming: boolean) { + this.isStreaming = isStreaming; + const messages = a2uiMessagesIn(source); + const grew = + messages.length >= this.applied.length && + this.applied.every((message, index) => messages[index] === message); + if (!grew) { + this.stopWatching(); + this.processor.dispose(); + this.processor = this.makeProcessor(); + this.stopWatching = this.watch(); + this.applied = []; + } + for (const raw of messages.slice(this.applied.length)) { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + json = undefined; + } + if (!isRecord(json)) { + this.report({ code: 'VALIDATION_FAILED', message: 'A message isn’t a JSON object' }); + continue; + } + this.feed(normalizeA2UIMessage(json, this.engine.catalogId)); + } + this.applied = messages; + this.refresh(); + // A finished reply that made no surface shows its text, which the host shows when asked + // again: it asked before these messages were applied. + const showsNothing = !isStreaming && this.shown.length === 0; + if (showsNothing !== this.showedNothing) { + this.showedNothing = showsNothing; + this.engine.host.refresh(); + } + } + + dispose() { + this.stopWatching(); + this.processor.dispose(); + this.listeners.clear(); + } + + // Feeds a message to the processor. A batch of components that one bad component spoils is + // fed again a component at a time, so the rest show, as they would while being written. + private feed(message: Record) { + const errors = this.processor.processMessagesWithErrors(message as never); + const reported = errors.length ? (this.oneAtATime(message) ?? errors) : errors; + for (const { error } of reported) this.report(errorOf(error as RendererError)); + } + + // The components of a message one at a time, with what goes wrong with each, or `undefined` + // when the message has no batch of them to feed again. A surface made with components is made + // before they are added, and stays when they fail, so they are added to it. + private oneAtATime(message: Record) { + const { createSurface, updateComponents } = message; + const batch = isRecord(updateComponents) + ? updateComponents + : isRecord(createSurface) + ? createSurface + : undefined; + if (!batch || !Array.isArray(batch.components) || batch.components.length < 2) { + return undefined; + } + const { surfaceId } = batch; + if ( + batch === createSurface && + (typeof surfaceId !== 'string' || !this.processor.model.getSurface(surfaceId)) + ) { + return undefined; + } + return batch.components.flatMap((component: unknown) => + this.processor.processMessagesWithErrors({ + updateComponents: { components: [component], surfaceId }, + version: message.version, + } as never), + ); + } + + private makeProcessor() { + return new MessageProcessor(this.engine.catalogs(), (action) => this.act(action)); + } + + // Tells the agent what the person did, in the words of the button they tapped, else its name. + // Never in the words the agent gave the action, which could say something the button doesn't; + // those go along as `userMessage`. What the person typed into an obscured field goes nowhere: + // not in the context, the message or the data model, which goes when the surface asks for it. + private act(action: ActionPayload) { + const surface = this.processor.model.getSurface(action.surfaceId); + const paths = secretPathsOf(surface); + const secrets = secretValuesOf(surface); + const context = withoutSecrets(action.context ?? {}, secrets) as Record; + for (const key of secretContextKeysOf(surface, action.sourceComponentId)) delete context[key]; + const userMessage = + typeof action.userMessage === 'string' + ? withoutSecrets(action.userMessage, secrets).trim() + : ''; + const dataModel = surface?.sendDataModel + ? withoutSecrets(withoutPaths(surface.dataModel.get('/'), paths), secrets) + : undefined; + void this.engine.host.send({ + format: 'a2ui', + label: this.buttonText(action) ?? wordsOf(action.name), + messageId: this.messageId, + name: action.name, + payload: { + context, + sourceComponentId: action.sourceComponentId, + surfaceId: action.surfaceId, + ...(userMessage ? { userMessage } : {}), + ...(dataModel !== undefined ? { dataModel } : {}), + }, + }); + } + + // What the tapped button says: its child Text, written out or bound to the data model. + private buttonText(action: ActionPayload) { + const surface = this.processor.model.getSurface(action.surfaceId); + const components = surface?.componentsModel; + const child: unknown = components?.get(action.sourceComponentId)?.properties.child; + if (typeof child !== 'string') return undefined; + const text: unknown = components?.get(child)?.properties.text; + if (typeof text === 'string') return text.trim() === '' ? undefined : text; + if (isRecord(text) && typeof text['@path'] === 'string' && text['@path'].startsWith('/')) { + const bound: unknown = surface?.dataModel.get(text['@path']); + return typeof bound === 'string' && bound.trim() !== '' ? bound : undefined; + } + return undefined; + } + + private watch() { + const subscriptions = [ + this.processor.onSurfaceCreated(() => this.refresh()), + this.processor.onSurfaceDeleted(() => this.refresh()), + this.processor.model.onError.subscribe((error) => + this.report(errorOf(error as RendererError)), + ), + ]; + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + } + + private refresh() { + const surfaces = [...this.processor.getSurfaces().values()]; + if ( + surfaces.length === this.shown.length && + surfaces.every((surface, index) => surface === this.shown[index]) + ) { + return; + } + this.shown = surfaces; + this.listeners.forEach((listener) => listener()); + } + + // Each error once: a reply's messages are fed again as it streams, and the processor tells + // the same error both as it fails a message, with the place in it, and to the surface. + private report(error: A2UIError) { + const key = [error.code, error.message, error.surfaceId].join('\u0000'); + if (this.reported.has(key)) return; + this.reported.add(key); + this.engine.report(error, this.messageId); + } +} + +/** A reply's surfaces, following its document as the agent writes it. */ +const A2UIReplyContent = ({ + document, + engine, + messageId, +}: { + document: AIUIDocument; + engine: A2UIEngine; + messageId: string; +}) => { + const reply = engine.replyOf(document, messageId); + const source = a2uiSourceOf(document) ?? ''; + const { isStreaming } = document; + useLayoutEffect(() => { + reply.update(source, isStreaming); + }, [isStreaming, reply, source]); + const surfaces = useSyncExternalStore(reply.subscribe, reply.surfaces); + const { theme } = useTheme(); + const replyContext = useMemo(() => ({ isStreaming, messageId }), [isStreaming, messageId]); + if (surfaces.length === 0) return isStreaming ? : null; + return ( + + + + {surfaces.map((surface) => ( + + ))} + + + + ); +}; + +/** + * Shows the A2UI of one conversation. Each reply's surfaces are its own, kept for as long as + * the host keeps the engine, so what the person entered survives scrolling. + */ +export class A2UIEngine implements AIUIEngine { + readonly catalogId: string; + readonly components: A2UIComponents; + readonly host: AIUIHost; + readonly icons: A2UIIcons; + readonly mediaUrl: AIUIMediaURL; + /** Each reply's surfaces, by the reply's id. */ + readonly replies = new Map(); + + private made?: Catalog[]; + private readonly options: A2UIRendererOptions; + + constructor(host: AIUIHost, options: A2UIRendererOptions) { + this.host = host; + this.options = options; + this.catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + // The basic catalog's views, the app's own over them. + this.components = { ...A2UI_BASIC_COMPONENTS, ...options.components }; + this.icons = options.icons ?? {}; + this.mediaUrl = options.mediaUrl ?? aiUIPublicMediaURL; + } + + /** The address an Image, Video or AudioPlayer loads for a link the agent wrote, if any. */ + mediaSource(value: unknown) { + return aiUIMediaSource(value, this.mediaUrl); + } + + /** The catalogs a reply's surfaces are made with, made once. */ + catalogs(): Catalog[] { + this.made ??= a2uiCatalogsOf({ + catalogId: this.catalogId, + components: this.host.components, + locale: this.options.locale, + removing: this.options.removing, + onComponentError: (component, error) => + this.report({ + code: 'CATALOG_ERROR', + message: `The component ${component.definition.name} can't join the catalog: ${String(error)}`, + }), + onToolError: (tool, error) => + this.report({ + code: 'CATALOG_ERROR', + message: `The tool ${tool.name} can't join the catalog: ${String(error)}`, + }), + openUrl: (url) => this.host.open(url), + runTool: (name, args, options) => this.host.runTool(name, args, options), + tools: this.host.toolDefinitions, + }); + return this.made; + } + + /** The app's component the agent places by its A2UI type, such as `PlaceCard`. */ + appComponent(type: string) { + return this.host.components.find( + (component) => a2uiTypeName(component.definition.name) === type, + ); + } + + /** + * Tells the app what went wrong, after the current render: a reply's messages are applied + * as it first shows, and an app may set state when told. + */ + report(error: A2UIError, messageId?: string) { + queueMicrotask(() => reportA2UIError(this.options, error, messageId)); + } + + /** The surfaces of a reply, made with its messages so far the first time they are asked for. */ + replyOf(document: AIUIDocument, messageId: string) { + let reply = this.replies.get(messageId); + if (!reply) { + reply = new A2UIReplyState(this, messageId); + this.replies.set(messageId, reply); + reply.update(a2uiSourceOf(document) ?? '', document.isStreaming); + } + return reply; + } + + remove(messageId: string) { + this.replies.get(messageId)?.dispose(); + this.replies.delete(messageId); + } + + view(document: AIUIDocument, messageId: string) { + if (a2uiSourceOf(document) === undefined) return null; + // A finished reply whose A2UI made no surface shows its text instead, once that is known. + if (!document.isStreaming && this.replyOf(document, messageId).surfaces().length === 0) { + return null; + } + return ; + } +} + +/** + * The renderer of [A2UI](https://a2ui.org) 1.0 for `AIUIHost`, on the A2UI core + * (`@a2ui/web_core`), with the basic catalog's components drawn natively. The app's components + * join its catalog, so the agent places them like the basic catalog's own. + */ +export const createA2UIRenderer = (options: A2UIRendererOptions = {}): AIUIRenderer => { + const catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + return { + // The fence, the catalog and the instructions for both, so the agent's backend has its model + // write UI this renderer shows. Without the instructions, when they can't be written, the rest + // still goes. + capabilities: async (components, tools) => { + const fence = `ui:a2ui@${A2UI_VERSION}`; + let instructions: string | undefined; + try { + instructions = await a2uiInstructions({ + catalogId, + components, + fence, + ...(options.instructions?.trim() ? { preamble: options.instructions.trim() } : {}), + removing: options.removing, + tools, + }); + } catch (error) { + reportA2UIError(options, { + code: 'EXECUTION_ERROR', + message: `The A2UI instructions couldn't be written: ${String(error)}`, + }); + } + return { + catalog_id: catalogId, + fence, + ...(instructions === undefined ? {} : { instructions }), + }; + }, + createEngine: (host) => new A2UIEngine(host, options), + format: 'a2ui', + supports: supportsA2UIVersion, + }; +}; + +const styles = StyleSheet.create({ + surfaces: { gap: primitives.spacingSm }, +}); diff --git a/package/src/a2ui/A2UIStream.ts b/package/src/a2ui/A2UIStream.ts new file mode 100644 index 0000000000..fd84e2e934 --- /dev/null +++ b/package/src/a2ui/A2UIStream.ts @@ -0,0 +1,105 @@ +import type { AIUIDocument } from '../ai-components/generative-ui/AIUIDocument'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; + +/** The version of A2UI this renderer reads. */ +export const A2UI_VERSION = '1.0'; + +// The spellings of 1.0 an agent may write, such as from instructions written before its +// release, or by copying the fence's `ui:a2ui@1.0` in another form. +const VERSION_SPELLINGS = new Set(['1', '1.0', '1.0.0', 'v1', 'v1.0']); + +/** Whether a reply's A2UI of `version`, or of no version, is one this renderer reads. */ +export const supportsA2UIVersion = (version: string | undefined) => + version === undefined || VERSION_SPELLINGS.has(version); + +const VALUE_STARTS = ':,['; +const WHITESPACE = /\s/; + +/** + * The complete A2UI messages in `text`, as written: every top-level JSON object, in order. + * One object per line, a pretty-printed one or a JSON array of them all read the same, and + * words around them are skipped. An object still being written is left out until it closes, so + * a reply's UI shows each message as it completes. + */ +export const a2uiMessagesIn = (text: string): string[] => { + const messages: string[] = []; + let depth = 0; + let start = 0; + let inString = false; + let escaped = false; + let resyncing = false; + let previous = ' '; + const breakOff = (index: number) => { + messages.push(text.slice(start, index).trimEnd()); + depth = 0; + inString = false; + escaped = false; + resyncing = true; + }; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (char === '\n') breakOff(index); + else if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') { + inString = false; + previous = char; + } + continue; + } + if (WHITESPACE.test(char)) continue; + if (char === '"' && depth > 0) { + inString = true; + } else if (char === '{') { + const firstColumn = index === 0 || text[index - 1] === '\n'; + if (depth > 0 && firstColumn && !VALUE_STARTS.includes(previous)) breakOff(index); + if (depth > 0 || !resyncing || firstColumn) { + if (depth === 0) start = index; + resyncing = false; + depth++; + } + } else if (char === '}' && depth > 0) { + depth--; + if (depth === 0) messages.push(text.slice(start, index + 1)); + } + previous = char; + } + return messages; +}; + +/** + * The messages of a document as text: as the agent wrote them, or written out when a backend + * sent them as JSON. `undefined` for content that holds no messages. + */ +export const a2uiSourceOf = (document: AIUIDocument): string | undefined => { + const { content } = document; + if (typeof content === 'string') return content; + if (typeof content === 'object' && content !== null) { + try { + return JSON.stringify(content); + } catch { + return undefined; + } + } + return undefined; +}; + +/** + * A message as the processor reads it: 1.0 in its own spelling, and a surface the agent made + * without naming a catalog made with `catalogId`, the catalog with the app's components. + */ +export const normalizeA2UIMessage = ( + message: Record, + catalogId: string, +): Record => { + let normalized = message; + if (typeof message.version === 'string' && VERSION_SPELLINGS.has(message.version)) { + normalized = { ...normalized, version: A2UI_VERSION }; + } + const { createSurface } = normalized; + if (isRecord(createSurface) && createSurface.catalogId === undefined) { + normalized = { ...normalized, createSurface: { ...createSurface, catalogId } }; + } + return normalized; +}; diff --git a/package/src/a2ui/A2UISurface.tsx b/package/src/a2ui/A2UISurface.tsx new file mode 100644 index 0000000000..c017aedbc4 --- /dev/null +++ b/package/src/a2ui/A2UISurface.tsx @@ -0,0 +1,270 @@ +import React, { + createContext, + memo, + type ReactNode, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, + useSyncExternalStore, +} from 'react'; +import { type AccessibilityProps, View as RNView, type ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { + type ComponentApi, + type ComponentNode, + effect, + getValue, + isComponentNode, + isWritable, + NodeResolver, + peekValue, + ResolvedBinding, + type Signal, + type SurfaceModel, +} from '@a2ui/web_core'; + +import { A2UIEngineContext, A2UIReplyContext } from './A2UIContexts'; +import { keepSecretPath } from './secrets'; + +import { AIUIComponentView } from '../ai-components/generative-ui/AIUIComponent'; +import { useAIUIComesIn, useAIUIEntrance } from '../ai-components/generative-ui/AIUIEntrance'; +import { isRecord } from '../ai-components/generative-ui/AIUIHelpers'; + +/** The value of an A2UI signal, following it. */ +export const useSignalValue = (signal: Signal) => { + const subscribe = useCallback( + (onChange: () => void) => + effect(() => { + getValue(signal); + onChange(); + }), + [signal], + ); + const getSnapshot = useCallback(() => peekValue(signal), [signal]); + return useSyncExternalStore(subscribe, getSnapshot); +}; + +const isPlainObject = (value: unknown): value is Record => { + if (!isRecord(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const viewValueOf = (value: unknown): unknown => { + if (isComponentNode(value)) return value; + if (value instanceof ResolvedBinding) return viewValueOf(value.value); + if (Array.isArray(value)) return value.map(viewValueOf); + if (isPlainObject(value)) return viewPropsOf(value); + return value; +}; + +const noop = () => undefined; + +// What a component's view reads: bindings as their values, with a setter for each one the person +// can change, named after it, such as `setValue` for `value`. Children stay nodes. +const viewPropsOf = (props: Record) => { + const view: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (value instanceof ResolvedBinding) { + view[key] = viewValueOf(value.value); + view[`set${key.charAt(0).toUpperCase()}${key.slice(1)}`] = isWritable(value) + ? value.set + : noop; + } else view[key] = viewValueOf(value); + } + return view; +}; + +/** The axis the parent of a part lays its children out on, which a part's weight grows along. */ +export const A2UIAxisContext = createContext<'column' | 'row'>('column'); + +const said = (value: unknown) => + value === undefined || value === null || value === '' ? undefined : String(value); + +// What the agent tells assistive technology about a part, with its `accessibility`: what to call +// it, what more to say about it, whether to announce its changes, and whether to leave it out. +const accessibilityOf = (accessibility: unknown): AccessibilityProps => { + if (!isRecord(accessibility)) return {}; + const { description, hidden, label, live } = accessibility; + const named = said(label); + const described = said(description); + return { + ...(named ? { accessibilityLabel: named } : {}), + ...(described ? { accessibilityHint: described } : {}), + ...(live === 'polite' || live === 'assertive' ? { accessibilityLiveRegion: live } : {}), + ...(hidden === true + ? { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' } + : {}), + }; +}; + +// One of the app's components where the agent placed it, with the arguments it declares. +const PlacedComponent = ({ + node, + props, +}: { + node: ComponentNode; + props: Record; +}) => { + const engine = useContext(A2UIEngineContext); + const reply = useContext(A2UIReplyContext); + const component = engine?.appComponent(node.type); + const names = component?.definition.parameters.map(({ name }) => name); + const args = useMemo( + () => + Object.fromEntries( + (names ?? []).filter((name) => name in props).map((name) => [name, props[name]]), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- the names change with the component + [component, props], + ); + if (!engine || !reply || !component) return null; + const { messageId } = reply; + const { name } = component.definition; + return ( + + void engine.host.send({ + format: 'a2ui', + label, + messageId, + name: event, + payload: { ...payload, component: name }, + }), + host: engine.host, + isStreaming: reply.isStreaming, + messageId, + }} + /> + ); +}; + +/** + * One part of a surface: its view from the basic catalog, or the app's component of its type, + * coming in when it shows, with its weight and what the agent tells assistive technology. A part + * whose type no catalog has, or that is still on its way, shows nothing, and one that holds other + * parts, such as a card, shows once one of them does while the agent writes it. + */ +// The path of the data model a TextField writes what the person types to, when it is obscured, +// such as a password, which must never reach the agent. +const secretPathOf = (node: ComponentNode, resolved: Record) => { + if (node.type !== 'TextField') return undefined; + const variant = + resolved.variant instanceof ResolvedBinding ? resolved.variant.value : resolved.variant; + const { value } = resolved; + return variant === 'obscured' && value instanceof ResolvedBinding && isWritable(value) + ? (node.context?.dataContext.resolvePath(value.path) ?? value.path) + : undefined; +}; + +// Tells the part around a part that it shows. +const A2UIPartShowsContext = createContext<(() => void) | undefined>(undefined); + +// Whether a part's props hold other parts, as a card's child or a column's children do. +const holdsParts = (value: unknown): boolean => + isComponentNode(value) || + (Array.isArray(value) + ? value.some(holdsParts) + : isPlainObject(value) && Object.values(value).some(holdsParts)); + +const hiddenStyle = { display: 'none' } as const; + +const A2UINode = memo(({ node }: { node: ComponentNode }) => { + const engine = useContext(A2UIEngineContext); + const axis = useContext(A2UIAxisContext); + const resolved = useSignalValue(node.props) as Record; + const props = useMemo(() => viewPropsOf(resolved), [resolved]); + // Before the person can type: whichever view draws the field, what they type stays theirs. + const secretPath = secretPathOf(node, resolved); + const surface = node.context?.dataContext.surface; + useLayoutEffect(() => { + if (secretPath && surface) keepSecretPath(surface, secretPath); + }, [secretPath, surface]); + const View = engine?.components[node.type]; + const isApp = !View && !!engine?.appComponent(node.type); + const isReady = !!engine && !node.isPlaceholder && node.state === 'resolved' && (!!View || isApp); + const holds = useMemo(() => holdsParts(props), [props]); + const [hasShownPart, setHasShownPart] = useState(false); + const isWritten = !useContext(A2UIReplyContext)?.isStreaming; + const shows = isReady && (!holds || hasShownPart || isWritten); + const partShows = useContext(A2UIPartShowsContext); + useLayoutEffect(() => { + if (shows) partShows?.(); + }, [partShows, shows]); + const onPartShown = useCallback(() => setHasShownPart(true), []); + const comesIn = useAIUIComesIn(shows); + if (!isReady) return null; + const content = ( + + {View ? : } + + ); + if (!shows) return {content}; + // A weighted part shares the room its row leaves, as flex-grow from nothing does. + const weight = typeof props.weight === 'number' && axis === 'row' ? props.weight : undefined; + const Part = comesIn ? EnteringPart : ShownPart; + return ( + + {content} + + ); +}); +A2UINode.displayName = 'A2UINode'; + +type PartProps = ViewProps & { children: ReactNode }; + +// A part that comes in as it shows, such as while the agent writes it. +const EnteringPart = ({ children, style, ...props }: PartProps) => { + const { scope, style: entrance } = useAIUIEntrance(); + return ( + + {scope(children)} + + ); +}; + +// A part that shows at once, such as one of a reply from history: a plain view, since nothing of +// it moves and a list that scrolls fast mounts it all the time. +const ShownPart = ({ children, ...props }: PartProps) => {children}; + +/** A child of a part, as its view gets it: a node, or nothing while it is on its way. */ +export const A2UIChild = ({ node }: { node: unknown }) => + isComponentNode(node) ? : null; + +/** The children of a part, as its view gets them. */ +export const A2UIChildren = ({ nodes }: { nodes: unknown }) => ( + <> + {Array.isArray(nodes) + ? nodes.map((node) => + isComponentNode(node) ? : null, + ) + : null} + +); + +const resolvers = new WeakMap, NodeResolver>(); + +const resolverOf = (surface: SurfaceModel) => { + let resolver = resolvers.get(surface); + if (!resolver) { + resolver = new NodeResolver(surface, surface.defaultCatalog); + resolvers.set(surface, resolver); + } + return resolver; +}; + +/** A surface of a reply: its tree of parts, from its root, as the agent writes them. */ +export const A2UISurfaceView = ({ surface }: { surface: SurfaceModel }) => { + const root = useSignalValue(resolverOf(surface).rootNode); + return root ? : null; +}; diff --git a/package/src/a2ui/__tests__/A2UIInstructions.test.ts b/package/src/a2ui/__tests__/A2UIInstructions.test.ts new file mode 100644 index 0000000000..6a3813cb8f --- /dev/null +++ b/package/src/a2ui/__tests__/A2UIInstructions.test.ts @@ -0,0 +1,247 @@ +// What an agent is told, written in a script or a build too. +import { parseAIUIComponentDefinition } from '../../ai-components'; +import { ohridLines, uiComponents } from '../../mock-builders/a2ui/ohrid'; +import { + A2UI_DEFAULT_CATALOG_ID, + A2UI_WEB_CATALOG_ID, + a2uiInstructions, + A2UIInstructionsError, + a2uiProblems, +} from '../index'; + +const components = uiComponents.flatMap((json) => { + const definition = parseAIUIComponentDefinition(json); + return definition ? [definition] : []; +}); + +// The basic catalog's part the Swift assistant's `a2ui/prompt.json` keeps. +const keep = [ + 'Text', + 'Icon', + 'Row', + 'Column', + 'List', + 'Card', + 'Tabs', + 'Divider', + 'Button', + 'TextField', + 'CheckBox', + 'ChoicePicker', + 'Slider', + 'DateTimeInput', + 'formatString', + 'formatDate', + 'formatNumber', + 'openUrl', + 'required', +]; + +const example = (lines: string[]) => + [ + 'Example — a weekend trip:', + 'A weekend in Ohrid by the lake: a map, your calendar and a plan for each day.', + `\`\`\`ui:a2ui@1.0\n${lines.join('\n')}\n\`\`\``, + ].join('\n\n'); + +const line = (message: Record) => JSON.stringify({ version: '1.0', ...message }); + +const surface = (components: Record[]) => [ + line({ createSurface: { catalogId: A2UI_DEFAULT_CATALOG_ID, surfaceId: 's' } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('a2uiInstructions', () => { + it('writes the instructions from prompt.json, in its order', async () => { + const text = await a2uiInstructions({ + components, + examples: [example(ohridLines)], + keep, + notes: ['- PlaceCard, TripMap and CalendarCheck are the phone’s own views.'], + preamble: 'Some answers work better as interactive UI than as text.', + rules: ['Never add images for places.', 'Write the whole UI again.'], + }); + + const headings = text.split('\n').filter((row) => /^#{1,2} /.test(row)); + expect(headings).toEqual([ + '## Writing UI in A2UI', + '# Generating user interfaces with A2UI v1.0', + '## Catalog Guidelines', + "## The app's components", + '## The catalog `getstream.io:app-catalog`', + '## Rules', + '## Examples', + ]); + expect(text.startsWith('Some answers work better')).toBe(true); + expect(text).toContain('whose info string is `ui:a2ui@1.0`'); + expect(text).toContain('the `catalogId` `getstream.io:app-catalog`'); + expect(text).toContain('1. Never add images for places.\n2. Write the whole UI again.'); + + const catalog = JSON.parse((text.match(/```json\n(\{.*\})\n```/) ?? [])[1] ?? '{}') as { + catalogId: string; + components: Record; + }; + expect(catalog.catalogId).toBe(A2UI_DEFAULT_CATALOG_ID); + expect(Object.keys(catalog.components)).toEqual( + expect.arrayContaining(['PlaceCard', 'TripMap', 'CalendarCheck', 'Text', 'Tabs']), + ); + expect(catalog.components).not.toHaveProperty('Video'); + expect(catalog.components).not.toHaveProperty('Modal'); + expect(catalog.components.CalendarCheck).toEqual({ + description: + "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", + properties: { + component: { const: 'CalendarCheck' }, + end: { + description: 'The last day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + start: { + description: 'The first day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + }, + required: ['component', 'end', 'start'], + type: 'object', + }); + expect(JSON.stringify(catalog.components.PlaceCard)).toContain( + 'Tapping the card asks you about the place.', + ); + }); + + it('writes nothing when an example would not render, and says why', async () => { + const broken = [ + ...ohridLines.slice(0, 2), + line({ + updateComponents: { + components: [{ component: 'Globe', id: 'map' }], + surfaceId: 'ohrid-weekend', + }, + }), + ]; + const asked = a2uiInstructions({ components, examples: [example(broken)], keep }); + await expect(asked).rejects.toBeInstanceOf(A2UIInstructionsError); + await expect(asked).rejects.toMatchObject({ + problems: expect.arrayContaining([expect.stringMatching(/^example: map: .*Globe/)]), + }); + }); +}); + +describe('a2uiProblems', () => { + it('finds nothing in the Ohrid plan', async () => { + expect(await a2uiProblems(ohridLines.join('\n'), { components })).toEqual([]); + }); + + it('says what doesn’t fit the catalog: components, properties and their values', async () => { + const problems = await a2uiProblems( + surface([ + { children: ['title', 'map', 'go'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', size: 3, text: 'A weekend' }, + { component: 'Globe', id: 'map' }, + { child: 'title', component: 'Button', id: 'go' }, + ]).join('\n'), + { components }, + ); + expect(problems).toEqual([ + expect.stringMatching(/^go: .*action/), + expect.stringMatching(/^map: .*Globe/), + expect.stringMatching(/^title: .*size/), + ]); + }); + + it('says what the components make together: references, root, orphans, cycles and ids', async () => { + const dangling = await a2uiProblems( + surface([ + { children: ['a', 'gone'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'a', text: 'Here' }, + { component: 'Text', id: 'lost', text: 'Nobody holds this' }, + ]).join('\n'), + ); + expect(dangling).toEqual([ + 'root holds gone, which is not defined', + 'lost is defined but nothing holds it', + ]); + + expect( + await a2uiProblems(surface([{ component: 'Text', id: 'top', text: 'Hi' }]).join('\n')), + ).toEqual(['surface s has no component with the id root']); + + const cycle = await a2uiProblems( + surface([ + { children: ['loop'], component: 'Column', id: 'root' }, + { children: ['root'], component: 'Column', id: 'loop' }, + ]).join('\n'), + ); + expect(cycle).toEqual([expect.stringMatching(/holds itself through its children/)]); + + const named = await a2uiProblems( + surface([ + { children: ['saturday-plan'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'saturday-plan', text: 'Morning' }, + ]).join('\n'), + ); + expect(named).toEqual([expect.stringMatching(/^saturday-plan: .*identifier/)]); + }); + + it('says what is wrong with the messages themselves', async () => { + const problems = await a2uiProblems( + [ + '{not json}', + line({ updateComponents: { components: [], surfaceId: 'later' } }), + line({ callRendererFunction: { name: 'openUrl' } }), + line({ createSurface: { catalogId: 'someone-else', surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Text', id: 'root', text: 'One' }, + { component: 'Text', id: 'root', text: 'Two' }, + ], + surfaceId: 's', + }, + }), + ].join('\n'), + ); + expect(problems).toEqual([ + "this isn't JSON: {not json}", + 'surface later gets components before it is created', + "a reply's UI calls no functions", + 'surface s names the catalog someone-else, not getstream.io:app-catalog', + 'surface s defines root more than once in one message', + ]); + expect( + await a2uiProblems( + [ + ...surface([{ component: 'Text', id: 'root', text: 'One' }]), + line({ createSurface: { catalogId: A2UI_DEFAULT_CATALOG_ID, surfaceId: 's' } }), + ].join('\n'), + ), + ).toEqual([expect.stringMatching(/^surface s is created again/)]); + expect( + await a2uiProblems( + [ + line({ createSurface: { catalogId: A2UI_WEB_CATALOG_ID, surfaceId: 's' } }), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'One' }], + surfaceId: 's', + }, + }), + ].join('\n'), + ), + ).toEqual([]); + expect( + await a2uiProblems( + [ + ...surface([{ component: 'Text', id: 'root', text: 'One' }]), + line({ deleteSurface: { surfaceId: 's' } }), + ...surface([{ component: 'Text', id: 'root', text: 'Two' }]), + ].join('\n'), + ), + ).toEqual([]); + expect(await a2uiProblems('Just words.')).toEqual([ + 'nothing renders: there is no createSurface', + ]); + }); +}); diff --git a/package/src/a2ui/__tests__/A2UIRenderer.test.tsx b/package/src/a2ui/__tests__/A2UIRenderer.test.tsx new file mode 100644 index 0000000000..58b60579a6 --- /dev/null +++ b/package/src/a2ui/__tests__/A2UIRenderer.test.tsx @@ -0,0 +1,1604 @@ +import React from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput } from 'react-native'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { + type AIUIAction, + AIUIHost, + AIUIView, + defineAIUIComponent, + parseAIUIComponentDefinition, + parseAIUIDocument, + StreamTheme, +} from '../../ai-components'; +import { aiUIMediaSource, isPublicWebURL } from '../../ai-components/generative-ui/AIUIMedia'; +import type { + AIClientTool, + AIClientToolDefinition, +} from '../../ai-components/tool-calls/AIClientToolRunner'; +import { ohridLines, uiComponents } from '../../mock-builders/a2ui/ohrid'; +import { NativeHandlers } from '../../native'; +import { a2uiMessagesIn, normalizeA2UIMessage, supportsA2UIVersion } from '../A2UIStream'; +import basicCatalogJSON from '../basicCatalog.json'; +import { a2uiAppCatalogDocument, a2uiTypeName } from '../catalogDocument'; +import { formatMoment, parseMoment } from '../components/DateTimeInput'; +import { A2UI_ICON_PATHS } from '../components/iconPaths'; +import { + A2UI_DEFAULT_CATALOG_ID, + A2UI_WEB_CATALOG_ID, + type A2UIRendererOptions, + createA2UIRenderer, +} from '../index'; +import { withoutPaths, withoutSecrets } from '../secrets'; + +const definitionOf = (name: string) => { + const definition = parseAIUIComponentDefinition( + uiComponents.find((component) => component.name === name), + ); + if (!definition) throw new Error(`no ${name}`); + return definition; +}; + +const placeCard = defineAIUIComponent<{ note?: string; title: string }>( + definitionOf('place_card'), + ({ args, context }) => ( + + context.emit('ask_about', `Tell me more about ${args.title}`, { title: args.title }) + } + testID='place-card' + > + + {args.title} + {args.note ? ` (${args.note})` : ''} + + + ), +); + +const tripMap = defineAIUIComponent<{ places: { name: string }[] }>( + definitionOf('trip_map'), + ({ args }) => {args.places.map(({ name }) => name).join(', ')}, +); + +const calendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( + definitionOf('calendar_check'), + ({ args }) => ( + + {args.start.getDate()}–{args.end.getDate()} + + ), +); + +const setup = (options: A2UIRendererOptions = {}) => { + const sent: AIUIAction[] = []; + const errors: { code: string; message: string; surfaceId?: string }[] = []; + const open = jest.fn(); + const ui = new AIUIHost({ + components: [placeCard, tripMap, calendarCheck], + open, + renderers: [createA2UIRenderer({ ...options, onError: (error) => errors.push(error) })], + send: (action) => { + sent.push(action); + }, + }); + return { errors, open, sent, ui }; +}; + +const documentOf = (lines: string[], status = 'complete') => { + const document = parseAIUIDocument({ + content: lines.join('\n'), + format: 'a2ui', + status, + v: 1, + version: '1.0', + }); + if (!document) throw new Error('no document'); + return document; +}; + +// Renders one reply's UI, and lets what it tells the host after the render land. +const renderReply = async (ui: AIUIHost, lines: string[], status = 'complete') => { + const result = render( + + + , + ); + await act(() => Promise.resolve()); + return result; +}; + +const line = (message: Record) => JSON.stringify({ version: '1.0', ...message }); + +const button = (id: string, label: string, action: Record, extra = {}) => [ + { action, child: `${id}_label`, component: 'Button', id, ...extra }, + { component: 'Text', id: `${id}_label`, text: label }, +]; + +const surfaceOf = (components: Record[], dataModel?: Record) => [ + line({ createSurface: { surfaceId: 's', ...(dataModel ? { dataModel } : {}) } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('a2uiMessagesIn', () => { + it('reads the messages one per line, in an array, pretty-printed, or among words', () => { + const one = '{"version":"1.0","deleteSurface":{"surfaceId":"a"}}'; + const two = '{"version":"1.0","deleteSurface":{"surfaceId":"b"}}'; + expect(a2uiMessagesIn(`${one}\n${two}`)).toEqual([one, two]); + expect(a2uiMessagesIn(`[${one},${two}]`)).toEqual([one, two]); + expect(a2uiMessagesIn(`Here it is:\n${one}\nand more.`)).toEqual([one]); + expect( + a2uiMessagesIn('{\n "version": "1.0",\n "deleteSurface": {"surfaceId": "a"}\n}'), + ).toHaveLength(1); + }); + + it('leaves out a message still being written', () => { + expect(a2uiMessagesIn('{"version":"1.0"}\n{"version":"1.0","updateComp')).toEqual([ + '{"version":"1.0"}', + ]); + }); + + it('is not misled by braces and quotes inside strings', () => { + const tricky = '{"text":"a } and a \\" and a {"}'; + expect(a2uiMessagesIn(tricky)).toEqual([tricky]); + }); + + const parsed = (message: string): unknown => { + try { + return JSON.parse(message); + } catch { + return undefined; + } + }; + + const surfaceIdOf = (message: string) => + (parsed(message) as { createSurface: { surfaceId: string } }).createSurface.surfaceId; + + it('reads a pretty-printed message across its lines', () => { + const text = [ + '{', + ' "version": "1.0",', + ' "updateComponents": {', + ' "surfaceId": "s",', + ' "components": [', + ' {"id": "root", "component": "Column", "children": ["a"]},', + ' {', + ' "id": "a", "component": "Text", "text": "{ not a brace }"', + ' }', + ' ]', + ' }', + '}', + '{"version": "1.0", "deleteSurface": {"surfaceId": "s"}}', + ].join('\n'); + const messages = a2uiMessagesIn(text); + expect(messages).toHaveLength(2); + expect(messages.map(parsed)).toEqual([expect.any(Object), expect.any(Object)]); + }); + + it('ends a message with a stray quote at its line, and the messages after it still count', () => { + const lines = [ + '{"version": "1.0", "createSurface": {"surfaceId": "a"}}', + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [{"id": "t", "text">Day 1"}]}}', + '{"version": "1.0", "createSurface": {"surfaceId": "b"}}', + '{"version": "1.0", "createSurface": {"surfaceId": "c", "note": "a } and a \\" in a string"}}', + ]; + const messages = a2uiMessagesIn(lines.join('\n')); + expect(messages).toEqual(lines); + expect(parsed(messages[1])).toBeUndefined(); + expect([0, 2, 3].map((index) => surfaceIdOf(messages[index]))).toEqual(['a', 'b', 'c']); + }); + + it('ends a message missing a brace where the next one starts', () => { + const lines = [ + '{"version": "1.0", "createSurface": {"surfaceId": "a"}', + '{"version": "1.0", "createSurface": {"surfaceId": "b"}}', + '{"version": "1.0", "createSurface": {"surfaceId": "c"}}', + ]; + const messages = a2uiMessagesIn(lines.join('\n')); + expect(messages).toEqual(lines); + expect(parsed(messages[0])).toBeUndefined(); + expect(messages.slice(1).map(surfaceIdOf)).toEqual(['b', 'c']); + }); + + it('skips the rest of a broken pretty-printed message instead of reading it as messages', () => { + const broken = [ + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [', + ' {"id": "t", "text">Day 1"},', + ' {"id": "u", "component": "Text"},', + ' {"id": "w", "component": "Text"}', + ']}}', + ].join('\n'); + const newline = [ + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [', + ' {"id": "t", "component": "Text", "text": "Day 1', + 'Use {name} here"}', + ']}}', + ].join('\n'); + const next = '{"version": "1.0", "createSurface": {"surfaceId": "b"}}'; + for (const text of [broken, newline]) { + const messages = a2uiMessagesIn(`${text}\n${next}`); + expect(messages).toHaveLength(2); + expect(parsed(messages[0])).toBeUndefined(); + expect(surfaceIdOf(messages[1])).toBe('b'); + } + }); + + it('keeps a missing comma between indented objects inside its message', () => { + const text = [ + '{"version": "1.0", "updateComponents": {"surfaceId": "a", "components": [', + ' {"id": "t", "component": "Text"}', + ' {"id": "u", "component": "Text"}', + ']}}', + '{"version": "1.0", "createSurface": {"surfaceId": "b"}}', + ].join('\n'); + const messages = a2uiMessagesIn(text); + expect(messages).toHaveLength(2); + expect(parsed(messages[0])).toBeUndefined(); + expect(surfaceIdOf(messages[1])).toBe('b'); + }); + + it('leaves out the line still being written after a broken message', () => { + const broken = '{"version": "1.0", "updateComponents": {"surfaceId": "a", "text">x"}}'; + const next = '{"version": "1.0", "createSurface": {"surfaceId": "b"}}'; + for (let length = 0; length < next.length; length++) { + expect(a2uiMessagesIn(`${broken}\n${next.slice(0, length)}`)).toEqual([broken]); + } + expect(a2uiMessagesIn(`${broken}\n${next}`)).toEqual([broken, next]); + }); + + it('lets a string still being written hold braces and quotes', () => { + const partial = + '{"version": "1.0", "createSurface": {"surfaceId": "s", "note": "a { and a \\" so far'; + expect(a2uiMessagesIn(partial)).toEqual([]); + expect(a2uiMessagesIn(`${partial}"}}`)).toHaveLength(1); + }); +}); + +describe('the A2UI versions and catalog it reads', () => { + it('reads 1.0 in the spellings an agent writes, and no other version', () => { + for (const version of [undefined, '1', '1.0', '1.0.0', 'v1', 'v1.0']) { + expect(supportsA2UIVersion(version)).toBe(true); + } + for (const version of ['0.9', '2.0', 'v0.8']) { + expect(supportsA2UIVersion(version)).toBe(false); + } + }); + + it('writes every message as 1.0, and names the app’s catalog for a surface naming none', () => { + expect(normalizeA2UIMessage({ version: 'v1.0' }, 'app').version).toBe('1.0'); + expect( + normalizeA2UIMessage({ createSurface: { surfaceId: 's' }, version: '1.0' }, 'app'), + ).toEqual({ createSurface: { catalogId: 'app', surfaceId: 's' }, version: '1.0' }); + expect( + normalizeA2UIMessage( + { createSurface: { catalogId: 'other', surfaceId: 's' }, version: '1.0' }, + 'app', + ).createSurface, + ).toEqual({ catalogId: 'other', surfaceId: 's' }); + }); + + it('describes the app’s components to the agent', () => { + expect(a2uiTypeName('place_card')).toBe('PlaceCard'); + const document = a2uiAppCatalogDocument(A2UI_DEFAULT_CATALOG_ID, [placeCard]); + expect(document.components.PlaceCard).toMatchObject({ + description: + 'One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip. Tapping the card asks you about the place.', + required: ['component', 'title'], + }); + }); + + it('tells the agent’s backend its fence, catalog and instructions', async () => { + const { ui } = setup(); + const { renderers } = await ui.capabilities(); + expect(renderers).toEqual([ + { + catalog_id: A2UI_DEFAULT_CATALOG_ID, + fence: 'ui:a2ui@1.0', + format: 'a2ui', + instructions: expect.any(String), + }, + ]); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions).toContain('whose info string is `ui:a2ui@1.0`'); + expect(instructions).toContain(`## The catalog \`${A2UI_DEFAULT_CATALOG_ID}\``); + expect(instructions).toContain('"PlaceCard"'); + }); + + it('leaves what it removes out of what it shows and of what the agent is told', async () => { + const { errors, ui } = setup({ removing: ['Divider', 'formatCurrency'] }); + const { renderers } = await ui.capabilities(); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions).not.toContain('"Divider"'); + expect(instructions).not.toContain('"formatCurrency"'); + expect(instructions).toContain('"Card"'); + await renderReply( + ui, + surfaceOf([ + { children: ['line', 'words'], component: 'Column', id: 'root' }, + { component: 'Divider', id: 'line' }, + { component: 'Text', id: 'words', text: 'Still here' }, + ]), + ); + await waitFor(() => expect(screen.getByText(/Still here/)).toBeTruthy()); + expect(screen.queryByTestId('a2ui-line')).toBeNull(); + expect(errors.map(({ code }) => code)).not.toContain('EXECUTION_ERROR'); + }); +}); + +describe('a reply written in A2UI', () => { + it('draws a surface that names the web SDK’s catalog id', async () => { + const { errors, ui } = setup(); + await renderReply(ui, [ + line({ createSurface: { catalogId: A2UI_WEB_CATALOG_ID, surfaceId: 's' } }), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'From the web agent' }], + surfaceId: 's', + }, + }), + ]); + await waitFor(() => expect(screen.getByText(/From the web agent/)).toBeTruthy()); + expect(errors).toEqual([]); + }); + it('shows the Ohrid plan with the basic catalog and the app’s components', async () => { + const { errors, ui } = setup(); + await renderReply(ui, ohridLines); + await waitFor(() => + expect(screen.getByTestId('trip-map')).toHaveTextContent(/Church of St\. John at Kaneo/), + ); + expect(screen.getByTestId('calendar-check')).toHaveTextContent('14–15'); + expect(screen.getAllByTestId('place-card')[0]).toHaveTextContent('Plaošnik (1 hour)'); + expect(screen.getByText(/Two easy days by the lake/)).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Saturday' })).toBeTruthy(); + expect(errors).toEqual([]); + }); + + it('tells the agent what the person did, in the words of the button they tapped', async () => { + const { sent, ui } = setup(); + await renderReply(ui, ohridLines); + fireEvent.press(await screen.findByRole('radio', { name: 'Packed' })); + fireEvent.press(screen.getByRole('button', { name: 'Update the plan' })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]).toEqual({ + format: 'a2ui', + label: 'Update the plan', + messageId: 'a1', + name: 'update_plan', + payload: { + context: { interests: [], pace: ['packed'] }, + sourceComponentId: 'update', + surfaceId: 'ohrid-weekend', + userMessage: 'Update the plan', + }, + }); + + fireEvent.press(screen.getAllByTestId('place-card')[0]); + await waitFor(() => expect(sent).toHaveLength(2)); + expect(sent[1]).toEqual({ + format: 'a2ui', + label: 'Tell me more about Plaošnik', + messageId: 'a1', + name: 'ask_about', + payload: { component: 'place_card', title: 'Plaošnik' }, + }); + }); + + it('never sends the agent’s words for an action in place of what its button says', async () => { + const { sent, ui } = setup(); + await renderReply( + ui, + surfaceOf( + button('root', 'Book the boat', { + event: { name: 'book', userMessage: 'Cancel my whole trip' }, + }), + ), + ); + fireEvent.press(await screen.findByRole('button', { name: 'Book the boat' })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.label).toBe('Book the boat'); + expect(sent[0]?.payload.userMessage).toBe('Cancel my whole trip'); + }); + + it('names an action by what its button says when bound, else by its name in words', async () => { + const { sent, ui } = setup(); + await renderReply( + ui, + surfaceOf( + [ + { children: ['bound', 'nameless'], component: 'Column', id: 'root' }, + { + action: { event: { name: 'book' } }, + child: 'bound_label', + component: 'Button', + id: 'bound', + }, + { component: 'Text', id: 'bound_label', text: { '@path': '/cta' } }, + { + action: { event: { name: 'submit_form' } }, + child: 'icon', + component: 'Button', + id: 'nameless', + }, + { component: 'Icon', id: 'icon', name: 'check' }, + ], + { cta: 'Book for two' }, + ), + ); + fireEvent.press(await screen.findByRole('button', { name: 'Book for two' })); + await waitFor(() => expect(sent[0]?.label).toBe('Book for two')); + fireEvent.press(screen.getAllByRole('button')[1]); + await waitFor(() => expect(sent[1]?.label).toBe('Submit form')); + }); + + it('follows a reply as the agent writes it, a message at a time', async () => { + const { ui } = setup(); + const view = (lines: string[]) => ( + + + + ); + const { rerender } = render(view([...ohridLines.slice(0, 2), '{"version":"1.0","upd'])); + await waitFor(() => expect(screen.getByText(/Two easy days by the lake/)).toBeTruthy()); + expect(screen.queryByTestId('trip-map')).toBeNull(); + + rerender(view(ohridLines.slice(0, 3))); + await waitFor(() => expect(screen.getByTestId('trip-map')).toBeTruthy()); + }); + + it('holds back a card until something in it shows, while the agent writes it', async () => { + const { ui } = setup(); + const view = (lines: string[], status = 'streaming') => ( + + + + ); + const written = [ + { children: ['title', 'card'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', text: 'Fit check' }, + { child: 'list', component: 'Card', id: 'card' }, + { children: ['tag'], component: 'Column', id: 'list' }, + ]; + const { rerender } = render(view(surfaceOf(written))); + await waitFor(() => expect(screen.getByTestId('a2ui-title')).toBeTruthy()); + expect(screen.queryByTestId('a2ui-card')).toBeNull(); + expect(screen.queryByTestId('a2ui-list')).toBeNull(); + + rerender( + view([ + ...surfaceOf(written), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'tag', text: 'Name tag' }], + surfaceId: 's', + }, + }), + ]), + ); + await waitFor(() => expect(screen.getByTestId('a2ui-card')).toBeTruthy()); + expect(screen.getByText('Name tag')).toBeTruthy(); + }); + + it('shows a card that holds nothing yet once the agent has written the reply', async () => { + const { ui } = setup(); + await renderReply( + ui, + surfaceOf([ + { child: 'list', component: 'Card', id: 'root' }, + { children: ['tag'], component: 'Column', id: 'list' }, + ]), + ); + expect(screen.getByTestId('a2ui-root')).toBeTruthy(); + }); + + it('shows a placeholder while the first surface is being written', async () => { + const { ui } = setup(); + await renderReply(ui, ['{"version":"1.0","createSur'], 'streaming'); + expect(screen.getByTestId('a2ui-placeholder')).toBeTruthy(); + }); + + it('starts the surfaces again when the agent rewrites what it had written', async () => { + const { ui } = setup(); + const text = (words: string) => surfaceOf([{ component: 'Text', id: 'root', text: words }]); + const view = (lines: string[]) => ( + + + + ); + const { rerender } = render(view(text('First draft'))); + await waitFor(() => expect(screen.getByText(/First draft/)).toBeTruthy()); + rerender(view(text('Second draft'))); + await waitFor(() => expect(screen.getByText(/Second draft/)).toBeTruthy()); + expect(screen.queryByText(/First draft/)).toBeNull(); + }); + + it('shows the rest of a batch that one bad component spoils, and says what went wrong', async () => { + const { errors, ui } = setup(); + await renderReply( + ui, + surfaceOf([ + { children: ['good', 'bad'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'good', text: 'Still here' }, + { component: 'Slider', id: 'bad', value: 'not a number' }, + ]), + ); + await waitFor(() => expect(screen.getByText(/Still here/)).toBeTruthy()); + await waitFor(() => + expect(errors).toEqual([ + expect.objectContaining({ code: 'VALIDATION_FAILED', surfaceId: 's' }), + ]), + ); + }); + + it('says a message is not JSON, and shows the rest', async () => { + const { errors, ui } = setup(); + await renderReply(ui, [ + line({ createSurface: { surfaceId: 's' } }), + '{not json}', + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'After the bad one' }], + surfaceId: 's', + }, + }), + ]); + await waitFor(() => expect(screen.getByText(/After the bad one/)).toBeTruthy()); + await waitFor(() => + expect(errors.map(({ message }) => message)).toContain('A message isn’t a JSON object'), + ); + }); + + it('shows the messages after one the agent broke, and reports the broken one', async () => { + const { errors, ui } = setup(); + const text = (surfaceId: string, words: string) => [ + line({ createSurface: { surfaceId } }), + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: words }], + surfaceId, + }, + }), + ]; + await renderReply(ui, [ + ...text('a', 'Day 1'), + line({ createSurface: { surfaceId: 'b' } }).slice(0, -1), + ...text('c', 'Day 2'), + ]); + await waitFor(() => expect(screen.getByText('Day 2')).toBeTruthy()); + expect(screen.getByText('Day 1')).toBeTruthy(); + expect(errors).toEqual([ + expect.objectContaining({ + code: 'VALIDATION_FAILED', + message: 'A message isn’t a JSON object', + }), + ]); + }); + + it('opens links the way the host allows, from the person’s tap only', async () => { + const { open, ui } = setup(); + await renderReply( + ui, + surfaceOf( + button('root', 'Read about Ohrid', { + functionCall: { + '@call': 'openUrl', + args: { url: 'https://en.wikipedia.org/wiki/Ohrid' }, + }, + }), + ), + ); + const read = await screen.findByRole('button', { name: 'Read about Ohrid' }); + expect(open).not.toHaveBeenCalled(); + fireEvent.press(read); + await waitFor(() => expect(open).toHaveBeenCalledWith('https://en.wikipedia.org/wiki/Ohrid')); + }); + + it('shows nothing for a finished reply whose A2UI made no surface, so its text shows', async () => { + const { ui } = setup(); + await renderReply(ui, ['{"version":"1.0","deleteSurface":{"surfaceId":"none"}}']); + expect( + ui.content(documentOf(['{"version":"1.0","deleteSurface":{"surfaceId":"none"}}']), 'a1'), + ).toBeNull(); + expect(screen.toJSON()).toBeNull(); + }); +}); + +describe('what the person types into an obscured field', () => { + // A sign-in form: an email, a password, and a button that sends both, and the password inside + // a sentence, as an agent may bind it. + const signIn = ({ sendDataModel = false } = {}) => [ + line({ + createSurface: { + dataModel: { account: { email: '', password: '' } }, + sendDataModel, + surfaceId: 's', + }, + }), + line({ + updateComponents: { + components: [ + { children: ['email', 'password', 'go'], component: 'Column', id: 'root' }, + { + component: 'TextField', + id: 'email', + label: 'Email', + value: { '@path': '/account/email' }, + }, + { + component: 'TextField', + id: 'password', + label: 'Password', + value: { '@path': '/account/password' }, + variant: 'obscured', + }, + ...button('go', 'Sign in', { + event: { + context: { + email: { '@path': '/account/email' }, + password: { '@path': '/account/password' }, + said: { + '@call': 'formatString', + args: { value: 'My password is ${/account/password}' }, + }, + }, + name: 'sign_in', + userMessage: { + '@call': 'formatString', + args: { value: 'Signing in with ${/account/password}' }, + }, + }, + }), + ], + surfaceId: 's', + }, + }), + ]; + + const fillIn = async () => { + fireEvent.changeText(await screen.findByLabelText('Email'), 'ana@example.com'); + fireEvent.changeText(screen.getByLabelText('Password'), 'hunter22'); + fireEvent.press(screen.getByRole('button', { name: 'Sign in' })); + }; + + it('never reaches the agent in an action’s context or its message, bound or within text', async () => { + const { sent, ui } = setup(); + await renderReply(ui, signIn()); + await fillIn(); + await waitFor(() => expect(sent).toHaveLength(1)); + // The key bound to the field goes; text written with the password goes without it. + expect(sent[0]?.payload.context).toEqual({ + email: 'ana@example.com', + said: 'My password is ', + }); + expect(sent[0]?.payload.userMessage).toBe('Signing in with'); + expect(sent[0]?.payload).not.toHaveProperty('dataModel'); + expect(JSON.stringify(sent[0])).not.toContain('hunter22'); + }); + + it('is left out of the data model a surface sends with its actions', async () => { + const { sent, ui } = setup(); + await renderReply(ui, signIn({ sendDataModel: true })); + await fillIn(); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.payload.dataModel).toEqual({ account: { email: 'ana@example.com' } }); + expect(JSON.stringify(sent[0])).not.toContain('hunter22'); + }); + + it('stays the person’s whichever view the app draws the field with', async () => { + const { sent, ui } = setup({ + components: { + TextField: ({ props }) => ( + (props.setValue as (value: string) => void)(text)} + value={String(props.value ?? '')} + /> + ), + }, + }); + await renderReply(ui, signIn()); + await fillIn(); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(JSON.stringify(sent[0])).not.toContain('hunter22'); + }); + + it('stays the person’s in an obscured field of a list’s row', async () => { + const { sent, ui } = setup(); + await renderReply(ui, [ + line({ + createSurface: { + dataModel: { accounts: [{ name: 'Main', pin: '' }] }, + sendDataModel: true, + surfaceId: 's', + }, + }), + line({ + updateComponents: { + components: [ + { children: ['accounts', 'go'], component: 'Column', id: 'root' }, + { + children: { componentId: 'pin', path: '/accounts' }, + component: 'List', + id: 'accounts', + }, + { + component: 'TextField', + id: 'pin', + label: 'PIN', + value: { '@path': 'pin' }, + variant: 'obscured', + }, + ...button('go', 'Save', { event: { name: 'save' } }), + ], + surfaceId: 's', + }, + }), + ]); + fireEvent.changeText(await screen.findByLabelText('PIN'), '4821'); + fireEvent.press(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.payload.dataModel).toEqual({ accounts: [{ name: 'Main' }] }); + expect(JSON.stringify(sent[0])).not.toContain('4821'); + }); + + it('leaves the context key a row’s button binds to the row’s obscured field', async () => { + const { sent, ui } = setup(); + await renderReply(ui, [ + line({ + createSurface: { + dataModel: { + accounts: [ + { name: 'Main', pin: '' }, + { name: 'Savings', pin: '' }, + ], + }, + surfaceId: 's', + }, + }), + line({ + updateComponents: { + components: [ + { + children: { componentId: 'row', path: '/accounts' }, + component: 'List', + id: 'root', + }, + { children: ['pin', 'go'], component: 'Row', id: 'row' }, + { + component: 'TextField', + id: 'pin', + label: 'PIN', + value: { '@path': 'pin' }, + variant: 'obscured', + }, + ...button('go', 'Save', { + event: { + context: { + account: { '@path': 'name' }, + pin: { '@path': 'pin' }, + said: { '@call': 'formatString', args: { value: 'My PIN is ${pin}' } }, + }, + name: 'save', + }, + }), + ], + surfaceId: 's', + }, + }), + ]); + const [, savings] = await screen.findAllByLabelText('PIN'); + fireEvent.changeText(savings, '4821'); + fireEvent.press(screen.getAllByRole('button', { name: 'Save' })[1]); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]?.payload.context).toEqual({ account: 'Savings', said: 'My PIN is ' }); + expect(JSON.stringify(sent[0])).not.toContain('4821'); + }); +}); + +describe('parseMoment and formatMoment', () => { + it('read and write the days and times an agent writes', () => { + expect(parseMoment('2026-11-14')).toEqual({ day: { date: 14, month: 10, year: 2026 } }); + expect(parseMoment('09:30')).toEqual({ hours: 9, minutes: 30 }); + expect(parseMoment('2026-11-14T09:30:00Z')).toEqual({ + day: { date: 14, month: 10, year: 2026 }, + hours: 9, + minutes: 30, + }); + const both = { enableDate: true, enableTime: true }; + expect(formatMoment(parseMoment('2026-01-02T03:04'), both)).toBe('2026-01-02T03:04'); + expect(formatMoment(parseMoment('2026-01-02'), { ...both, enableTime: false })).toBe( + '2026-01-02', + ); + expect(formatMoment(parseMoment('03:04'), { ...both, enableDate: false })).toBe('03:04'); + }); +}); + +describe('withoutSecrets and withoutPaths', () => { + it('cut a secret out of every string, in objects and lists', () => { + expect( + withoutSecrets({ a: 'hunter22', b: ['x', 'my hunter22'], c: { d: 'keep', e: 1 } }, [ + 'hunter22', + '', + ]), + ).toEqual({ a: '', b: ['x', 'my '], c: { d: 'keep', e: 1 } }); + expect(withoutSecrets('Signing in with abc', ['abc'])).toBe('Signing in with '); + expect(withoutSecrets({ a: 1 }, [])).toEqual({ a: 1 }); + }); + + it('remove what is at JSON Pointers, escaped segments and list items included', () => { + expect( + withoutPaths({ 'a/b': 1, list: [1, 2, 3], nested: { keep: true, secret: 's' } }, [ + '/a~1b', + '/list/1', + '/nested/secret', + '/missing/path', + ]), + ).toEqual({ list: [1, 3], nested: { keep: true } }); + }); +}); + +describe('the app’s tools in a reply’s UI', () => { + const weather = { + annotations: { readOnlyHint: true }, + description: 'The weather in a city today.', + inputSchema: { + properties: { city: { description: 'The city', type: 'string' } }, + required: ['city'], + type: 'object', + }, + name: 'weather', + }; + // A tool as the app gives it, whose output is what `run` resolves to. + const toolOf = ( + definition: AIClientToolDefinition, + run: (args: Record) => unknown, + ): AIClientTool => ({ + definition, + run: async (call) => ({ output: await run(call.arguments as Record) }), + }); + + const withTools = (tools: ConstructorParameters[0]['tools']) => { + const errors: { code: string; message: string }[] = []; + const ui = new AIUIHost({ + renderers: [createA2UIRenderer({ onError: (error) => errors.push(error) })], + send: jest.fn(), + tools, + }); + return { errors, ui }; + }; + + const forecast = surfaceOf( + [ + { children: ['title', 'sky'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', text: 'Ohrid today' }, + { + component: 'Text', + id: 'sky', + text: { '@call': 'weather', args: { city: { '@path': '/city' } } }, + }, + ], + { city: 'Ohrid' }, + ); + + it('runs a tool the agent calls as a function, with its arguments worked out', async () => { + const run = jest.fn(({ city }: Record) => + Promise.resolve(`Sunny in ${String(city)}`), + ); + const { errors, ui } = withTools([toolOf(weather, run)]); + await renderReply(ui, forecast); + await waitFor(() => expect(screen.getByText(/Sunny in Ohrid/)).toBeTruthy()); + expect(run).toHaveBeenCalledWith({ city: 'Ohrid' }); + expect(errors).toEqual([]); + }); + + it('tells the agent about the tools it can call, with what they take', async () => { + const { ui } = withTools([toolOf(weather, () => 'Sunny')]); + const { renderers, tools } = await ui.capabilities(); + expect(tools).toEqual([weather]); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions).toContain('"weather"'); + expect(instructions).toContain('The weather in a city today.'); + expect(instructions).toContain('"city"'); + expect(instructions).toContain("## The app's tools"); + expect(instructions).toContain('- `weather` (read-only): The weather in a city today.'); + expect(instructions).toContain('Any other runs only from a Button'); + }); + + it('shows the rest of the UI when a tool fails, and says so', async () => { + const { errors, ui } = withTools([ + toolOf(weather, () => Promise.reject(new Error('No forecast'))), + ]); + await renderReply(ui, forecast); + await waitFor(() => expect(screen.getByText(/Ohrid today/)).toBeTruthy()); + await waitFor(() => expect(errors.length).toBeGreaterThan(0)); + expect(screen.queryByText(/Sunny/)).toBeNull(); + }); + + it('tells the model the app’s own instructions first', async () => { + const ui = new AIUIHost({ + renderers: [createA2UIRenderer({ instructions: ' Write every trip plan as UI. ' })], + send: jest.fn(), + }); + const { renderers } = await ui.capabilities(); + const { instructions } = renderers[0] as unknown as { instructions: string }; + expect(instructions.startsWith('Write every trip plan as UI.')).toBe(true); + }); + + it('runs a tool that changes something only when the person asks, never as the reply shows', async () => { + const run = jest.fn(() => 'Sunny'); + const { errors, ui } = withTools([ + toolOf({ ...weather, annotations: { readOnlyHint: false } }, run), + ]); + await renderReply(ui, forecast); + await waitFor(() => expect(screen.getByText(/Ohrid today/)).toBeTruthy()); + await waitFor(() => expect(errors.length).toBeGreaterThan(0)); + expect(errors[0]?.message).toMatch(/isn't read-only/); + expect(run).not.toHaveBeenCalled(); + }); + + it('gives a tool none of what the person typed into an obscured field', async () => { + const run = jest.fn((args: Record) => `Sunny in ${String(args.city)}`); + const { ui } = withTools([toolOf(weather, run)]); + await renderReply( + ui, + surfaceOf( + [ + { children: ['secret', 'sky'], component: 'Column', id: 'root' }, + { + component: 'TextField', + id: 'secret', + label: 'Password', + value: { '@path': '/password' }, + variant: 'obscured', + }, + { + component: 'Text', + id: 'sky', + text: { '@call': 'weather', args: { city: { '@path': '/password' } } }, + }, + ], + { password: 'hunter22' }, + ), + ); + await waitFor(() => expect(run).toHaveBeenCalled()); + expect(JSON.stringify(run.mock.calls)).not.toContain('hunter22'); + }); + + it('leaves out a tool that would take the name of one of the basic catalog’s functions', async () => { + const { errors, ui } = withTools([toolOf({ name: 'required' }, () => true)]); + await renderReply(ui, surfaceOf([{ component: 'Text', id: 'root', text: 'Here' }])); + await waitFor(() => expect(screen.getByText(/Here/)).toBeTruthy()); + await waitFor(() => + expect(errors).toEqual([expect.objectContaining({ code: 'CATALOG_ERROR' })]), + ); + }); +}); + +describe('the basic catalog’s icons', () => { + const pathOf = (id: string) => + screen.getByTestId(`a2ui-${id}`).findAll((node) => typeof node.props.d === 'string')[0]?.props + .d as string | undefined; + + it('draws every icon name the catalog has', () => { + const document = basicCatalogJSON.components.Icon as unknown as Record; + const enumOf = (value: unknown): string[] | undefined => { + if (Array.isArray(value)) { + for (const item of value) { + const found = enumOf(item); + if (found) return found; + } + return undefined; + } + if (typeof value !== 'object' || value === null) return undefined; + const record = value as Record; + if (Array.isArray(record.enum)) return record.enum as string[]; + for (const item of Object.values(record)) { + const found = enumOf(item); + if (found) return found; + } + return undefined; + }; + const catalogNames = enumOf(document) ?? []; + expect(catalogNames.length).toBeGreaterThan(50); + expect(catalogNames.filter((name) => !A2UI_ICON_PATHS[name])).toEqual([]); + }); + + // A name the catalog doesn't have fails validation before it is drawn, so only a path that + // isn't one falls back to the neutral icon. + it('draws a name’s Material icon, the agent’s own path, and a neutral one for a bad path', async () => { + const { ui } = setup(); + await renderReply( + ui, + surfaceOf([ + { children: ['cart', 'drawn', 'bad'], component: 'Row', id: 'root' }, + { component: 'Icon', id: 'cart', name: 'shoppingCart' }, + { component: 'Icon', id: 'drawn', name: { svgPath: 'M0 0h24v24H0z' } }, + { component: 'Icon', id: 'bad', name: { svgPath: '