From 8f5b1261b2091736fddaa30b10ee124ce21a2443 Mon Sep 17 00:00:00 2001 From: Jay Gindin Date: Mon, 22 Jun 2026 17:30:53 -0400 Subject: [PATCH] bundle ng-basic-catalog as default out-of-the-box renderer - Update shell build options in angular.json to copy basic catalog assets from the locally resolved node_modules folder. - Enforce topological order and exclude root workspace in root package.json scripts (build, test, prettier, clean, lint) to prevent execution loops. - Reorder build steps in deploy workflows to build ng-basic-catalog before the shell. - Set defaultRendererUrl in config.json to relative path. - Add basic catalog workspace dependency to shell package.json for build ordering, and build catalog workspace on shell start. - Configure ng-basic-catalog build with relative base href to support embedded frame loading. - Resolve preview-bridge catalog endpoints relatively to support embedded paths. - Relax settings page pattern validator and QueryParser URL parser to support relative paths. - Add E2E and unit tests verifying relative URL resolution, validation, and initial workspace loading. - Restrict CAR_BOOKING pre-population to when the renderer app supports the basic catalog. - Update StateSync to dynamically detect catalog changes and reset the active draft if incompatible. - Add unit tests for StateSync to cover dynamic draft resets on catalog change and verify parsing logic edge cases. TAG=agy BUG= --- .github/workflows/deploy_pages.yml | 2 +- .github/workflows/deploy_pr_preview.yml | 2 +- bridge/src/preview-bridge.spec.ts | 30 +-- bridge/src/preview-bridge.ts | 8 +- package.json | 10 +- samples/ng-basic-catalog/angular.json | 1 + .../ng-basic-catalog/src/assets/catalog.json | 2 +- samples/react-basic-catalog/src/main.spec.tsx | 4 +- shell/angular.json | 14 +- shell/e2e/settings-and-configuration.e2e.ts | 16 ++ shell/package.json | 6 +- .../app/chat/state-sync/state-sync.spec.ts | 198 +++++++++++++++++- shell/src/app/chat/state-sync/state-sync.ts | 89 +++++++- .../settings/settings-view/settings.ng.html | 4 +- .../settings/settings-view/settings.spec.ts | 46 +++- .../app/settings/settings-view/settings.ts | 6 +- .../shell/query-parser/query-parser.spec.ts | 16 ++ .../app/shell/query-parser/query-parser.ts | 5 +- shell/src/config.json | 2 +- yarn.lock | 3 +- 20 files changed, 422 insertions(+), 42 deletions(-) diff --git a/.github/workflows/deploy_pages.yml b/.github/workflows/deploy_pages.yml index 23b9d56e..0cba74f8 100644 --- a/.github/workflows/deploy_pages.yml +++ b/.github/workflows/deploy_pages.yml @@ -55,8 +55,8 @@ jobs: - name: Build Production Shell & Catalogs with Correct Base URLs run: | - yarn workspace a2ui-composer-shell run build --base-href=/composer/ yarn workspace ng-basic-catalog run build --base-href=/composer/samples/ng-basic-catalog/ + yarn workspace a2ui-composer-shell run build --base-href=/composer/ yarn workspace lit-basic-catalog run build --base=/composer/samples/lit-basic-catalog/ yarn workspace react-basic-catalog run build --base=/composer/samples/react-basic-catalog/ diff --git a/.github/workflows/deploy_pr_preview.yml b/.github/workflows/deploy_pr_preview.yml index d989e298..51828cb1 100644 --- a/.github/workflows/deploy_pr_preview.yml +++ b/.github/workflows/deploy_pr_preview.yml @@ -58,8 +58,8 @@ jobs: env: PR_NUM: ${{ github.event.pull_request.number }} run: | - yarn workspace a2ui-composer-shell run build --base-href=/composer/pr/${PR_NUM}/ yarn workspace ng-basic-catalog run build --base-href=/composer/pr/${PR_NUM}/samples/ng-basic-catalog/ + yarn workspace a2ui-composer-shell run build --base-href=/composer/pr/${PR_NUM}/ yarn workspace lit-basic-catalog run build --base=/composer/pr/${PR_NUM}/samples/lit-basic-catalog/ yarn workspace react-basic-catalog run build --base=/composer/pr/${PR_NUM}/samples/react-basic-catalog/ diff --git a/bridge/src/preview-bridge.spec.ts b/bridge/src/preview-bridge.spec.ts index 20d9f289..5a99b72f 100644 --- a/bridge/src/preview-bridge.spec.ts +++ b/bridge/src/preview-bridge.spec.ts @@ -202,7 +202,7 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenCalledWith('/catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('catalog', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, @@ -236,8 +236,8 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenNthCalledWith(1, '/catalog', expect.any(Object)); - expect(window.fetch).toHaveBeenNthCalledWith(2, '/catalog.json', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(1, 'catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(2, 'catalog.json', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, @@ -270,15 +270,15 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenNthCalledWith(1, '/catalog', expect.any(Object)); - expect(window.fetch).toHaveBeenNthCalledWith(2, '/catalog.json', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(1, 'catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(2, 'catalog.json', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, payload: { error: { message: - 'Catalog fetch returned HTML (SPA fallback) for both /catalog and /catalog.json. Ensure the catalog JSON is correctly hosted and served.', + 'Catalog fetch returned HTML (SPA fallback) for both catalog and catalog.json. Ensure the catalog JSON is correctly hosted and served.', }, }, }, @@ -310,8 +310,8 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenNthCalledWith(1, '/catalog', expect.any(Object)); - expect(window.fetch).toHaveBeenNthCalledWith(2, '/catalog.json', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(1, 'catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(2, 'catalog.json', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, @@ -502,7 +502,7 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenCalledWith('/catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('catalog', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, @@ -536,8 +536,8 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenNthCalledWith(1, '/catalog', expect.any(Object)); - expect(window.fetch).toHaveBeenNthCalledWith(2, '/catalog.json', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(1, 'catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(2, 'catalog.json', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, @@ -1443,15 +1443,15 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenNthCalledWith(1, '/catalog', expect.any(Object)); - expect(window.fetch).toHaveBeenNthCalledWith(2, '/catalog.json', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(1, 'catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenNthCalledWith(2, 'catalog.json', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, payload: { error: { message: - 'Catalog fetch returned HTML and fallback to /catalog.json failed with status: 404', + 'Catalog fetch returned HTML and fallback to catalog.json failed with status: 404', }, }, }, @@ -1532,7 +1532,7 @@ describe('PreviewBridge Core API Runtime', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.fetch).toHaveBeenCalledWith('/catalog', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('catalog', expect.any(Object)); expect(spy).toHaveBeenCalledWith( { type: PreviewBridgeMessageType.A2UI_CATALOG, diff --git a/bridge/src/preview-bridge.ts b/bridge/src/preview-bridge.ts index 14efeff9..de101c76 100644 --- a/bridge/src/preview-bridge.ts +++ b/bridge/src/preview-bridge.ts @@ -664,7 +664,7 @@ export class PreviewBridge { if (typeof window === 'undefined' || !window.fetch) return null; - let res = await this.fetchWithTimeout('/catalog'); + let res = await this.fetchWithTimeout('catalog'); if (!res.ok) { throw new Error(`Catalog fetch failed with status: ${res.status}`); } @@ -674,10 +674,10 @@ export class PreviewBridge { const trimmedLower = rawText.trim().toLowerCase(); const isHtml = trimmedLower.startsWith(' { await new Promise(resolve => setTimeout(resolve, 50)); }); - // Verify fetch was triggered on standard path '/catalog' - expect(fetchSpy).toHaveBeenCalledWith('/catalog', expect.any(Object)); + // Verify fetch was triggered on standard path 'catalog' + expect(fetchSpy).toHaveBeenCalledWith('catalog', expect.any(Object)); // Verify bridge posted back A2UI_CATALOG with resolved payload expect(postSpy).toHaveBeenCalledWith( diff --git a/shell/angular.json b/shell/angular.json index 5c8ffa57..1504f1cb 100644 --- a/shell/angular.json +++ b/shell/angular.json @@ -21,9 +21,19 @@ "polyfills": [], "tsConfig": "tsconfig.json", "inlineStyleLanguage": "scss", - "assets": ["src/favicon.svg", "src/assets", "src/config.json"], + "assets": [ + "src/favicon.svg", + "src/assets", + "src/config.json", + { + "glob": "**/*", + "input": "node_modules/ng-basic-catalog/dist/ng-basic-catalog/browser", + "output": "samples/ng-basic-catalog" + } + ], "styles": ["@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss"], - "scripts": [] + "scripts": [], + "preserveSymlinks": true }, "configurations": { "production": { diff --git a/shell/e2e/settings-and-configuration.e2e.ts b/shell/e2e/settings-and-configuration.e2e.ts index f188fd3c..a54e6c34 100644 --- a/shell/e2e/settings-and-configuration.e2e.ts +++ b/shell/e2e/settings-and-configuration.e2e.ts @@ -60,6 +60,22 @@ test.describe('Settings and Client Configuration', () => { await page.goto('/'); await expect(page.locator('.workspace-container')).toBeVisible(); }); + + test('persists configuration successfully with default relative renderer URL and loads workspace with pre-populated draft', async ({ + page, + }) => { + const apiKeyInput = page.getByLabel('Gemini API Key'); + await apiKeyInput.fill('test-api-key'); + + const saveBtn = page.getByRole('button', {name: 'Save Settings'}); + await Promise.all([page.waitForURL(url => url.pathname === '/'), saveBtn.click()]); + await page.waitForLoadState('load'); + + await expect(page.locator('.workspace-container')).toBeVisible(); + + const iframe = page.frameLocator('iframe.preview-iframe'); + await expect(iframe.getByRole('button', {name: 'Search Cars'})).toBeVisible(); + }); }); test.describe('Enterprise & Environment Constraints', () => { diff --git a/shell/package.json b/shell/package.json index 00184327..3c0f953c 100644 --- a/shell/package.json +++ b/shell/package.json @@ -2,9 +2,12 @@ "name": "a2ui-composer-shell", "version": "0.1.0", "private": true, + "installConfig": { + "hoistingLimits": "workspaces" + }, "type": "module", "scripts": { - "start": "ng serve", + "start": "yarn workspace ng-basic-catalog build && ng serve", "build": "yarn lint && ng build", "test": "vitest run --coverage", "e2e": "playwright test", @@ -42,6 +45,7 @@ "angular-eslint": "22.0.0", "eslint": "10.5.0", "jsdom": "29.1.1", + "ng-basic-catalog": "0.1.0", "prettier": "3.8.4", "typescript": "6.0.3", "typescript-eslint": "8.61.1", 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 9abad52a..20222581 100644 --- a/shell/src/app/chat/state-sync/state-sync.spec.ts +++ b/shell/src/app/chat/state-sync/state-sync.spec.ts @@ -21,6 +21,9 @@ import {ChatState, LlmLogEntry, LlmLogType} from '../chat-state/chat-state'; import {LlmMessage} from '../llm-client/llm-client'; 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 {signal} from '@angular/core'; class MockChatState { private readonly _chatHistory: LlmMessage[] = []; @@ -53,20 +56,35 @@ class MockChatState { }); } +class MockCatalogManagement { + readonly activeCatalog = signal(null); +} + describe('StateSync Autosave Draft Integrations', () => { let service: StateSync; let chatStateMock: MockChatState; + let catalogManagementMock: MockCatalogManagement; beforeEach(() => { TestBed.resetTestingModule(); vi.useFakeTimers(); TestBed.configureTestingModule({ - providers: [StateSync, {provide: ChatState, useClass: MockChatState}], + providers: [ + StateSync, + {provide: ChatState, useClass: MockChatState}, + {provide: CatalogManagement, useClass: MockCatalogManagement}, + ], }); service = TestBed.inject(StateSync); chatStateMock = TestBed.inject(ChatState) as unknown as MockChatState; + catalogManagementMock = TestBed.inject(CatalogManagement) as unknown as MockCatalogManagement; + + // Initialize catalog mock with the basic catalog ID + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/basic_catalog.json', + }); // Eagerly flush Angular change detection effect bindings instantly upon // setup to prevent microtask leaks @@ -446,6 +464,7 @@ describe('StateSync Autosave Draft Integrations', () => { expect(syncedContent).toContain('"updateComponents"'); expect(syncedContent).toContain('"primitive-element"'); }); + it('returns empty string when all layout lines are sanitized to null', () => { service.updateDraft('{"registerMockRules": true}'); TestBed.flushEffects(); @@ -473,4 +492,181 @@ describe('StateSync Autosave Draft Integrations', () => { expect(syncedContent).toContain('"components":"not-an-array"'); }); }); + + describe('Dynamic Initial Draft Pre-population', () => { + it('pre-populates with generic draft instead of CAR_BOOKING if catalog does not support basic catalog', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + StateSync, + {provide: ChatState, useClass: MockChatState}, + {provide: CatalogManagement, useClass: MockCatalogManagement}, + ], + }); + const newCatalogMock = TestBed.inject(CatalogManagement) as unknown as MockCatalogManagement; + newCatalogMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + const newService = TestBed.inject(StateSync); + TestBed.flushEffects(); + + expect(newService.activeDraft()).toContain('material_catalog.json'); + expect(newService.activeDraft()).not.toContain('Book a Car'); + }); + + it('resets to appropriate dynamic initial draft on flushDraft', () => { + // Switch activeCatalog to material_catalog + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + + service.updateDraft('{"version": "dirty"}'); + expect(service.activeDraft()).toBe('{"version": "dirty"}'); + + service.flushDraft(); + expect(service.activeDraft()).toContain('material_catalog.json'); + expect(service.activeDraft()).not.toContain('Book a Car'); + }); + + it('resets activeDraft dynamically when catalogId changes and draft is incompatible', () => { + // Start with basic catalog + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/basic_catalog.json', + }); + TestBed.flushEffects(); + expect(service.activeDraft()).toBe(CAR_BOOKING); + + // User has typed some layout (dirty) matching basic catalog + service.updateDraft( + '{"version": "v0.9", "createSurface": {"surfaceId": "sample-surface", "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", "sendDataModel": true}}\n{"version": "v0.9", "updateComponents": {"components": []}}', + ); + TestBed.flushEffects(); + + // Catalog changes to material + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + + // Expect draft to be reset to material initial draft because the old draft was basic catalog + expect(service.activeDraft()).toContain('material_catalog.json'); + expect(service.activeDraft()).not.toContain('basic_catalog.json'); + }); + + it('does not reset activeDraft if catalogId changes but draft already matches it', () => { + // Start with material catalog + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + const materialInitialDraft = service.activeDraft(); + expect(materialInitialDraft).toContain('material_catalog.json'); + + // User makes a change (dirty layout) but still on material catalog + const editedMaterialDraft = + materialInitialDraft + + '{"version": "v0.9", "updateComponents": {"components": [{"id": "foo"}]}}'; + service.updateDraft(editedMaterialDraft); + TestBed.flushEffects(); + + // Trigger a catalog change with the same catalog ID (e.g. metadata refresh) + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + title: 'New Title', // different title, same catalogId + }); + TestBed.flushEffects(); + + // Expect draft NOT to be reset, preserving user changes + expect(service.activeDraft()).toBe(editedMaterialDraft); + }); + + it('does not reset activeDraft if catalogId changes and draft is a matching single JSON object', () => { + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + + const singleObjectDraft = + '{"version": "v0.9", "createSurface": {"surfaceId": "sample-surface", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json", "sendDataModel": true}}'; + service.updateDraft(singleObjectDraft); + TestBed.flushEffects(); + + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + title: 'New Title', + }); + TestBed.flushEffects(); + + expect(service.activeDraft()).toBe(singleObjectDraft); + }); + + it('does not reset activeDraft if catalogId changes and draft is a matching JSON array', () => { + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + + const arrayDraft = + '[{"version": "v0.9", "createSurface": {"surfaceId": "sample-surface", "catalogId": "https://a2ui.org/specification/v0_9/material_catalog.json", "sendDataModel": true}}]'; + service.updateDraft(arrayDraft); + TestBed.flushEffects(); + + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + title: 'New Title', + }); + TestBed.flushEffects(); + + expect(service.activeDraft()).toBe(arrayDraft); + }); + + it('resets activeDraft if catalogId changes and draft has no catalogId', () => { + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + + service.updateDraft('{"version": "v0.9", "updateComponents": {"components": []}}'); + TestBed.flushEffects(); + + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + title: 'New Title', + }); + TestBed.flushEffects(); + + expect(service.activeDraft()).toContain('material_catalog.json'); + expect(service.activeDraft()).not.toContain('updateComponents'); + }); + + it('resets activeDraft if catalogId changes and draft is whitespace', () => { + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + }); + TestBed.flushEffects(); + + service.updateDraft(' '); + TestBed.flushEffects(); + + catalogManagementMock.activeCatalog.set({ + catalogId: 'https://a2ui.org/specification/v0_9/material_catalog.json', + title: 'New Title', + }); + TestBed.flushEffects(); + + expect(service.activeDraft()).toContain('material_catalog.json'); + }); + + it('sets activeDraft to empty string on flushDraft if activeCatalog has no catalogId', () => { + catalogManagementMock.activeCatalog.set({ + catalogId: '', + }); + TestBed.flushEffects(); + service.updateDraft('{"version": "dirty"}'); + + service.flushDraft(); + expect(service.activeDraft()).toBe(''); + }); + }); }); diff --git a/shell/src/app/chat/state-sync/state-sync.ts b/shell/src/app/chat/state-sync/state-sync.ts index 5e795cf9..df86b640 100644 --- a/shell/src/app/chat/state-sync/state-sync.ts +++ b/shell/src/app/chat/state-sync/state-sync.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import {Injectable, inject, signal, DestroyRef} from '@angular/core'; +import {Injectable, inject, signal, DestroyRef, untracked, effect} from '@angular/core'; import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop'; import {debounceTime, distinctUntilChanged, skip} from 'rxjs/operators'; import {ChatState} from '../chat-state/chat-state'; import {MessageRole} from '../llm-client/llm-client'; import {CAR_BOOKING} from '../chat-service/initial-draft'; +import {CatalogManagement} from '../../storage/catalog-management/catalog-management'; import {tryParseJsonArray} from '../../utils/json'; import {RenderA2uiItem, A2uiComponentInstance, UpdateComponentsDetails} from 'a2ui-bridge'; @@ -46,8 +47,20 @@ const MOCK_RULES_CONTAINER = 'mock_rules_container'; export class StateSync { private readonly destroyRef = inject(DestroyRef); private readonly chatState = inject(ChatState); + private readonly catalogManagement = inject(CatalogManagement); - private readonly _activeDraft = signal(CAR_BOOKING); + // A "draft" represents the volatile, unsaved in-memory JSON Lines + // payload containing the active surface setup, component hierarchy, + // and data models currently rendered on the preview canvas. + // + // We maintain separate signals for `_activeDraft` and `_draftInput` + // to prevent feedback loops when syncing with LLM chat history: + // - `_activeDraft` is the source of truth for active editor/preview + // UI bindings, updating instantly. + // - `_draftInput` is an event trigger used to debounce and sync + // user edits back to the history. LLM-initiated edits update + // `_activeDraft` directly, bypassing history sync. + private readonly _activeDraft = signal(''); /** * Volatile, read-only reactive Signal exposing the currently buffered * in-memory canvas layout string. @@ -57,6 +70,20 @@ export class StateSync { private readonly _draftInput = signal(''); constructor() { + effect(() => { + const catalog = this.catalogManagement.activeCatalog(); + if (catalog) { + const catalogId = catalog.catalogId || ''; + untracked(() => { + const currentDraft = this._activeDraft(); + const draftCatalogId = this.getCatalogIdFromDraft(currentDraft); + if (currentDraft === '' || draftCatalogId !== catalogId) { + this._activeDraft.set(this.getInitialDraft(catalogId)); + } + }); + } + }); + toObservable(this._draftInput) .pipe(skip(1), debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((val: string) => { @@ -94,7 +121,63 @@ export class StateSync { * memory to default. */ flushDraft(): void { - this._activeDraft.set(CAR_BOOKING); + const catalog = this.catalogManagement.activeCatalog(); + const catalogId = catalog?.catalogId || ''; + this._activeDraft.set(this.getInitialDraft(catalogId)); + } + + private getInitialDraft(catalogId: string): string { + if (catalogId === 'https://a2ui.org/specification/v0_9/basic_catalog.json') { + return CAR_BOOKING; + } + if (!catalogId) { + return ''; + } + const draftObj = { + version: 'v0.9', + createSurface: { + surfaceId: 'sample-surface', + catalogId, + sendDataModel: true, + }, + }; + return JSON.stringify(draftObj) + '\n'; + } + + private getCatalogIdFromDraft(draft: string): string | null { + const trimmed = draft.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item?.createSurface?.catalogId) { + return item.createSurface.catalogId; + } + } + } else if (parsed?.createSurface?.catalogId) { + return parsed.createSurface.catalogId; + } + } catch (e) { + // Might be JSON Lines + } + + const lines = trimmed.split('\n'); + for (const line of lines) { + const lineTrimmed = line.trim(); + if (!lineTrimmed) continue; + try { + const parsed = JSON.parse(lineTrimmed); + if (parsed?.createSurface?.catalogId) { + return parsed.createSurface.catalogId; + } + } catch (e) { + // ignore + } + } + return null; } /** diff --git a/shell/src/app/settings/settings-view/settings.ng.html b/shell/src/app/settings/settings-view/settings.ng.html index de4283ee..7fbaf23c 100644 --- a/shell/src/app/settings/settings-view/settings.ng.html +++ b/shell/src/app/settings/settings-view/settings.ng.html @@ -39,7 +39,9 @@

Renderer Application URL

Renderer URL is required. } @if (settingsForm.controls.rendererUrl.hasError('pattern')) { - Must be a valid HTTP or HTTPS URL. + + Must be a valid HTTP/HTTPS URL or relative path starting with "/". + } diff --git a/shell/src/app/settings/settings-view/settings.spec.ts b/shell/src/app/settings/settings-view/settings.spec.ts index 37715afe..7e824e2c 100644 --- a/shell/src/app/settings/settings-view/settings.spec.ts +++ b/shell/src/app/settings/settings-view/settings.spec.ts @@ -316,7 +316,9 @@ describe('Settings', () => { const patternErrors = await harness.getErrorsText(); expect(patternErrors.length).toBe(1); - expect(patternErrors[0]).toContain('Must be a valid HTTP or HTTPS URL'); + expect(patternErrors[0]).toContain( + 'Must be a valid HTTP/HTTPS URL or relative path starting with "/"', + ); }); class FakeAppConfigProvider extends AppConfigProvider { @@ -416,6 +418,11 @@ describe('Settings', () => { ); expect(await harness.isSlideToggleDisabled()).toBe(true); + + const initialForcedAuth = component.forceThirdPartyAuth(); + component.toggleForceThirdPartyAuth(); + expect(component.forceThirdPartyAuth()).toBe(initialForcedAuth); + expect(mockConfigProvider.setForcedAuthMode).not.toHaveBeenCalled(); }); it('reloads the application at the dynamic base path when hosted under a dynamic base href', async () => { @@ -447,4 +454,41 @@ describe('Settings', () => { expect(attr).toBe('true'); }); }); + + it.for(['/samples/ng-basic-catalog/index.html', '/renderer'])( + 'accepts relative paths starting with "/"', + async relativeUrl => { + const {fixture, component, harness} = await setupComponent(); + + await harness.setRendererUrlValue(relativeUrl); + fixture.detectChanges(); + expect(component.settingsForm.controls.rendererUrl.valid).toBe(true); + }, + ); + + it.for([ + 'samples/ng-basic-catalog/index.html', + 'renderer', + '//renderer', + '//example.com/foo/bar', + ])('rejects other relative paths', async relativeUrl => { + const {fixture, component, harness} = await setupComponent(); + + await harness.setRendererUrlValue(relativeUrl); + fixture.detectChanges(); + expect(component.settingsForm.controls.rendererUrl.valid).toBe(false); + expect(component.settingsForm.controls.rendererUrl.errors?.['pattern']).toBeTruthy(); + }); + + it('saves settings with valid relative rendererUrl', async () => { + const {component, harness} = await setupComponent(); + + await harness.setRendererUrlValue('/samples/ng-basic-catalog/index.html'); + await component.saveSettings(); + + expect(component.settingsForm.valid).toBe(true); + expect(mockConfigProvider.setRendererUrl).toHaveBeenCalledWith( + '/samples/ng-basic-catalog/index.html', + ); + }); }); diff --git a/shell/src/app/settings/settings-view/settings.ts b/shell/src/app/settings/settings-view/settings.ts index 4fcd3588..e2d71b7c 100644 --- a/shell/src/app/settings/settings-view/settings.ts +++ b/shell/src/app/settings/settings-view/settings.ts @@ -78,8 +78,12 @@ export class Settings implements OnInit { this.catalogManagement.catalogError(), ); + // Matches either absolute HTTP/HTTPS URLs (starting with http:// or https://) + // or relative paths starting with '/'. + // Note that the `\/(?!/)` means Match a forward slash (\/), but only if it is + // not immediately followed by another forward slash ((?!/))". readonly settingsForm = this.fb.group({ - rendererUrl: ['', [Validators.required, Validators.pattern(/^https?:\/\/.+/i)]], + rendererUrl: ['', [Validators.required, Validators.pattern(/^(https?:\/\/|\/(?!\/)).+/i)]], apiKey: [''], }); diff --git a/shell/src/app/shell/query-parser/query-parser.spec.ts b/shell/src/app/shell/query-parser/query-parser.spec.ts index ab023526..8c9ede45 100644 --- a/shell/src/app/shell/query-parser/query-parser.spec.ts +++ b/shell/src/app/shell/query-parser/query-parser.spec.ts @@ -64,4 +64,20 @@ describe('QueryParser', () => { expect.stringContaining('Security Violation: Prohibited credentials'), ); }); + + it('resolves relative renderer paths starting with "/" against window.location.origin', () => { + // Under Vitest/jsdom, location.origin defaults to http://localhost:3000 or similar. + const expectedPrefix = globalThis.location?.origin || 'http://localhost'; + const url = QueryParser.parseRendererUrl('?renderer=/samples/ng-basic-catalog/index.html'); + expect(url).toBe(`${expectedPrefix}/samples/ng-basic-catalog/index.html`); + }); + + it('rejects relative renderer paths that do not start with "/"', () => { + const warnSpy = vi.spyOn(console, 'warn'); + const url = QueryParser.parseRendererUrl('?renderer=samples/ng-basic-catalog/index.html'); + expect(url).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Malformed renderer parameter string encountered'), + ); + }); }); diff --git a/shell/src/app/shell/query-parser/query-parser.ts b/shell/src/app/shell/query-parser/query-parser.ts index d98ca695..1f4de595 100644 --- a/shell/src/app/shell/query-parser/query-parser.ts +++ b/shell/src/app/shell/query-parser/query-parser.ts @@ -42,9 +42,12 @@ export class QueryParser { return null; } + const baseOrigin = globalThis.location?.origin || ''; for (const uriCandidate of renderers) { try { - const validUrl = new URL(uriCandidate); + const validUrl = uriCandidate.startsWith('/') + ? new URL(uriCandidate, baseOrigin) + : new URL(uriCandidate); if (validUrl.protocol === 'http:' || validUrl.protocol === 'https:') { // 2. Prohibit keys embedded inside the inner renderer target string for (const innerKey of validUrl.searchParams.keys()) { diff --git a/shell/src/config.json b/shell/src/config.json index b8bc14fa..6ffd1f3b 100644 --- a/shell/src/config.json +++ b/shell/src/config.json @@ -1,4 +1,4 @@ { - "defaultRendererUrl": "http://localhost:3000", + "defaultRendererUrl": "/samples/ng-basic-catalog/index.html", "allowOverrides": true } diff --git a/yarn.lock b/yarn.lock index ecf4d2ed..9e033bf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5876,6 +5876,7 @@ __metadata: angular-eslint: "npm:22.0.0" eslint: "npm:10.5.0" jsdom: "npm:29.1.1" + ng-basic-catalog: "npm:0.1.0" prettier: "npm:3.8.4" rxjs: "npm:7.8.2" safevalues: "npm:1.2.0" @@ -9922,7 +9923,7 @@ __metadata: languageName: node linkType: hard -"ng-basic-catalog@workspace:samples/ng-basic-catalog": +"ng-basic-catalog@npm:0.1.0, ng-basic-catalog@workspace:samples/ng-basic-catalog": version: 0.0.0-use.local resolution: "ng-basic-catalog@workspace:samples/ng-basic-catalog" dependencies: