Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions renderers/web_core/src/v0_9/processing/message-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,61 @@ describe("MessageProcessor", () => {
const surface = processor.model.getSurface("s1");
assert.ok(surface);
assert.strictEqual(surface.id, "s1");
assert.strictEqual(surface.sendDataModel, false);
});

it("creates surface with sendDataModel enabled", () => {
processor.processMessages([
{
version: "v0.9",
createSurface: {
surfaceId: "s1",
catalogId: "test-catalog",
sendDataModel: true,
},
},
]);
const surface = processor.model.getSurface("s1");
assert.strictEqual(surface?.sendDataModel, true);
});

it("getClientDataModel filters surfaces correctly", () => {
processor.processMessages([
{
version: "v0.9",
createSurface: { surfaceId: "s1", catalogId: "test-catalog", sendDataModel: true },
},
{
version: "v0.9",
createSurface: { surfaceId: "s2", catalogId: "test-catalog", sendDataModel: false },
},
{
version: "v0.9",
updateDataModel: { surfaceId: "s1", value: { user: "Alice" } },
},
{
version: "v0.9",
updateDataModel: { surfaceId: "s2", value: { secret: "Bob" } },
},
]);

const dataModel = processor.getClientDataModel();
assert.ok(dataModel);
assert.strictEqual(dataModel.version, "v0.9");
assert.deepStrictEqual(dataModel.surfaces, {
s1: { user: "Alice" },
});
assert.strictEqual((dataModel.surfaces as any).s2, undefined);
});

it("getClientDataModel returns undefined if no surfaces have sendDataModel enabled", () => {
processor.processMessages([
{
version: "v0.9",
createSurface: { surfaceId: "s1", catalogId: "test-catalog" },
},
]);
assert.strictEqual(processor.getClientDataModel(), undefined);
});

it("updates components on correct surface", () => {
Expand Down
32 changes: 30 additions & 2 deletions renderers/web_core/src/v0_9/processing/message-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
UpdateDataModelMessage,
DeleteSurfaceMessage,
} from "../schema/server-to-client.js";
import { A2uiClientDataModel } from "../schema/client-to-server.js";
import { A2uiStateError, A2uiValidationError } from "../errors.js";

/**
Expand All @@ -52,6 +53,28 @@ export class MessageProcessor<T extends ComponentApi> {
}
}

/**
* Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled.
*/
getClientDataModel(): A2uiClientDataModel | undefined {
const surfaces: Record<string, any> = {};

for (const surface of this.model.surfacesMap.values()) {
if (surface.sendDataModel) {
surfaces[surface.id] = surface.dataModel.get("/");
}
}

if (Object.keys(surfaces).length === 0) {
return undefined;
}

return {
version: "v0.9",
surfaces,
};
}

/**
* Subscribes to surface creation events.
*/
Expand Down Expand Up @@ -114,7 +137,7 @@ export class MessageProcessor<T extends ComponentApi> {

private processCreateSurfaceMessage(message: CreateSurfaceMessage): void {
const payload = message.createSurface;
const { surfaceId, catalogId, theme } = payload;
const { surfaceId, catalogId, theme, sendDataModel } = payload;

// Find catalog
const catalog = this.catalogs.find((c) => c.id === catalogId);
Expand All @@ -126,7 +149,12 @@ export class MessageProcessor<T extends ComponentApi> {
throw new A2uiStateError(`Surface ${surfaceId} already exists.`);
}

const surface = new SurfaceModel<T>(surfaceId, catalog, theme);
const surface = new SurfaceModel<T>(
surfaceId,
catalog,
theme,
sendDataModel ?? false,
);
this.model.addSurface(surface);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ describe("ComponentContext", () => {

it("dispatches actions", async () => {
const context = new ComponentContext(mockSurface, componentId);
let actionDispatched = null;
let actionDispatched: any = null;

const subscription = mockSurface.onAction.subscribe((action: any) => {
actionDispatched = action;
});

await context.dispatchAction({ type: "test" });
await context.dispatchAction({ event: { name: "test", context: { a: 1 } } });

assert.deepStrictEqual(actionDispatched, { type: "test" });
assert.strictEqual(actionDispatched.name, "test");
assert.strictEqual(actionDispatched.sourceComponentId, componentId);
assert.deepStrictEqual(actionDispatched.context, { a: 1 });
subscription.unsubscribe();
});

Expand Down
2 changes: 1 addition & 1 deletion renderers/web_core/src/v0_9/rendering/component-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class ComponentContext {
dataModelBasePath,
surface.catalog.invoker
);
this._actionDispatcher = (action) => surface.dispatchAction(action);
this._actionDispatcher = (action) => surface.dispatchAction(action, this.componentModel.id);
}

private _actionDispatcher: (action: any) => Promise<void>;
Expand Down
90 changes: 90 additions & 0 deletions renderers/web_core/src/v0_9/schema/client-to-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, it } from "node:test";
import * as assert from "node:assert";
import { A2uiClientMessageSchema, A2uiClientDataModelSchema } from "./client-to-server.js";

describe("Client-to-Server Schema Verification", () => {
it("validates a valid action message", () => {
const validAction = {
version: "v0.9",
action: {
name: "submit",
surfaceId: "s1",
sourceComponentId: "c1",
timestamp: new Date().toISOString(),
context: { foo: "bar" },
},
};
const result = A2uiClientMessageSchema.safeParse(validAction);
assert.ok(result.success, result.success ? "" : result.error.message);
});

it("validates a valid error message (validation failed)", () => {
const validError = {
version: "v0.9",
error: {
code: "VALIDATION_FAILED",
surfaceId: "s1",
path: "/components/0/text",
message: "Too short",
},
};
const result = A2uiClientMessageSchema.safeParse(validError);
assert.ok(result.success, result.success ? "" : result.error.message);
});

it("validates a valid error message (generic)", () => {
const validError = {
version: "v0.9",
error: {
code: "INTERNAL_ERROR",
message: "Something went wrong",
surfaceId: "s1",
},
};
const result = A2uiClientMessageSchema.safeParse(validError);
assert.ok(result.success, result.success ? "" : result.error.message);
});

it("validates a valid data model message", () => {
const validDataModel = {
version: "v0.9",
surfaces: {
s1: { user: "Alice" },
s2: { cart: [] },
},
};
const result = A2uiClientDataModelSchema.safeParse(validDataModel);
assert.ok(result.success, result.success ? "" : result.error.message);
});

it("fails on invalid version", () => {
const invalidAction = {
version: "v0.8",
action: {
name: "submit",
surfaceId: "s1",
sourceComponentId: "c1",
timestamp: new Date().toISOString(),
context: {},
},
};
const result = A2uiClientMessageSchema.safeParse(invalidAction);
assert.strictEqual(result.success, false);
});
});
82 changes: 82 additions & 0 deletions renderers/web_core/src/v0_9/schema/client-to-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { z } from "zod";

/**
* Reports a user-initiated action from a component.
* Matches 'action' in specification/v0_9/json/client_to_server.json.
*/
export const A2uiClientActionSchema = z.object({
name: z.string().describe("The name of the action, taken from the component's action.event.name property."),
surfaceId: z.string().describe("The id of the surface where the event originated."),
sourceComponentId: z.string().describe("The id of the component that triggered the event."),
timestamp: z.string().datetime().describe("An ISO 8601 timestamp of when the event occurred."),
context: z.record(z.any()).describe("A JSON object containing the key-value pairs from the component's action.event.context, after resolving all data bindings."),
}).strict();

/**
* Reports a client-side validation failure.
*/
export const A2uiValidationErrorSchema = z.object({
code: z.literal("VALIDATION_FAILED"),
surfaceId: z.string().describe("The id of the surface where the error occurred."),
path: z.string().describe("The JSON pointer to the field that failed validation (e.g. '/components/0/text')."),
message: z.string().describe("A short one or two sentence description of why validation failed."),
}).strict();

/**
* Reports a generic client-side error.
*/
export const A2uiGenericErrorSchema = z.object({
code: z.string().refine(c => c !== "VALIDATION_FAILED"),
message: z.string().describe("A short one or two sentence description of why the error occurred."),
surfaceId: z.string().describe("The id of the surface where the error occurred."),
}).passthrough();

/**
* Reports a client-side error.
* Matches 'error' in specification/v0_9/json/client_to_server.json.
*/
export const A2uiClientErrorSchema = z.union([
A2uiValidationErrorSchema,
A2uiGenericErrorSchema,
]);

/**
* A message sent from the A2UI client to the server.
* Matches specification/v0_9/json/client_to_server.json.
*/
export const A2uiClientMessageSchema = z.object({
version: z.literal("v0.9"),
}).and(z.union([
z.object({ action: A2uiClientActionSchema }),
z.object({ error: A2uiClientErrorSchema }),
]));

/**
* Schema for the client data model synchronization.
* Matches specification/v0_9/json/client_data_model.json.
*/
export const A2uiClientDataModelSchema = z.object({
version: z.literal("v0.9"),
surfaces: z.record(z.object({}).passthrough()).describe("A map of surface IDs to their current data models."),
}).strict();

export type A2uiClientAction = z.infer<typeof A2uiClientActionSchema>;
export type A2uiClientError = z.infer<typeof A2uiClientErrorSchema>;
export type A2uiClientMessage = z.infer<typeof A2uiClientMessageSchema>;
export type A2uiClientDataModel = z.infer<typeof A2uiClientDataModelSchema>;
1 change: 1 addition & 0 deletions renderers/web_core/src/v0_9/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@

export * from "./common-types.js";
export * from "./server-to-client.js";
export * from "./client-to-server.js";
8 changes: 5 additions & 3 deletions renderers/web_core/src/v0_9/state/surface-group-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ describe("SurfaceGroupModel", () => {
const surface = new SurfaceModel("s1", catalog, {});
model.addSurface(surface);

await surface.dispatchAction({ type: "test" });
assert.deepStrictEqual(receivedAction, { type: "test" });
await surface.dispatchAction({ event: { name: "test" } }, "c1");
assert.strictEqual(receivedAction.name, "test");
assert.strictEqual(receivedAction.surfaceId, "s1");
assert.strictEqual(receivedAction.sourceComponentId, "c1");
});

it("stops propagating actions after deletion", async () => {
Expand All @@ -96,7 +98,7 @@ describe("SurfaceGroupModel", () => {
model.addSurface(surface);
model.deleteSurface("s1");

await surface.dispatchAction({ type: "test" });
await surface.dispatchAction({ event: { name: "test" } }, "c1");
assert.strictEqual(callCount, 0);
});

Expand Down
26 changes: 22 additions & 4 deletions renderers/web_core/src/v0_9/state/surface-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,28 @@ describe("SurfaceModel", () => {
assert.ok(surface.componentsModel.get("c1"));
});

it("dispatches actions", async () => {
await surface.dispatchAction({ type: "click" });
it("dispatches actions with metadata", async () => {
await surface.dispatchAction(
{ event: { name: "click", context: { foo: "bar" } } },
"comp-1"
);
assert.strictEqual(actions.length, 1);
const action = actions[0];
assert.strictEqual(action.name, "click");
assert.strictEqual(action.surfaceId, "surface-1");
assert.strictEqual(action.sourceComponentId, "comp-1");
assert.deepStrictEqual(action.context, { foo: "bar" });
assert.ok(action.timestamp);
assert.doesNotThrow(() => new Date(action.timestamp));
});

it("dispatches actions with default context", async () => {
await surface.dispatchAction(
{ event: { name: "click" } },
"comp-1"
);
assert.strictEqual(actions.length, 1);
assert.strictEqual(actions[0].type, "click");
assert.deepStrictEqual(actions[0].context, {});
});

it("creates a component context", () => {
Expand All @@ -73,7 +91,7 @@ describe("SurfaceModel", () => {

// After dispose, no more actions should be emitted.
// The EventEmitter.dispose method clears all listeners.
surface.dispatchAction({ type: "click" });
surface.dispatchAction({ event: { name: "click" } }, "c1");
assert.strictEqual(
actionReceived,
false,
Expand Down
Loading
Loading