diff --git a/shell/e2e/samples.ts b/shell/e2e/samples.ts new file mode 100644 index 00000000..2f5f3739 --- /dev/null +++ b/shell/e2e/samples.ts @@ -0,0 +1,310 @@ +/** + * 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 + * + * http://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. + */ + +/** + * Compressed A2UI corresponding to the EV_CHARGE_CONTROL_A2UI. + */ +export const ELECTRIC_CAR_CHARGING_UI = + 'a2ui=d1.vVhLj9s2EL7vrxgQ7c3wQ7HbRLfGKZA9FAWyRXIIAoGWaIuJRArkyM428H8vKNGORMm2JGt7szUPfjPzcTjk5weAHw8AAGTPlOZSEB_Ifj59Qybl51AxiuwpV1saMuJbbQCiy0-PkbFg-yCMqdpxsbOGxpQiTeSu1IgRM-3PZtTL-VSq3UxnLORbHlLkUsz28-DNbEM1DwNrNv2qpSCFr-MDwHFyE2qeRRTZWqaZFEyg7om2avfZfoWzh0KHF-ZKSjzb1W2NeC2TPBWOQsyTSDFRc11IYkYjpmraAATpRgehFEi5YIpUhF_Ov4-TayBb_NZhfpCHuvRrrpFvn41MZzRkbxkeGHMCoQnfFYkPmcCG_xtRBsgxYW6sVsbDU7mHBNriuh7uP-y7UzQ0X3wgf36EtWEDg7UUqGRSV9tTxWnpI_ZIP1BFTJcxPTbEgqZmkxEutrLrWg5XrqSAbrSTAvPFLdaP2j-jVaTWMDtXigmEJ6SYa6eOp_oX_CkUAqQbUtM5Tjqu9J5rlOr5yhJxqdGyxgAOVfCOta-ty5CqyCU8DU3LC5Q8DOH7JccO2KbYKY5xUNDGWPRf-2w6VsI2FJGp5yBhe5YE6G7Xwrbs2YGiyFo1Ir7nZt8tXIEBrodm_Aayjn2mTnaSUYyN_iwsN9WstkoV5vFiO1p0rduNzI0UgWVHqned4G9k9Nw1gPbC1nG_K3Wc8-o710WupOL_mh6Z9OF6SZk7ztA_lMxF20a82DMC5GnjiCwEmTwMGwXa3Y5UdOM3UCylXJhxqkvhQ5qZBtinEDb4F8DfyOpYsCtdfhiB-k1YGxSBRpkFium8ySAjVmyrmI4HtcAr3usRvc0RG0PP6eg5u2m2oEqiM8VT6hz-Np3NgrJ9uXBjljiNUih3u4StT_P-xVnh2DcVL9VGLXESunHOgR4AT6W-o07WxfVSRWxL8wRHKpVd0o6Xo1TqchQdbwcf3E1zY_CvjKWjXRGtz4RrHHRDqtoPBuUU82z6WFujbd49M9wqDSH1Rf-dx96TB44s7Tv3ttqOXV4lD83BtSr07il-0_sYzwG3ozJPMpeCCuV9hG74vq8DF-7GnlxrsY6H1U3dqFhLsv3_XGGCqZ176f9Z7VzR-gDWnzEtK9xXB-vwBebHRtgjEr0lkz1gP1Rz3-tt9h1F-peMWNLvaXZPk5zVwiF2WHJirF_Vjb_Xq1_t655zLlQuq6Zj2zWBIryXKYOnPGOqQOKO4M6FxweyXEHKhQaUsM0T5_mwvF_4QH6fLuHbJ5iBt5x_bBuY7MTnA3lCmUFzYq2U6MQOZy859S4amg_k7xDBW07Am3u_NR5VTCPxgfyy8KbLuSu19C6CnHrw7VMMNIpY44g_M8oH8iqGxSq9yK1rGOc3ML6erq5AfOVNF90gejEs5oMgLla30riaLq5gXK2m824YlzHMnTSef5_a3vHnFnz48h8'; + +/** + * A2UI interface to display the charting status and history for an EV. + */ +export const EV_CHARGE_CONTROL_A2UI = `[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "ev_charging", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "ev_charging", + "components": [ + { + "id": "root", + "component": "Column", + "children": [ + "header", + "tabs_container" + ] + }, + { + "id": "header", + "component": "Row", + "justify": "spaceBetween", + "align": "center", + "children": [ + "header_title", + "header_icon" + ] + }, + { + "id": "header_title", + "component": "Text", + "text": "EV Charge Control", + "variant": "h2" + }, + { + "id": "header_icon", + "component": "Icon", + "name": "info" + }, + { + "id": "tabs_container", + "component": "Tabs", + "tabs": [ + { + "title": "Current Status", + "child": "status_tab" + }, + { + "title": "History", + "child": "history_tab" + } + ] + }, + { + "id": "status_tab", + "component": "Column", + "children": [ + "status_card", + "action_row" + ] + }, + { + "id": "status_card", + "component": "Card", + "child": "status_card_content" + }, + { + "id": "status_card_content", + "component": "Column", + "children": [ + "battery_level_text", + "charging_rate_text", + "divider_1", + "stats_row" + ] + }, + { + "id": "battery_level_text", + "component": "Text", + "text": { + "path": "/current/battery_level" + }, + "variant": "h1" + }, + { + "id": "charging_rate_text", + "component": "Text", + "text": { + "path": "/current/status_msg" + }, + "variant": "body" + }, + { + "id": "divider_1", + "component": "Divider", + "axis": "horizontal" + }, + { + "id": "stats_row", + "component": "Row", + "justify": "spaceAround", + "children": [ + "stat_time", + "stat_power" + ] + }, + { + "id": "stat_time", + "component": "Text", + "text": { + "path": "/current/time_remaining" + }, + "variant": "caption" + }, + { + "id": "stat_power", + "component": "Text", + "text": { + "path": "/current/power" + }, + "variant": "caption" + }, + { + "id": "action_row", + "component": "Row", + "justify": "center", + "children": [ + "btn_stop_resume", + "btn_refresh" + ] + }, + { + "id": "btn_stop_resume", + "component": "Button", + "child": "btn_stop_text", + "variant": "primary", + "action": { + "event": { + "name": "toggleCharging" + } + } + }, + { + "id": "btn_stop_text", + "component": "Text", + "text": { + "path": "/current/action_label" + } + }, + { + "id": "btn_refresh", + "component": "Button", + "child": "btn_refresh_text", + "variant": "default", + "action": { + "event": { + "name": "refreshStatus" + } + } + }, + { + "id": "btn_refresh_text", + "component": "Text", + "text": "Refresh" + }, + { + "id": "history_tab", + "component": "Column", + "children": [ + "history_list" + ] + }, + { + "id": "history_list", + "component": "Column", + "children": { + "componentId": "history_card", + "path": "/history" + } + }, + { + "id": "history_card", + "component": "Card", + "child": "history_item_content" + }, + { + "id": "history_item_content", + "component": "Column", + "children": [ + "history_row_1", + "history_row_2" + ] + }, + { + "id": "history_row_1", + "component": "Row", + "justify": "spaceBetween", + "children": [ + "history_date", + "history_cost" + ] + }, + { + "id": "history_date", + "component": "Text", + "text": { + "path": "date" + }, + "variant": "body" + }, + { + "id": "history_cost", + "component": "Text", + "text": { + "path": "cost" + }, + "variant": "body" + }, + { + "id": "history_row_2", + "component": "Row", + "justify": "spaceBetween", + "children": [ + "history_energy", + "history_duration" + ] + }, + { + "id": "history_energy", + "component": "Text", + "text": { + "path": "energy" + }, + "variant": "caption" + }, + { + "id": "history_duration", + "component": "Text", + "text": { + "path": "duration" + }, + "variant": "caption" + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "ev_charging", + "value": { + "current": { + "battery_level": "85% Charged", + "status_msg": "Charging at Home Supercharger", + "time_remaining": "45 mins to full", + "power": "7.4 kW / 240V", + "action_label": "Stop Charging" + }, + "history": [ + { + "date": "Oct 24, 2026", + "cost": "$12.40", + "energy": "45.2 kWh added", + "duration": "3h 15m" + }, + { + "date": "Oct 20, 2026", + "cost": "$8.50", + "energy": "32.1 kWh added", + "duration": "2h 10m" + }, + { + "date": "Oct 15, 2026", + "cost": "$15.10", + "energy": "55.0 kWh added", + "duration": "4h 05m" + } + ] + } + } + } +]`; diff --git a/shell/e2e/user-journey.e2e.ts b/shell/e2e/user-journey.e2e.ts index b6467024..e7453185 100644 --- a/shell/e2e/user-journey.e2e.ts +++ b/shell/e2e/user-journey.e2e.ts @@ -16,6 +16,7 @@ import {test, expect} from '@playwright/test'; import {WindowWithMonaco} from './types'; +import {ELECTRIC_CAR_CHARGING_UI, EV_CHARGE_CONTROL_A2UI} from './samples'; test.beforeEach(async ({page}) => { page.on('pageerror', err => { @@ -119,7 +120,8 @@ test.describe('E2E Workspace User Journey', () => { return (monaco?.editor?.getModels()?.length ?? 0) > 0; }); - await page.waitForTimeout(500); + // Wait for initial layout snapshot in chat history + await expect(page.locator('.chat-history-log .bubble-layout')).toHaveCount(1); // Set invalid JSON await page.evaluate(() => { @@ -136,4 +138,64 @@ test.describe('E2E Workspace User Journey', () => { await expect(page.locator('.chat-history-log .bubble-text')).toHaveCount(0); await expect(page.locator('.chat-history-log .bubble-layout')).toHaveCount(1); }); + + test('verifies sharing design URL copies a2ui query param and loads payload on new page', async ({ + context, + page, + }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await expect(page.locator('.header-title')).toContainText('A2UI Composer'); + + await page.waitForFunction(() => { + const monaco = (window as unknown as WindowWithMonaco).monaco; + return (monaco?.editor?.getModels()?.length ?? 0) > 0; + }); + + await page.evaluate(payload => { + const model = (window as unknown as WindowWithMonaco).monaco?.editor?.getModels()?.[0]; + if (model) { + model.setValue(payload); + } + }, EV_CHARGE_CONTROL_A2UI); + + await expect(page.locator('.header-title')).toContainText('A2UI Composer'); + const shareButton = page.getByRole('button', {name: 'Share design'}); + await expect(shareButton).toBeVisible(); + await shareButton.click(); + + const snackbarLocator = page.locator('.mat-mdc-snack-bar-label').first(); + await expect(snackbarLocator).toContainText('Shareable link copied to clipboard'); + + const shareUrl = await page.evaluate(() => navigator.clipboard.readText()); + expect(shareUrl).toContain(ELECTRIC_CAR_CHARGING_UI); + + // Open recipient page with copied link + const recipientPage = await context.newPage(); + await recipientPage.goto(shareUrl); + await recipientPage.waitForLoadState('load'); + + const editorLocator = recipientPage + .locator('a2ui-composer-monaco-editor .monaco-editor') + .first(); + await expect(editorLocator).toBeVisible(); + + const previewIframe = recipientPage + .frameLocator('.preview-frame iframe, iframe.preview-iframe, iframe') + .first(); + await expect(previewIframe.locator('body')).toBeVisible({timeout: 10000}); + await expect(previewIframe.locator('a2ui-v09-surface').first()).toBeVisible({timeout: 10000}); + }); + + test('displays informative error snackbar when navigating with truncated or corrupted shared design URL', async ({ + page, + }) => { + await page.goto('/?a2ui=d1.corrupted_truncated_payload_data!!!'); + await page.waitForLoadState('load'); + + const snackbarLocator = page.locator('.mat-mdc-snack-bar-label').first(); + await expect(snackbarLocator).toBeVisible({timeout: 10000}); + await expect(snackbarLocator).toContainText('Unable to load shared design'); + await expect(snackbarLocator).toContainText('truncated or corrupted'); + }); }); diff --git a/shell/src/app/app.routes.spec.ts b/shell/src/app/app.routes.spec.ts index 99f87023..2eb7f9fb 100644 --- a/shell/src/app/app.routes.spec.ts +++ b/shell/src/app/app.routes.spec.ts @@ -43,6 +43,8 @@ class MockStartupResolution { readonly apiKeys = signal>({}); readonly selectedRendererId = signal(null); readonly activeRenderer = signal(null); + readonly sharedA2uiPayload = signal(null); + readonly sharedA2uiError = signal(null); readonly isLoading = signal(false); readonly error = signal(null); setSelectedRendererId = vi.fn(); diff --git a/shell/src/app/chat/state-sync/state-sync.spec.ts b/shell/src/app/chat/state-sync/state-sync.spec.ts index 1eba4b43..6b7394f7 100644 --- a/shell/src/app/chat/state-sync/state-sync.spec.ts +++ b/shell/src/app/chat/state-sync/state-sync.spec.ts @@ -23,6 +23,7 @@ import {MessageRole} from '../llm-client/llm-client'; import {CAR_BOOKING} from '../chat-service/initial-draft'; import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; import {Catalog} from '../../storage/models/catalog-storage.model'; +import {StartupResolution} from '../../shell/startup-resolution/startup-resolution'; import {signal} from '@angular/core'; class MockChatState { @@ -60,20 +61,29 @@ class MockCatalogManagement { readonly activeCatalog = signal(null); } +class MockStartupResolution { + readonly sharedA2uiPayload = signal(null); + readonly activeRenderer = signal<{samplePayload?: string} | null>(null); +} + describe('StateSync Autosave Draft Integrations', () => { let service: StateSync; let chatStateMock: MockChatState; let catalogManagementMock: MockCatalogManagement; + let startupResolutionMock: MockStartupResolution; beforeEach(() => { TestBed.resetTestingModule(); vi.useFakeTimers(); + startupResolutionMock = new MockStartupResolution(); + TestBed.configureTestingModule({ providers: [ StateSync, {provide: ChatState, useClass: MockChatState}, {provide: CatalogManagement, useClass: MockCatalogManagement}, + {provide: StartupResolution, useValue: startupResolutionMock}, ], }); @@ -97,10 +107,20 @@ describe('StateSync Autosave Draft Integrations', () => { vi.restoreAllMocks(); }); - it('initializes activeDraft with CAR_BOOKING constant', () => { + it('initializes activeDraft with CAR_BOOKING constant when sharedA2uiPayload is null', () => { expect(service.activeDraft()).toBe(CAR_BOOKING); }); + it('prepopulates activeDraft with sharedA2uiPayload when present on StartupResolution', () => { + const customSharedJson = '[{"version":"v0.9","createSurface":{"surfaceId":"shared-surface"}}]'; + startupResolutionMock.sharedA2uiPayload.set(customSharedJson); + + // Create fresh instance after setting shared payload + const sharedService = TestBed.inject(StateSync); + sharedService.flushDraft(); + expect(sharedService.activeDraft()).toBe(customSharedJson); + }); + it('updates draft in-memory synchronously and ignores initial setup debouncer', () => { service.updateDraft('[{"version": "v0.9"}]'); expect(service.hydrateActiveDraft()).toBe('[{"version": "v0.9"}]'); diff --git a/shell/src/app/chat/state-sync/state-sync.ts b/shell/src/app/chat/state-sync/state-sync.ts index 91d0f048..f3ff8179 100644 --- a/shell/src/app/chat/state-sync/state-sync.ts +++ b/shell/src/app/chat/state-sync/state-sync.ts @@ -133,6 +133,10 @@ export class StateSync { } private getInitialDraft(catalogId: string): string { + const sharedPayload = this.startupResolution?.sharedA2uiPayload(); + if (sharedPayload) { + return sharedPayload; + } const activeRenderer = this.startupResolution?.activeRenderer(); if (activeRenderer?.samplePayload) { return activeRenderer.samplePayload; diff --git a/shell/src/app/preview/raw/raw-frame.spec.ts b/shell/src/app/preview/raw/raw-frame.spec.ts index 0e9101ca..ae511e48 100644 --- a/shell/src/app/preview/raw/raw-frame.spec.ts +++ b/shell/src/app/preview/raw/raw-frame.spec.ts @@ -687,6 +687,19 @@ describe('RawFrame JSON Source Editor View', () => { expect(sendRenderA2UIMock).toHaveBeenCalledTimes(1); }); + it('re-dispatches layout payload when receiving A2UI_CATALOG event from messageStream$', async () => { + await setup(false); + sendRenderA2UIMock.mockClear(); + + messageStreamSubject.next({ + type: PreviewBridgeMessageType.A2UI_CATALOG, + origin: 'http://test', + timestamp: Date.now(), + }); + + expect(sendRenderA2UIMock).toHaveBeenCalledTimes(1); + }); + it('tracks isDestroyed state on component destruction', async () => { const {fixture, component} = await setup(false); expect(component['isDestroyed']).toBe(false); diff --git a/shell/src/app/preview/raw/raw-frame.ts b/shell/src/app/preview/raw/raw-frame.ts index 14a5570c..c6875bba 100644 --- a/shell/src/app/preview/raw/raw-frame.ts +++ b/shell/src/app/preview/raw/raw-frame.ts @@ -91,7 +91,11 @@ export class RawFrame { this.hostCommunication.messageStream$ .pipe( - filter(envelope => envelope?.type === PreviewBridgeMessageType.RENDERER_READY), + filter( + envelope => + envelope?.type === PreviewBridgeMessageType.RENDERER_READY || + envelope?.type === PreviewBridgeMessageType.A2UI_CATALOG, + ), takeUntilDestroyed(), ) .subscribe(() => { @@ -102,9 +106,9 @@ export class RawFrame { } } catch (err) { if (err instanceof SyntaxError) { - console.warn('Syntax error on RENDERER_READY:', err); + console.warn(`Syntax error in JSON:`, err); } else { - console.error('Unexpected error on RENDERER_READY:', err); + console.error('Unexpected error sending A2UI to renderer:', err); } } }); diff --git a/shell/src/app/shell/composer-shell/composer-shell.ng.html b/shell/src/app/shell/composer-shell/composer-shell.ng.html index c6c1892b..05ae8e6d 100644 --- a/shell/src/app/shell/composer-shell/composer-shell.ng.html +++ b/shell/src/app/shell/composer-shell/composer-shell.ng.html @@ -32,6 +32,16 @@ +