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
81 changes: 80 additions & 1 deletion packages/sdks-tests/src/e2e-tests/symbols.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { DEFAULT_TEXT_SYMBOL, FRENCH_TEXT_SYMBOL } from '../specs/symbol-with-locale.js';
import { FIRST_SYMBOL_CONTENT, SECOND_SYMBOL_CONTENT } from '../specs/symbols.js';
import {
FIRST_SYMBOL_CONTENT,
SECOND_SYMBOL_CONTENT,
GLOBAL_SYMBOL_OWNER_ID,
} from '../specs/symbols.js';
import {
excludeGen2,
checkIsGen1React,
Expand Down Expand Up @@ -245,4 +249,79 @@ test.describe('Symbols', () => {
const symbols = page.locator(selector);
await expect(symbols).toHaveCount(2);
});

test.describe('global symbols', () => {
const symbolUrlMatch = /https:\/\/cdn\.builder\.io\/api\/v3\/content\/symbol\.*/;

/**
* When a symbol block has `global: true` and `ownerId` set to a different space,
* the SDK must use `ownerId` as the apiKey in the CDN fetch — not the current space's apiKey.
*
* Uses `/symbols-with-global` which has block[1] marked as global with GLOBAL_SYMBOL_OWNER_ID.
*/
test('uses ownerId as apiKey when fetching a global symbol', async ({
page,
packageName,
sdk,
}) => {
test.skip(checkIsGen1React(sdk));
test.fail(SSR_FETCHING_PACKAGES.includes(packageName));

const capturedApiKeys: string[] = [];

await page.route(symbolUrlMatch, route => {
const url = new URL(route.request().url());
capturedApiKeys.push(url.searchParams.get('apiKey') || '');

return route.fulfill({
status: 200,
json: { results: [FIRST_SYMBOL_CONTENT] },
});
});

await page.goto('/symbols-with-global');

// Wait for symbols to render (ensures the CDN fetch has completed)
await expect(page.getByText('default description').locator('visible=true')).toBeVisible();

// At least one fetch must have used the global symbol owner's apiKey
expect(capturedApiKeys).toContain(GLOBAL_SYMBOL_OWNER_ID);
});

/**
* When a symbol block does not have the `global` attribute, the SDK must always use the current
* space's apiKey — even if `ownerId` is present on the symbol data.
*
* Uses `/symbols-without-content` which has no global symbols (no `global: true`).
* All fetches must use the same apiKey (the current space key).
*/
test('non-global symbol does not use ownerId as apiKey', async ({ page, packageName, sdk }) => {
test.skip(checkIsGen1React(sdk));
test.fail(SSR_FETCHING_PACKAGES.includes(packageName));

const capturedApiKeys: string[] = [];

await page.route(symbolUrlMatch, route => {
const url = new URL(route.request().url());
capturedApiKeys.push(url.searchParams.get('apiKey') || '');

return route.fulfill({
status: 200,
json: { results: [FIRST_SYMBOL_CONTENT] },
});
});

await page.goto('/symbols-without-content');

// Wait for symbols to render (ensures the CDN fetch has completed)
await expect(page.getByText('default description').locator('visible=true')).toBeVisible();

await expect(capturedApiKeys.length).toBeGreaterThanOrEqual(1);

// All fetches must use the same apiKey — no cross-space key should appear
const uniqueKeys = Array.from(new Set(capturedApiKeys));
expect(uniqueKeys.length).toBe(1);
expect(uniqueKeys[0]).not.toBe(GLOBAL_SYMBOL_OWNER_ID);
});
});
});
3 changes: 2 additions & 1 deletion packages/sdks-tests/src/specs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { CONTENT as symbolAbTest } from './symbol-ab-test.js';
import { CONTENT as symbolBindings } from './symbol-bindings.js';
import { CONTENT as symbolWithInputBinding } from './symbol-with-input-binding.js';
import { CONTENT as symbolWithLocale } from './symbol-with-locale.js';
import { CONTENT_WITHOUT_SYMBOLS, CONTENT as symbols } from './symbols.js';
import { CONTENT_WITHOUT_SYMBOLS, CONTENT_WITH_GLOBAL_SYMBOL, CONTENT as symbols } from './symbols.js';
import { TABS } from './tabs.js';
import { CONTENT as textBlock } from './text-block.js';
import { CONTENT as textEval } from './text-eval.js';
Expand Down Expand Up @@ -137,6 +137,7 @@ export const PAGES: Record<string, Page> = {
'/symbols': { content: symbols },
'/js-code': { content: JS_CODE_CONTENT },
'/symbols-without-content': { content: CONTENT_WITHOUT_SYMBOLS },
'/symbols-with-global': { content: CONTENT_WITH_GLOBAL_SYMBOL },
'/symbol-bindings': { content: symbolBindings },
'/symbol-with-locale': { content: symbolWithLocale },
'/link-url': { content: linkUrl },
Expand Down
18 changes: 18 additions & 0 deletions packages/sdks-tests/src/specs/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,3 +738,21 @@ const splitUpContent = () => {

export const { CONTENT_WITHOUT_SYMBOLS, FIRST_SYMBOL_CONTENT, SECOND_SYMBOL_CONTENT } =
splitUpContent();

/**
* The API key of the space that owns the global symbol — different from the current space.
*/
export const GLOBAL_SYMBOL_OWNER_ID = 'global-owner-space-api-key-abc123';

/**
* A page content where:
* - block[1] is a GLOBAL symbol (global: true, ownerId = GLOBAL_SYMBOL_OWNER_ID)
* - block[2] is a LOCAL symbol (no global/ownerId — uses current space apiKey by default)
*/
export const CONTENT_WITH_GLOBAL_SYMBOL = (() => {
const clone = JSON.parse(JSON.stringify(CONTENT_WITHOUT_SYMBOLS));
// Mark the first symbol as global, owned by a different space.
clone.data.blocks[1].component.options.symbol.global = true;
clone.data.blocks[1].component.options.symbol.ownerId = GLOBAL_SYMBOL_OWNER_ID;
return clone;
})();
4 changes: 3 additions & 1 deletion packages/sdks/src/blocks/symbol/symbol.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface SymbolInfo {
content?: BuilderContent;
inline?: boolean;
dynamic?: boolean;
ownerId?: string;
global?: boolean;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate SymbolInfo interface risks future divergence

Low Severity

The SymbolInfo interface is independently defined in both symbol.helpers.ts and symbol.types.ts with identical fields. This PR adds ownerId and global to both copies. The component passes props.symbol (typed via symbol.types.ts) to fetchSymbolContent (typed via symbol.helpers.ts), so if these definitions ever diverge, type mismatches could go unnoticed. symbol.helpers.ts could import SymbolInfo from symbol.types.ts instead of redeclaring it.

Additional Locations (1)

Fix in Cursor Fix in Web

}

export const fetchSymbolContent = async ({
Expand All @@ -35,7 +37,7 @@ export const fetchSymbolContent = async ({
) {
return fetchOneEntry({
model: symbol.model,
apiKey: builderContextValue.apiKey,
apiKey: (symbol.global && symbol.ownerId) ? symbol.ownerId : builderContextValue.apiKey,
apiVersion: builderContextValue.apiVersion,
...(symbol?.entry && {
query: {
Expand Down
2 changes: 1 addition & 1 deletion packages/sdks/src/blocks/symbol/symbol.lite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export default function Symbol(props: SymbolProps) {
nonce={props.builderContext.value.nonce}
isNestedRender
apiVersion={props.builderContext.value.apiVersion}
apiKey={props.builderContext.value.apiKey!}
apiKey={(props.symbol?.global && props.symbol?.ownerId) ? props.symbol.ownerId : props.builderContext.value.apiKey!}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check if the ownerId is part of the same root org ? Else it will allow global symbol to be used between orgs right ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good suggestion, but I don't think there is a way of knowing the root org at SDK side (do correct me if I am wrong), and anyways the UI and backend already have those checks to control which symbols are insertable to which spaces, so it should not be concerning.

context={{
...props.builderContext.value.context,
symbolId: props.builderBlock?.id,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdks/src/blocks/symbol/symbol.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface SymbolInfo {
content?: BuilderContent;
inline?: boolean;
dynamic?: boolean;
ownerId?: string;
global?: boolean;
}

export interface SymbolProps
Expand Down
Loading