diff --git a/Makefile b/Makefile
index 6d6f3970..138f3b15 100644
--- a/Makefile
+++ b/Makefile
@@ -84,6 +84,50 @@ else
endif
$(TARGET_OK)
+.PHONY: tests-sandbox
+tests-sandbox: .require-compose ## [Tests][docker] Runs jsdom, browser and e2e tests for v1-sandbox
+ $(TARGET_HEADER)
+ $(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:all
+ $(TARGET_OK)
+
+.PHONY: tests-sandbox-browser
+tests-sandbox-browser: .require-compose ## [Tests][docker] Run browser mod tests for v1-sandbox
+ $(TARGET_HEADER)
+ifdef cli
+ @$(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:browser $(cli) --passWithNoTests
+else
+ @$(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:browser
+endif
+ $(TARGET_OK)
+
+
+.PHONY: tests-sandbox-e2e
+tests-sandbox-e2e: .require-compose ## [Tests][docker] Run e2e tests for v1-sandbox
+ $(TARGET_HEADER)
+ifdef cli
+ @$(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:e2e $(cli)
+else
+ @$(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:e2e
+endif
+ $(TARGET_OK)
+
+.PHONY: tests-sandbox-jsdom
+tests-sandbox-jsdom: .require-compose ## [Tests][docker] Run jsdom tests for v1-sandbox
+ $(TARGET_HEADER)
+ifdef cli
+ @$(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:jsdom $(cli) --passWithNoTests
+else
+ @$(COMPOSE) run --rm --user "$$(id -u):$$(id -g)" playwright \
+ yarn workspace @retailcrm/embed-ui-v1-sandbox test:jsdom
+endif
+ $(TARGET_OK)
+
.PHONY: tests-browser
tests-browser: .require-compose ## [Tests][docker] Runs browser mode tests for all workspaces
$(TARGET_HEADER)
diff --git a/docker-compose.yml b/docker-compose.yml
index 5326ef90..0f5b89d4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,6 +43,8 @@ services:
environment:
- COREPACK_ENABLE_DOWNLOAD_PROMPT=0
- PATH=/project/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+ - HOME=/tmp
+ - COREPACK_HOME=/tmp/corepack
volumes:
- ./:/project
working_dir: /project
diff --git a/packages/v1-sandbox/README.md b/packages/v1-sandbox/README.md
index eec89fd2..e791853a 100644
--- a/packages/v1-sandbox/README.md
+++ b/packages/v1-sandbox/README.md
@@ -39,8 +39,21 @@ npx @retailcrm/embed-ui-v1-sandbox serve
embed-ui-v1-sandbox serve --host 0.0.0.0 --port 4173
```
-Откройте URL из вывода команды, укажите URL расширения
-`%extension-url%/extension/%extension-id%`, выберите режим и fixture, затем нажмите `Apply`.
+Откройте URL из вывода команды и вставьте runtime-дескриптор расширения:
+
+```json
+{
+ "code": "returnsModule",
+ "baseUrl": "https://extension.test",
+ "entrypoint": "/build/worker.js",
+ "pages": ["returns"],
+ "stylesheet": "/build/extension.css",
+ "targets": []
+}
+```
+
+`entrypoint` и `stylesheet` разрешаются относительно `baseUrl`. JSON можно
+вставить целиком либо заполнить те же поля отдельно в DevPanel.
## Применение в тестах
@@ -68,8 +81,15 @@ test('loads extension page in sandbox', async ({ page }) => {
await page.goto('/')
await launchSandboxExtension(page, {
+ descriptor: {
+ code: 'returnsModule',
+ baseUrl: 'https://extension.test',
+ entrypoint: '/build/worker.js',
+ pages: ['returns'],
+ stylesheet: '/build/extension.css',
+ targets: [],
+ },
fixture: 'order-basic',
- manifestUrl: '%extension-url%/extension/%extension-id%',
mode: 'page',
pageCode: 'returns',
})
diff --git a/packages/v1-sandbox/docs/api.md b/packages/v1-sandbox/docs/api.md
index 2d065110..630fcac0 100644
--- a/packages/v1-sandbox/docs/api.md
+++ b/packages/v1-sandbox/docs/api.md
@@ -123,9 +123,15 @@ HostAPI snapshot from `window.__CRM_EMBED_SANDBOX__`:
```ts
await page.goto(createSandboxPagePath({
- extensionUrl: '%extension-url%/extension/%extension-id%',
+ descriptor: {
+ code: 'returnsModule',
+ baseUrl: 'https://extension.test',
+ entrypoint: '/build/worker.js',
+ pages: ['returns'],
+ stylesheet: '/build/extension.css',
+ targets: [],
+ },
pageCode: 'returns',
- targets: ['order/card:common.after'],
}))
const snapshot = await readSandboxSnapshot(page)
@@ -156,24 +162,40 @@ from package delivery or a test process.
The sandbox app is controlled by query parameters:
-- `manifestUrl`: full extension descriptor/entrypoint/script URL.
+- `descriptor`: URL-encoded JSON runtime descriptor. This is the primary mode.
- `mode`: `widget` or `page`.
- `fixture`: sandbox fixture code, for example `order-basic`.
- `pageCode`: page runner code for `mode=page`.
- `targets`: comma-separated widget targets for `mode=widget`.
- `widgetId`: base widget instance id.
-- `extensionUrl`: low-level fallback for direct worker entrypoints.
+
+The runtime descriptor has exactly these fields:
+
+```ts
+type SandboxExtensionDescriptor = {
+ code: string
+ baseUrl: string
+ entrypoint: string
+ stylesheet: string | null
+ pages: string[]
+ targets: TargetName[]
+}
+```
+
+`baseUrl` must be an absolute HTTP(S) URL. `entrypoint` and non-null
+`stylesheet` may be relative to it or absolute. Unknown fields, including
+`runner` and `uuid`, are rejected.
Example page URL:
```text
-%sandbox-url%/?manifestUrl=%extension-url%/extension/%extension-id%&mode=page&pageCode=returns&fixture=order-basic
+%sandbox-url%/?descriptor=%url-encoded-descriptor-json%&mode=page&pageCode=returns&fixture=order-basic
```
Example widget URL:
```text
-%sandbox-url%/?manifestUrl=%extension-url%/extension/%extension-id%&mode=widget&targets=order/card:common.after&fixture=order-basic
+%sandbox-url%/?descriptor=%url-encoded-descriptor-json%&mode=widget&targets=order/card:common.after&fixture=order-basic
```
## HostAPI HTTP Contract
@@ -184,7 +206,8 @@ Extensions call:
await host.httpCall('/returns', payload)
```
-When the sandbox can infer the backend base from `manifestUrl`, it sends:
+In descriptor mode the backend base is `baseUrl`; the call
+above sends:
```text
POST %extension-url%/returns
diff --git a/packages/v1-sandbox/docs/examples.md b/packages/v1-sandbox/docs/examples.md
index 9dfe0d64..ad69c16f 100644
--- a/packages/v1-sandbox/docs/examples.md
+++ b/packages/v1-sandbox/docs/examples.md
@@ -11,22 +11,29 @@ Start the sandbox app:
npx @retailcrm/embed-ui-v1-sandbox serve
```
-Open the printed URL and paste a full extension endpoint:
+Open the printed URL and paste a runtime descriptor:
-```text
-%extension-url%/extension/%extension-id%
+```json
+{
+ "code": "promoModule",
+ "baseUrl": "http://web-extensions-server.simla.local",
+ "entrypoint": "/extension/8ebe1617-d609-43e4-b35a-fbfae011eee3/script",
+ "stylesheet": "/extension/8ebe1617-d609-43e4-b35a-fbfae011eee3/stylesheet",
+ "targets": [],
+ "pages": ["settings"],
+}
```
Page runner direct URL:
```text
-%sandbox-url%/?manifestUrl=%extension-url%/extension/%extension-id%&mode=page&pageCode=returns&fixture=order-basic
+%sandbox-url%/?descriptor=%url-encoded-descriptor-json%&mode=page&pageCode=returns&fixture=order-basic
```
Widget runner direct URL:
```text
-%sandbox-url%/?manifestUrl=%extension-url%/extension/%extension-id%&mode=widget&targets=order/card:common.after&fixture=order-basic
+%sandbox-url%/?descriptor=%url-encoded-descriptor-json%&mode=widget&targets=order/card:common.after&fixture=order-basic
```
Use the DevPanel to switch fixtures, edit Context JSON, or change page/widget
@@ -88,8 +95,15 @@ afterEach(() => {
test('mounts page extension in browser mode', async () => {
sandbox = await launchSandboxExtension({
+ descriptor: {
+ baseUrl: new URL('/tests/fixtures/extensions/returnsModule/', window.location.href).href,
+ code: 'returnsModule',
+ entrypoint: 'index.ts',
+ pages: ['returns'],
+ stylesheet: null,
+ targets: [],
+ },
fixture: 'order-basic',
- manifestUrl: '/tests/fixtures/extensions/returnsModule/index.ts',
mode: 'page',
pageCode: 'returns',
})
@@ -119,8 +133,15 @@ import { expect, test } from '@playwright/test'
test('loads returns page extension', async ({ page }) => {
await page.goto(
- '/?manifestUrl='
- + encodeURIComponent('%extension-url%/extension/%extension-id%')
+ '/?descriptor='
+ + encodeURIComponent(JSON.stringify({
+ code: 'returnsModule',
+ baseUrl: 'https://extension.test',
+ entrypoint: '/build/worker.js',
+ pages: ['returns'],
+ stylesheet: '/build/extension.css',
+ targets: [],
+ }))
+ '&mode=page'
+ '&pageCode=returns'
+ '&fixture=order-basic'
@@ -142,8 +163,15 @@ import {
await page.goto('/')
await launchSandboxExtension(page, {
+ descriptor: {
+ code: 'promoModule',
+ baseUrl: 'https://extension.test',
+ entrypoint: '/build/worker.js',
+ pages: [],
+ stylesheet: null,
+ targets: ['order/card:common.after'],
+ },
fixture: 'order-basic',
- manifestUrl: '%extension-url%/extension/%extension-id%',
mode: 'widget',
targets: ['order/card:common.after'],
})
diff --git a/packages/v1-sandbox/docs/usage-guide.md b/packages/v1-sandbox/docs/usage-guide.md
index 539553b5..d2f1afab 100644
--- a/packages/v1-sandbox/docs/usage-guide.md
+++ b/packages/v1-sandbox/docs/usage-guide.md
@@ -1,9 +1,9 @@
# v1-sandbox Usage Guide
`v1-sandbox` is a local CRM-like environment for running JS extensions before
-installing them into a real CRM. The sandbox loads an external extension by URL,
-mounts it into a selected widget target or page area, provides fixture-backed
-context, and simulates HostAPI.
+installing them into a real CRM. The sandbox loads an external extension from a
+runtime descriptor, mounts it into a selected widget target or page area,
+provides fixture-backed context, and simulates HostAPI.
Related docs:
@@ -36,30 +36,30 @@ yarn embed-ui-v1-sandbox serve
uses Traefik and the `.test` top-level domain; OrbStack on macOS uses `.local`
from its domain namespace.
-3. Start your extension project separately. It must expose the extension module
-over HTTP. The standard URL shape is:
+3. Start your extension project separately. It must expose the worker module
+and optional stylesheet over HTTP(S). Prepare a runtime descriptor:
-```text
-%extension-url%/extension/%extension-id%
+```json
+{
+ "baseUrl": "https://extension.test/build/",
+ "code": "returnsModule",
+ "entrypoint": "worker.js",
+ "pages": ["returns"],
+ "stylesheet": "extension.css",
+ "targets": []
+}
```
-`%extension-url%` is the delivery server origin without
-`/extension/%extension-id%`. It can be `http://web-extensions-server.simla.local`,
-`http://web-extensions-server.simla.test`, `https://ycp-retail.ru`, or any other
-available extension server. `manifestUrl` must include the `/extension/` path
-and `%extension-id%`.
+This descriptor is the runtime contract, not the raw `extensionrc.json` from an
+extension project. `baseUrl` is absolute, while `entrypoint` and `stylesheet`
+may be relative to it. `pages` contains page-code strings, and the descriptor
+has no `runner` or `uuid` field. The sandbox supports worker extensions only.
4. In the sandbox, click the `>` icon in the lower part of the dark rail. The
sandbox control panel opens.
-5. Paste the extension URL into `Manifest / extension URL`.
-
-- `%extension-url%`: delivery server origin without `/extension/%extension-id%`;
-- `/extension/`: the default extension server API path. If your server uses a
- different descriptor path, paste the full endpoint URL;
-- `%extension-id%`: id/UUID of the JS module or extension record on the
- extension server. It is not a `pageCode` and not a target; one extension can
- contain pages and widgets.
+5. Paste the JSON into `Descriptor JSON`, or fill in `Code`, `Base URL`,
+ `Entrypoint`, and `Stylesheet` separately.
6. Select a mode:
@@ -102,38 +102,39 @@ The CLI prints the URL it serves. Use that URL as `%sandbox-url%` in the
examples below.
The built app does not contain extension code. Your extension server still runs
-separately, and the sandbox receives it through `Manifest / extension URL`.
-
-## What to Paste into Manifest / Extension URL
-
-The primary value is the descriptor or entrypoint URL:
-
-```text
-%extension-url%/extension/%extension-id%
-```
-
-The sandbox can load:
+separately, and the sandbox receives its runtime descriptor through
+`Descriptor JSON`.
-- an HTML entrypoint, using the first `
@@ -119,26 +76,14 @@ const openDevPanel = () => props.openDevPanel()
{
"extensionOnboarding": {
"actions": {
- "openSandbox": "Open sandbox controls"
+ "openSandbox": "Open developer panel"
},
- "description": "Use the sandbox to check JS module pages and widgets locally before installing the module in RetailCRM.",
+ "description": "Use the sandbox to test JS module pages and widgets locally before installing the module in RetailCRM. Before adding the extension to the sandbox, make sure it is running.",
"eyebrow": "Local development",
- "example": {
- "completeExtensionUrl": "Complete extension URL example:",
- "description": "This is a template, not a ready-to-use link. Replace each placeholder with the value for your local environment.",
- "extensionId": "The extension UUID.",
- "extensionUrl": "The base URL of the extension server, for example http://127.0.0.1:4175.",
- "sandboxUrl": "The sandbox URL, for example http://127.0.0.1:4173.",
- "title": "Launch URL template"
- },
- "modeNote": "Then choose a mode: Widgets or Page.",
+ "modeNote": "When using the individual fields, then choose a mode: Widgets or Page.",
"note": "Only two running processes are required: the extension server and the sandbox. You do not need to start an additional server.",
- "steps": {
- "extensionServer": "Start the extension server.",
- "extensionUrl": "Open sandbox controls and enter the extension URL:",
- "sandboxServer": "Start the sandbox."
- },
- "title": "Connect an external extension"
+ "title": "Connect an external extension",
+ "descriptor": "Runtime descriptor format"
}
}
@@ -147,26 +92,14 @@ const openDevPanel = () => props.openDevPanel()
{
"extensionOnboarding": {
"actions": {
- "openSandbox": "Abrir controles de sandbox"
+ "openSandbox": "Abrir el panel de desarrollo"
},
- "description": "Utilice la sandbox para comprobar localmente las páginas y los widgets del módulo JS antes de instalarlo en RetailCRM.",
+ "description": "Utilice la sandbox para probar localmente las páginas y los widgets del módulo JS antes de instalarlo en RetailCRM. Antes de añadir la extensión a la sandbox, asegúrese de que esté en ejecución.",
"eyebrow": "Desarrollo local",
- "example": {
- "completeExtensionUrl": "Ejemplo de URL completa de la extensión:",
- "description": "Esta es una plantilla, no un enlace listo para usar. Sustituya cada placeholder por el valor de su entorno local.",
- "extensionId": "El UUID de la extensión.",
- "extensionUrl": "La URL base del servidor de la extensión, por ejemplo http://127.0.0.1:4175.",
- "sandboxUrl": "La URL de la sandbox, por ejemplo http://127.0.0.1:4173.",
- "title": "Plantilla de URL de inicio"
- },
- "modeNote": "Después elija un modo: Widgets o Página.",
+ "modeNote": "Si utiliza los campos individuales, elija después un modo: Widgets o Página.",
"note": "Solo se necesitan dos procesos en ejecución: el servidor de la extensión y la sandbox. No hace falta iniciar un servidor adicional.",
- "steps": {
- "extensionServer": "Inicie el servidor de la extensión.",
- "extensionUrl": "Abra los controles de sandbox e indique la URL de la extensión:",
- "sandboxServer": "Inicie la sandbox."
- },
- "title": "Conectar una extensión externa"
+ "title": "Conectar una extensión externa",
+ "descriptor": "Formato del descriptor de ejecución"
}
}
@@ -175,26 +108,14 @@ const openDevPanel = () => props.openDevPanel()
{
"extensionOnboarding": {
"actions": {
- "openSandbox": "Открыть песочницу"
+ "openSandbox": "Открыть дев-панель"
},
- "description": "Используйте песочницу, чтобы локально проверить страницы и виджеты JS-модуля до его установки в RetailCRM.",
+ "description": "Используйте песочницу, чтобы локально проверить страницы и виджеты JS-модуля до его установки в RetailCRM. Перед тем как добавлять расширение в песочницу, убедитесь, что расширение запущено",
"eyebrow": "Локальная разработка",
- "example": {
- "completeExtensionUrl": "Пример полного URL расширения:",
- "description": "Это шаблон, а не готовая ссылка. Замените каждое обозначение значением из своего локального окружения.",
- "extensionId": "UUID расширения.",
- "extensionUrl": "Базовый URL сервера расширения, например http://127.0.0.1:4175.",
- "sandboxUrl": "URL песочницы, например http://127.0.0.1:4173.",
- "title": "Шаблон URL запуска"
- },
- "modeNote": "Затем выберите режим: Виджеты или Страница.",
+ "modeNote": "При заполнении отдельных полей затем выберите режим: Виджеты или Страница.",
"note": "Для работы нужны только два запущенных процесса: сервер расширения и песочница. Дополнительный сервер запускать не требуется.",
- "steps": {
- "extensionServer": "Запустите сервер расширения.",
- "extensionUrl": "Откройте управление песочницей и укажите URL расширения:",
- "sandboxServer": "Запустите песочницу."
- },
- "title": "Подключите внешнее расширение"
+ "title": "Подключите внешнее расширение",
+ "descriptor": "Формат передаваемого дескриптора"
}
}
@@ -244,18 +165,19 @@ const openDevPanel = () => props.openDevPanel()
margin: 0;
}
- &__steps {
- color: @black-500;
- display: grid;
- font-size: 14px;
- gap: @spacing-xs;
- margin: 0;
- padding-left: 18px;
+ &__descriptor {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
- code {
- color: @blue-500;
- overflow-wrap: anywhere;
- }
+ &__example {
+ background: @grey-400;
+ border-radius: @border-radius-md;
+ margin: 0;
+ overflow: auto;
+ padding: @spacing-s;
+ white-space: pre;
}
&__note,
diff --git a/packages/v1-sandbox/src/scenario/descriptor.ts b/packages/v1-sandbox/src/scenario/descriptor.ts
new file mode 100644
index 00000000..403e43b9
--- /dev/null
+++ b/packages/v1-sandbox/src/scenario/descriptor.ts
@@ -0,0 +1,98 @@
+import type { SandboxExtensionDescriptor } from '@/scenario/types'
+
+import { z } from 'zod'
+
+const nonEmptyString = z.string().transform(value => value.trim()).pipe(z.string().min(1))
+const absoluteHttpUrl = nonEmptyString.refine((value) => {
+ try {
+ const url = new URL(value)
+
+ return url.protocol === 'http:' || url.protocol === 'https:'
+ } catch {
+ return false
+ }
+})
+
+const sandboxExtensionDescriptorSchema = z.object({
+ code: nonEmptyString,
+ baseUrl: absoluteHttpUrl,
+ entrypoint: nonEmptyString,
+ stylesheet: z.union([nonEmptyString, z.null()]),
+ pages: z.array(nonEmptyString),
+ targets: z.array(nonEmptyString),
+}).strict().superRefine((descriptor, context) => {
+ validateResourceUrl(descriptor.entrypoint, descriptor.baseUrl, ['entrypoint'], context)
+
+ if (descriptor.stylesheet) {
+ validateResourceUrl(descriptor.stylesheet, descriptor.baseUrl, ['stylesheet'], context)
+ }
+})
+
+const validateResourceUrl = (
+ value: string,
+ baseUrl: string,
+ path: string[],
+ context: z.RefinementCtx
+): void => {
+ if (resolvesToHttpUrl(value, baseUrl)) return
+
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Resource URL must resolve to an absolute http/https URL.',
+ path,
+ })
+}
+
+const resolvesToHttpUrl = (value: string, baseUrl: string): boolean => {
+ try {
+ const url = new URL(value, normalizeSandboxExtensionBaseUrl(baseUrl))
+
+ return url.protocol === 'http:' || url.protocol === 'https:'
+ } catch {
+ return false
+ }
+}
+
+export const normalizeSandboxExtensionBaseUrl = (value: string): string => {
+ const url = new URL(value)
+
+ if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`
+
+ return url.href
+}
+
+export const resolveSandboxExtensionResourceUrl = (
+ value: string,
+ baseUrl: string
+): URL => new URL(value, normalizeSandboxExtensionBaseUrl(baseUrl))
+
+export const parseSandboxExtensionDescriptor = (
+ value: unknown
+): SandboxExtensionDescriptor => {
+ const result = sandboxExtensionDescriptorSchema.safeParse(value)
+
+ if (!result.success) {
+ throw new Error('[sandbox:descriptor] Invalid extension descriptor.')
+ }
+
+ return result.data as SandboxExtensionDescriptor
+}
+
+export const parseSandboxExtensionDescriptorJson = (
+ value: string
+): SandboxExtensionDescriptor => {
+ let parsed: unknown
+
+ try {
+ parsed = JSON.parse(value) as unknown
+ } catch {
+ throw new Error('[sandbox:descriptor] Invalid extension descriptor JSON.')
+ }
+
+ return parseSandboxExtensionDescriptor(parsed)
+}
+
+export const serializeSandboxExtensionDescriptor = (
+ descriptor: SandboxExtensionDescriptor,
+ space?: number
+): string => JSON.stringify(descriptor, null, space)
diff --git a/packages/v1-sandbox/src/scenario/index.ts b/packages/v1-sandbox/src/scenario/index.ts
index 983d8aee..3c3df270 100644
--- a/packages/v1-sandbox/src/scenario/index.ts
+++ b/packages/v1-sandbox/src/scenario/index.ts
@@ -1,4 +1,5 @@
export type * from './types'
+export * from './descriptor'
export * from './defaults'
export * from './fixtures'
export * from './http'
diff --git a/packages/v1-sandbox/src/scenario/launch.ts b/packages/v1-sandbox/src/scenario/launch.ts
index e8bed025..939cea4b 100644
--- a/packages/v1-sandbox/src/scenario/launch.ts
+++ b/packages/v1-sandbox/src/scenario/launch.ts
@@ -8,6 +8,10 @@ import type {
import { DEFAULT_SANDBOX_TARGET } from '@/scenario/targets'
import { DefaultSandbox } from '@/scenario/defaults'
import { isSandboxOrderTarget } from '@/scenario/predicates'
+import {
+ parseSandboxExtensionDescriptorJson,
+ serializeSandboxExtensionDescriptor,
+} from '@/scenario/descriptor'
export type {
ParseSandboxLaunchConfigOptions,
@@ -21,12 +25,16 @@ export const parseSandboxLaunchConfig = (
params: URLSearchParams,
options: ParseSandboxLaunchConfigOptions = {}
): SandboxLaunchConfig => {
- const target = params.get('target') ?? options.targets?.[0] ?? DEFAULT_SANDBOX_TARGET
- const targets = parseTargetsParam(params.get('targets')) ?? options.targets ?? [
- isSandboxOrderTarget(target) ? target : DEFAULT_SANDBOX_TARGET,
- ]
+ const descriptor = parseDescriptorParam(params.get('descriptor')) ?? options.descriptor
+ const descriptorTargets = descriptor?.targets.filter(isSandboxOrderTarget) ?? []
+ const target = params.get('target') ?? options.targets?.[0]
+ const targets = parseTargetsParam(params.get('targets'))
+ ?? options.targets
+ ?? (target && isSandboxOrderTarget(target) ? [target] : null)
+ ?? (descriptorTargets.length > 0 ? descriptorTargets : [DEFAULT_SANDBOX_TARGET])
return {
+ ...(descriptor ? { descriptor } : {}),
extensionUrl: readStringParam(
params,
'extensionUrl',
@@ -63,9 +71,17 @@ export const updateSandboxLaunchQuery = (
): URL => {
const url = new URL(base)
- url.searchParams.set('extensionUrl', config.extensionUrl)
+ if (config.descriptor) {
+ url.searchParams.set('descriptor', serializeSandboxExtensionDescriptor(config.descriptor))
+ url.searchParams.delete('extensionUrl')
+ url.searchParams.delete('manifestUrl')
+ } else {
+ url.searchParams.delete('descriptor')
+ url.searchParams.set('extensionUrl', config.extensionUrl)
+ }
+
url.searchParams.set('fixture', config.fixture)
- url.searchParams.set('manifestUrl', config.manifestUrl)
+ if (!config.descriptor) url.searchParams.set('manifestUrl', config.manifestUrl)
url.searchParams.set('mode', config.mode)
url.searchParams.set('pageCode', config.pageCode)
url.searchParams.set('target', config.targets[0] ?? DEFAULT_SANDBOX_TARGET)
@@ -75,6 +91,12 @@ export const updateSandboxLaunchQuery = (
return url
}
+const parseDescriptorParam = (
+ value: string | null
+): SandboxLaunchConfig['descriptor'] => value
+ ? parseSandboxExtensionDescriptorJson(value)
+ : undefined
+
const readStringParam = (
params: URLSearchParams,
key: string,
diff --git a/packages/v1-sandbox/src/scenario/manifest.ts b/packages/v1-sandbox/src/scenario/manifest.ts
index 93b63736..457cb77d 100644
--- a/packages/v1-sandbox/src/scenario/manifest.ts
+++ b/packages/v1-sandbox/src/scenario/manifest.ts
@@ -7,12 +7,16 @@ import type {
} from '@/scenario/types'
import { isHtmlMimeType, isJavascriptMimeType } from '@/scenario/predicates'
+import {
+ normalizeSandboxExtensionBaseUrl,
+ parseSandboxExtensionDescriptor,
+ resolveSandboxExtensionResourceUrl,
+} from '@/scenario/descriptor'
export type {
FetchLike,
ResolveSandboxExtensionSourceOptions,
SandboxExtensionDescriptor,
- SandboxExtensionRunner,
SandboxExtensionSource,
} from '@/scenario/types'
@@ -22,6 +26,27 @@ export const resolveSandboxExtensionSource = async (
): Promise => {
const fetcher = options.fetch ?? fetch
+ if (config.descriptor) {
+ const inputDescriptor = parseSandboxExtensionDescriptor(config.descriptor)
+ const baseUrl = normalizeSandboxExtensionBaseUrl(inputDescriptor.baseUrl)
+ const entrypoint = resolveSandboxExtensionResourceUrl(inputDescriptor.entrypoint, baseUrl)
+ const descriptor = {
+ ...inputDescriptor,
+ baseUrl,
+ entrypoint: entrypoint.href,
+ stylesheet: inputDescriptor.stylesheet
+ ? resolveSandboxExtensionResourceUrl(inputDescriptor.stylesheet, baseUrl).href
+ : null,
+ }
+
+ return {
+ descriptor,
+ entrypoint,
+ httpBaseUrl: baseUrl,
+ manifestUrl: null,
+ }
+ }
+
if (!config.manifestUrl) {
const descriptor = createFallbackDescriptor(config)
@@ -58,12 +83,12 @@ export const resolveSandboxExtensionSource = async (
}
const createFallbackDescriptor = (config: SandboxLaunchConfig): SandboxExtensionDescriptor => ({
+ baseUrl: resolveDescriptorBaseUrl(config.extensionUrl),
+ code: config.widgetId,
entrypoint: config.extensionUrl,
pages: config.mode === 'page' ? [config.pageCode] : [],
- runner: 'worker',
stylesheet: null,
targets: config.targets,
- uuid: config.widgetId,
})
const createDescriptorFromEntrypoint = (
@@ -72,14 +97,20 @@ const createDescriptorFromEntrypoint = (
stylesheet: string | null,
pages = config.mode === 'page' ? [config.pageCode] : []
): SandboxExtensionDescriptor => ({
+ baseUrl: resolveDescriptorBaseUrl(responseUrl),
+ code: config.widgetId,
entrypoint: responseUrl,
pages,
- runner: 'worker',
stylesheet,
targets: config.targets,
- uuid: config.widgetId,
})
+const resolveDescriptorBaseUrl = (entrypoint: string): string => {
+ const backendBaseUrl = resolveExtensionBackendBaseUrl(entrypoint)
+
+ return backendBaseUrl ?? `${resolveUrl(entrypoint, window.location.href).origin}/`
+}
+
const resolveEntrypointSource = async (
config: SandboxLaunchConfig,
response: Response,
diff --git a/packages/v1-sandbox/src/scenario/types.d.ts b/packages/v1-sandbox/src/scenario/types.d.ts
index 0f9d2241..404b7858 100644
--- a/packages/v1-sandbox/src/scenario/types.d.ts
+++ b/packages/v1-sandbox/src/scenario/types.d.ts
@@ -11,6 +11,7 @@ export type SandboxSlotDefinition = {
export type SandboxLaunchMode = 'page' | 'widget'
export type SandboxLaunchConfig = {
+ descriptor?: SandboxExtensionDescriptor;
extensionUrl: string;
fixture: string;
manifestUrl: string;
@@ -22,15 +23,13 @@ export type SandboxLaunchConfig = {
export type ParseSandboxLaunchConfigOptions = Partial
-export type SandboxExtensionRunner = 'worker'
-
export type SandboxExtensionDescriptor = {
+ code: string;
+ baseUrl: string;
entrypoint: string;
- pages: string[];
- runner: SandboxExtensionRunner;
stylesheet: string | null;
+ pages: string[];
targets: TargetName[];
- uuid: string;
}
export type SandboxExtensionSource = {
diff --git a/packages/v1-sandbox/src/scenario/validation.ts b/packages/v1-sandbox/src/scenario/validation.ts
index a166c0e8..6a20c5a3 100644
--- a/packages/v1-sandbox/src/scenario/validation.ts
+++ b/packages/v1-sandbox/src/scenario/validation.ts
@@ -1,9 +1,14 @@
-import type { SandboxLaunchMode, SandboxOrderTarget } from '@/scenario/types'
+import type {
+ SandboxExtensionDescriptor,
+ SandboxLaunchMode,
+ SandboxOrderTarget,
+} from '@/scenario/types'
import { z } from 'zod'
import { isSandboxOrderTarget } from '@/scenario/predicates'
import { orderSandboxFixtures } from '@/scenario/fixtures'
+import { parseSandboxExtensionDescriptorJson } from '@/scenario/descriptor'
export type DevPanelField =
| 'contextJson'
@@ -22,8 +27,7 @@ export type DevPanelValidationMessages = {
contextJsonRootObject: string;
contextJsonUnknownContext(context: string): string;
fixture: string;
- manifestUrlEndpoint: string;
- manifestUrlFormat: string;
+ manifestUrlDescriptor: string;
manifestUrlRequired: string;
mode: string;
pageCodeFormat: string;
@@ -46,6 +50,7 @@ export type LaunchConfigValidationInput = {
}
export type ValidatedLaunchConfigInput = {
+ descriptor?: SandboxExtensionDescriptor;
fixture: string;
manifestUrl: string;
mode: SandboxLaunchMode;
@@ -76,9 +81,14 @@ export const validateLaunchConfigInput = (
}
}
+ const source = validateExtensionSourceInput(result.data.manifestUrl, messages)
+
+ if (!source.success) return source
+
return {
data: {
...result.data,
+ ...source.data,
mode: result.data.mode as SandboxLaunchMode,
targets: result.data.targets as SandboxOrderTarget[],
},
@@ -130,45 +140,7 @@ const createLaunchConfigSchema = (messages: DevPanelValidationMessages) => z.obj
),
manifestUrl: z.string()
.transform(value => value.trim())
- .superRefine((value, context) => {
- if (!value) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- message: messages.manifestUrlRequired,
- })
-
- return
- }
-
- let url: URL
-
- try {
- url = new URL(value)
- } catch {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- message: messages.manifestUrlFormat,
- })
-
- return
- }
-
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- message: messages.manifestUrlFormat,
- })
-
- return
- }
-
- if (!hasExtensionEndpoint(url)) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- message: messages.manifestUrlEndpoint,
- })
- }
- }),
+ .refine(value => value.length > 0, messages.manifestUrlRequired),
mode: z.enum(['page', 'widget'], {
invalid_type_error: messages.mode,
required_error: messages.mode,
@@ -209,6 +181,26 @@ const createLaunchConfigSchema = (messages: DevPanelValidationMessages) => z.obj
}
})
+const validateExtensionSourceInput = (
+ value: string,
+ messages: DevPanelValidationMessages
+): ValidationResult> => {
+ try {
+ return {
+ data: {
+ descriptor: parseSandboxExtensionDescriptorJson(value),
+ manifestUrl: '',
+ },
+ success: true,
+ }
+ } catch {
+ return {
+ errors: { manifestUrl: messages.manifestUrlDescriptor },
+ success: false,
+ }
+ }
+}
+
const createContextJsonSchema = (
contextNames: readonly string[],
messages: DevPanelValidationMessages
@@ -271,13 +263,6 @@ const isDevPanelField = (value: unknown): value is DevPanelField =>
|| value === 'pageCode'
|| value === 'targets'
-const hasExtensionEndpoint = (url: URL): boolean => {
- const parts = url.pathname.split('/').filter(Boolean)
- const extensionIndex = parts.indexOf('extension')
-
- return extensionIndex >= 0 && Boolean(parts[extensionIndex + 1])
-}
-
const isRecord = (value: unknown): value is Record =>
typeof value === 'object' && value !== null && !Array.isArray(value)
diff --git a/packages/v1-sandbox/tests/__utils__/extensions.ts b/packages/v1-sandbox/tests/__utils__/extensions.ts
index a83d208f..78d7f340 100644
--- a/packages/v1-sandbox/tests/__utils__/extensions.ts
+++ b/packages/v1-sandbox/tests/__utils__/extensions.ts
@@ -1,4 +1,4 @@
-import type { SandboxOrderTarget } from '@/scenario'
+import type { SandboxExtensionDescriptor, SandboxOrderTarget } from '@/scenario'
import { fileURLToPath } from 'node:url'
import fs from 'node:fs'
@@ -56,3 +56,31 @@ export const getExtensionTargets = (
descriptor: SandboxExtensionFixtureDescriptor
): SandboxOrderTarget[] =>
descriptor.targets?.filter(isSandboxOrderTarget) ?? []
+
+export const createRuntimeExtensionDescriptor = (
+ descriptor: SandboxExtensionFixtureDescriptor,
+ extensionBaseUrl = process.env.SANDBOX_RUNTIME_EXTENSION_URL
+ ?? process.env.SANDBOX_EXTENSION_URL
+): SandboxExtensionDescriptor => {
+ if (!extensionBaseUrl) {
+ throw new Error('[sandbox:test] SANDBOX_EXTENSION_URL is required for extension browser tests.')
+ }
+
+ if (!descriptor.fixtureName) {
+ throw new Error('[sandbox:test] Extension fixture name is required for runtime descriptor.')
+ }
+
+ const baseUrl = new URL(extensionBaseUrl)
+ const runtimeUrl = new URL(`/runtime/${descriptor.fixtureName}/`, baseUrl)
+
+ return {
+ baseUrl: runtimeUrl.href,
+ code: descriptor.code ?? descriptor.fixtureName,
+ entrypoint: 'entrypoint.js',
+ pages: getExtensionPageCodes(descriptor),
+ stylesheet: descriptor.stylesheet
+ ? 'stylesheet.css'
+ : null,
+ targets: getExtensionTargets(descriptor),
+ }
+}
diff --git a/packages/v1-sandbox/tests/__utils__/sandbox.ts b/packages/v1-sandbox/tests/__utils__/sandbox.ts
index 5fc688ce..68b849ae 100644
--- a/packages/v1-sandbox/tests/__utils__/sandbox.ts
+++ b/packages/v1-sandbox/tests/__utils__/sandbox.ts
@@ -15,7 +15,11 @@ import {
readSandboxSnapshot,
} from '@/automation/playwright'
-import { getExtensionPageCodes, getExtensionTargets } from './extensions'
+import {
+ createRuntimeExtensionDescriptor,
+ getExtensionPageCodes,
+ getExtensionTargets,
+} from './extensions'
export {
getSandboxExtensionBaseUrl,
@@ -71,6 +75,16 @@ export const createSandboxPagePath = (
sandboxPath: '/tests/__bootstrap__/index.html',
})
+export const createSandboxDescriptorPagePath = (
+ descriptor: SandboxExtensionFixtureDescriptor,
+ pageCode = getExtensionPageCodes(descriptor)[0]
+): string =>
+ createPublicSandboxPagePath({
+ descriptor: createRuntimeExtensionDescriptor(descriptor),
+ pageCode: pageCode ?? DefaultSandbox.PageCode,
+ sandboxPath: '/tests/__bootstrap__/index.html',
+ })
+
export const createSandboxWidgetPath = (
descriptor: SandboxExtensionFixtureDescriptor,
target?: SandboxOrderTarget
diff --git a/packages/v1-sandbox/tests/e2e/returnsModule.e2e.ts b/packages/v1-sandbox/tests/e2e/returnsModule.e2e.ts
index 714c5e9a..6ba47d5d 100644
--- a/packages/v1-sandbox/tests/e2e/returnsModule.e2e.ts
+++ b/packages/v1-sandbox/tests/e2e/returnsModule.e2e.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'
-import { createSandboxPagePath } from '../__utils__/sandbox'
+import { createSandboxDescriptorPagePath } from '../__utils__/sandbox'
import { getExtensionPageCodes } from '../__utils__/extensions'
import { readExtensionFixture } from '../__utils__/extensions'
import { readSandboxSnapshot } from '../__utils__/sandbox'
@@ -11,13 +11,30 @@ const [pageCode] = getExtensionPageCodes(extension)
if (!pageCode) throw new Error('returnsModule fixture has no page descriptor.')
test('loads returns page extension, filters, opens and saves return', async ({ page }) => {
- await page.goto(createSandboxPagePath(extension, pageCode))
+ const entrypointResponse = page.waitForResponse(
+ response => response.url().endsWith('/runtime/returnsModule/entrypoint.js')
+ )
+ const stylesheetResponse = page.waitForResponse(
+ response => response.url().endsWith('/runtime/returnsModule/stylesheet.css')
+ )
+
+ await page.goto(createSandboxDescriptorPagePath(extension, pageCode))
await expect(page).toHaveURL(/mode=page/u)
await expect(page).toHaveURL(new RegExp(`pageCode=${pageCode}`, 'u'))
+ await expect(page).toHaveURL(/descriptor=/u)
+ expect((await entrypointResponse).status()).toBe(200)
+ expect((await stylesheetResponse).status()).toBe(200)
await expect(page.getByRole('button', { name: 'Возвраты' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Создать возврат' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Список возвратов' })).toBeVisible()
+ await expect.poll(() => page.getByRole('heading', {
+ name: 'Список возвратов',
+ }).evaluate((heading) => {
+ const pageSection = heading.closest('section')
+
+ return pageSection ? getComputedStyle(pageSection).display : null
+ })).toBe('flex')
await expect.poll(async () => {
const snapshot = await readSandboxSnapshot(page)
diff --git a/packages/v1-sandbox/tests/sdk/automation/browser.test.ts b/packages/v1-sandbox/tests/sdk/automation/browser.test.ts
index ae882fa3..4e5a36ae 100644
--- a/packages/v1-sandbox/tests/sdk/automation/browser.test.ts
+++ b/packages/v1-sandbox/tests/sdk/automation/browser.test.ts
@@ -190,6 +190,43 @@ describe('browser sandbox mounting', () => {
expect(document.querySelector('#app')).toBeNull()
})
+ test('launches extension from a runtime descriptor object', async () => {
+ const descriptor = {
+ baseUrl: 'http://extension.test/runtime/',
+ code: 'returns-extension',
+ entrypoint: 'worker.js',
+ pages: ['returns'],
+ stylesheet: null,
+ targets: [],
+ }
+ const bridge = createLaunchBridge()
+ const createLaunchUrl = vi.fn((config) => {
+ const params = new URLSearchParams({
+ descriptor: JSON.stringify(config.descriptor),
+ mode: config.mode ?? 'widget',
+ })
+
+ return `/?${params.toString()}`
+ })
+
+ bridge.createLaunchUrl = createLaunchUrl
+ installSandboxAppMock(bridge)
+
+ const sandbox = await launchSandboxExtension({
+ descriptor,
+ mode: 'page',
+ })
+
+ expect(createLaunchUrl).toHaveBeenCalledWith({
+ descriptor,
+ mode: 'page',
+ })
+ expect(JSON.parse(new URLSearchParams(window.location.search).get('descriptor') ?? ''))
+ .toEqual(descriptor)
+
+ sandbox.unmount()
+ })
+
test('waits for bridge and reports timeout', async () => {
const bridge = createLaunchBridge()
diff --git a/packages/v1-sandbox/tests/sdk/automation/playwright.test.ts b/packages/v1-sandbox/tests/sdk/automation/playwright.test.ts
index e67577d3..c27344b9 100644
--- a/packages/v1-sandbox/tests/sdk/automation/playwright.test.ts
+++ b/packages/v1-sandbox/tests/sdk/automation/playwright.test.ts
@@ -26,6 +26,27 @@ import {
} from '@/automation/playwright'
describe('playwright automation helpers', () => {
+ test('creates sandbox path from runtime descriptor', () => {
+ const descriptor = {
+ baseUrl: 'https://extension.test/runtime/',
+ code: 'settings-extension',
+ entrypoint: 'worker.js',
+ pages: ['settings'],
+ stylesheet: null,
+ targets: ['order/card:common.after' as const],
+ }
+ const path = createSandboxPagePath({
+ descriptor,
+ pageCode: 'settings',
+ sandboxBaseUrl: 'http://127.0.0.1:4173',
+ })
+ const url = new URL(path, 'http://127.0.0.1:4173')
+
+ expect(JSON.parse(url.searchParams.get('descriptor') ?? '')).toEqual(descriptor)
+ expect(url.searchParams.has('manifestUrl')).toBe(false)
+ expect(url.searchParams.has('extensionUrl')).toBe(false)
+ })
+
test('creates page sandbox path from direct extension entrypoint', () => {
const path = createSandboxPagePath({
extensionUrl: 'http://127.0.0.1:5173/web/endpoint/endpoint.worker.ts',
@@ -228,6 +249,35 @@ describe('playwright automation helpers', () => {
expect(page.waitForURL).not.toHaveBeenCalled()
})
+ test('waits for a serialized runtime descriptor', async () => {
+ const descriptor = {
+ baseUrl: 'https://extension.test/runtime/',
+ code: 'returns-extension',
+ entrypoint: 'worker.js',
+ pages: ['returns'],
+ stylesheet: null,
+ targets: [],
+ }
+ const page = {
+ evaluate: vi.fn(async () => undefined),
+ waitForFunction: vi.fn(async () => undefined),
+ waitForURL: vi.fn(async (matcher: (url: URL) => boolean) => {
+ const url = new URL('http://sandbox.test/')
+
+ url.searchParams.set('descriptor', JSON.stringify(descriptor))
+ url.searchParams.set('mode', 'page')
+ expect(matcher(url)).toBe(true)
+ }),
+ } as unknown as SandboxPlaywrightPage
+
+ await launchSandboxExtension(page, {
+ descriptor,
+ mode: 'page',
+ })
+
+ expect(page.waitForURL).toHaveBeenCalledOnce()
+ })
+
test('reads sandbox snapshot from page global', async () => {
const snapshot = {
host: {
diff --git a/packages/v1-sandbox/tests/sdk/sandbox-ui/App.test.ts b/packages/v1-sandbox/tests/sdk/sandbox-ui/App.test.ts
index 179cc51b..412924ee 100644
--- a/packages/v1-sandbox/tests/sdk/sandbox-ui/App.test.ts
+++ b/packages/v1-sandbox/tests/sdk/sandbox-ui/App.test.ts
@@ -292,12 +292,15 @@ const createExtensionSource = (
descriptor: Partial = {}
): SandboxExtensionSource => ({
descriptor: {
+ baseUrl: 'http://extension.test/',
+ code: 'demo-extension',
entrypoint: 'http://extension.test/extension/demo/script',
pages: [],
- runner: 'worker',
stylesheet: null,
- targets: ['order/card:common.before'],
- uuid: 'demo-extension',
+ targets: [
+ 'order/card:common.before',
+ 'order/card:common.after',
+ ],
...descriptor,
},
entrypoint: new URL('http://extension.test/extension/demo/script'),
@@ -381,14 +384,43 @@ const selectOption = async (
await fireEvent.click(screen.getByRole('option', { name: option }))
}
+const openDescriptorJsonEditor = async (
+ dialog: HTMLElement
+): Promise => {
+ const toggle = within(dialog).getByRole('button', { name: 'JSON' })
+
+ if (toggle.getAttribute('aria-pressed') !== 'true') {
+ await fireEvent.click(toggle)
+ }
+
+ return within(dialog).getByRole('textbox', {
+ name: 'JSON дескриптора',
+ }) as HTMLTextAreaElement
+}
+
const configurePageLaunch = async (
dialog: HTMLElement,
- manifestUrl: string,
+ baseUrl: string,
pageCode: string
) => {
+ const descriptor = {
+ baseUrl,
+ code: 'demo-extension',
+ entrypoint: '/extension/demo/script',
+ pages: [pageCode],
+ stylesheet: null,
+ targets: [],
+ }
+
+ await fireEvent.update(within(dialog).getByRole('textbox', {
+ name: 'Код модуля',
+ }), descriptor.code)
await fireEvent.update(within(dialog).getByRole('textbox', {
- name: 'Манифест / URL расширения',
- }), manifestUrl)
+ name: 'Базовый URL',
+ }), descriptor.baseUrl)
+ await fireEvent.update(within(dialog).getByRole('textbox', {
+ name: 'Entrypoint',
+ }), descriptor.entrypoint)
await selectOption(dialog, 'Режим', 'Страница')
await fireEvent.update(within(dialog).getByRole('textbox', {
name: 'Код страницы',
@@ -458,6 +490,110 @@ test('does not show the widget run summary on onboarding', async () => {
})).toBeNull()
})
+test('reports an invalid descriptor without falling back to legacy urls', async () => {
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
+ const descriptor = encodeURIComponent(JSON.stringify({
+ baseUrl: 'http://extension.test/',
+ code: 'demo-extension',
+ entrypoint: 'http://extension.test/runtime/worker.js',
+ pages: [],
+ runner: 'worker',
+ stylesheet: null,
+ targets: ['order/card:common.before'],
+ }))
+
+ await renderAppWithRuntime(
+ `/?descriptor=${descriptor}`
+ + '&manifestUrl=http%3A%2F%2Fextension.test%2Fextension%2Flegacy'
+ + '&mode=widget'
+ )
+
+ expect(alertSpy).toHaveBeenCalledWith(
+ 'Не удалось запустить расширение\n\n[sandbox:descriptor] Invalid extension descriptor.'
+ )
+ expect(resolveSandboxExtensionSourceMock).not.toHaveBeenCalled()
+ expect(fakeWorkers).toHaveLength(0)
+})
+
+test('loads and preserves a formatted runtime descriptor', async () => {
+ const descriptor = {
+ baseUrl: 'http://extension.test/',
+ code: 'descriptor-extension',
+ entrypoint: 'http://extension.test/runtime/worker.js',
+ pages: ['settings'],
+ stylesheet: 'http://cdn.extension.test/runtime/extension.css',
+ targets: ['order/card:common.after' as const],
+ }
+ const source = createExtensionSource({
+ ...descriptor,
+ })
+
+ source.entrypoint = new URL(descriptor.entrypoint)
+ source.httpBaseUrl = 'http://extension.test/'
+ source.manifestUrl = null
+ resolveSandboxExtensionSourceMock.mockResolvedValue(source)
+
+ const { endpoint } = await renderAppWithRuntime(
+ `/?descriptor=${encodeURIComponent(JSON.stringify(descriptor))}`
+ + '&manifestUrl=http%3A%2F%2Fextension.test%2Fextension%2Flegacy'
+ + '&mode=page&pageCode=settings'
+ )
+
+ await waitFor(() => {
+ expect(endpoint.call.run).toHaveBeenCalledOnce()
+ })
+ expect(resolveSandboxExtensionSourceMock).toHaveBeenCalledWith(expect.objectContaining({
+ descriptor,
+ }))
+ expect(fakeWorkers[0]?.postMessage).toHaveBeenCalledWith(expect.objectContaining({
+ extensionUrl: descriptor.entrypoint,
+ }), expect.any(Array))
+
+ const dialog = await openDevPanel()
+ const descriptorInput = await openDescriptorJsonEditor(dialog)
+
+ expect(JSON.parse(descriptorInput.value)).toEqual(descriptor)
+ await selectOption(dialog, 'Выбранная фикстура', 'Заказ с доставкой')
+ expect(JSON.parse(descriptorInput.value)).toEqual(descriptor)
+
+ const launchUrl = new URL(
+ window[SANDBOX_LAUNCH_BRIDGE_GLOBAL_KEY]?.createLaunchUrl({
+ fixture: 'order-with-delivery',
+ }) ?? ''
+ )
+
+ expect(JSON.parse(launchUrl.searchParams.get('descriptor') ?? '')).toEqual(descriptor)
+ expect(launchUrl.searchParams.has('manifestUrl')).toBe(false)
+ expect(launchUrl.searchParams.has('extensionUrl')).toBe(false)
+})
+
+test('uses descriptor widget targets when the dev panel targets were not changed', async () => {
+ await renderAppWithRuntime('/?manifestUrl=&mode=widget')
+
+ const dialog = await openDevPanel()
+ const descriptor = {
+ baseUrl: 'http://extension.test/',
+ code: 'descriptor-widget',
+ entrypoint: 'http://extension.test/runtime/worker.js',
+ pages: [],
+ stylesheet: null,
+ targets: ['order/card:common.after'],
+ }
+
+ const descriptorInput = await openDescriptorJsonEditor(dialog)
+
+ await fireEvent.update(descriptorInput, JSON.stringify(descriptor))
+ expect(within(dialog).queryByPlaceholderText('Выберите места встраивания')).toBeNull()
+
+ await fireEvent.click(within(dialog).getByRole('button', { name: 'JSON' }))
+
+ const targets = within(dialog).getByPlaceholderText(
+ 'Выберите места встраивания'
+ ) as HTMLInputElement
+
+ expect(targets.value).toBe('order/card:common.after')
+})
+
test('does not show the widget run summary in page mode', async () => {
resolveSandboxExtensionSourceMock.mockResolvedValue(createExtensionSource({
pages: ['settings'],
@@ -498,7 +634,7 @@ test('blocks page extension with unknown page code', async () => {
expect(endpoint.call.run).not.toHaveBeenCalled()
})
-test('warns about page-only descriptor in explicit widget mode and continues mounting', async () => {
+test('blocks page-only descriptor in explicit widget mode', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
resolveSandboxExtensionSourceMock.mockResolvedValue(createExtensionSource({
@@ -512,10 +648,10 @@ test('warns about page-only descriptor in explicit widget mode and continues mou
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith(
- 'Проверьте режим запуска\n\nРасширение содержит страницы: settings. Сейчас выбран режим «Виджеты»; если ожидается страница, выберите режим «Страница».'
+ 'Место встраивания расширения не найдено\n\nРасширение не поддерживает выбранные места встраивания: order/card:common.before, order/card:common.after.'
)
- expect(endpoint.call.run).toHaveBeenCalled()
})
+ expect(endpoint.call.run).not.toHaveBeenCalled()
})
test('shows runtime error when worker bootstrap reports failure', async () => {
@@ -867,13 +1003,18 @@ test('updates dev panel launch fields and reports validation errors', async () =
const applyButton = within(dialog).getByRole('button', {
name: 'Применить',
}) as HTMLButtonElement
- const manifestInput = within(dialog).getByRole('textbox', {
- name: 'Манифест / URL расширения',
- })
expect(applyButton.disabled).toBe(true)
- await fireEvent.update(manifestInput, 'http://extension.test/extension/demo')
+ await fireEvent.update(within(dialog).getByRole('textbox', {
+ name: 'Код модуля',
+ }), 'demo-extension')
+ await fireEvent.update(within(dialog).getByRole('textbox', {
+ name: 'Базовый URL',
+ }), 'http://extension.test/extension/demo')
+ await fireEvent.update(within(dialog).getByRole('textbox', {
+ name: 'Entrypoint',
+ }), '/extension/demo/script')
await selectOption(dialog, 'Режим', 'Страница')
const pageCodeInput = within(dialog).getByRole('textbox', {
@@ -1005,7 +1146,11 @@ test('keeps dev panel open and shows available pages when page code is missing',
expect(resolveSandboxExtensionSourceMock).toHaveBeenCalledOnce()
expect(resolveSandboxExtensionSourceMock).toHaveBeenCalledWith(expect.objectContaining({
- manifestUrl: 'http://extension.test/extension/demo',
+ descriptor: expect.objectContaining({
+ baseUrl: 'http://extension.test/extension/demo',
+ code: 'demo-extension',
+ }),
+ manifestUrl: '',
mode: 'page',
pageCode: 'returns',
}))
@@ -1031,12 +1176,20 @@ test('keeps dev panel open and shows available pages when page code is missing',
name: 'Применить',
}))
await within(dialog).findByRole('alert')
- await fireEvent.update(within(dialog).getByRole('textbox', {
- name: 'Манифест / URL расширения',
- }), 'http://extension.test/extension/changed')
+ const descriptorInput = await openDescriptorJsonEditor(dialog)
+
+ await fireEvent.update(descriptorInput, JSON.stringify({
+ baseUrl: 'http://extension.test/extension/changed',
+ code: 'changed-extension',
+ entrypoint: '/extension/changed/script',
+ pages: ['returns'],
+ stylesheet: null,
+ targets: [],
+ }))
expect(within(dialog).queryByRole('alert')).toBeNull()
+ await fireEvent.click(within(dialog).getByRole('button', { name: 'JSON' }))
await fireEvent.click(within(dialog).getByRole('combobox', { name: 'Режим' }))
await fireEvent.click(screen.getByRole('option', { name: 'Виджеты' }))
diff --git a/packages/v1-sandbox/tests/sdk/sandbox-ui/DevPanel.test.ts b/packages/v1-sandbox/tests/sdk/sandbox-ui/DevPanel.test.ts
index f52ce0f6..0e2e8688 100644
--- a/packages/v1-sandbox/tests/sdk/sandbox-ui/DevPanel.test.ts
+++ b/packages/v1-sandbox/tests/sdk/sandbox-ui/DevPanel.test.ts
@@ -33,6 +33,14 @@ afterEach(() => {
})
test('dev panel updates launch fields and context', async () => {
+ const descriptor = {
+ baseUrl: 'http://extension.test/',
+ code: 'returnsModule',
+ entrypoint: '/extension/id/script',
+ pages: ['returns'],
+ stylesheet: null,
+ targets: [ORDER_SANDBOX_SLOTS[0].target],
+ }
const props = {
activeFixture: 'order-basic',
applyContextJson: vi.fn(async () => undefined),
@@ -48,7 +56,7 @@ test('dev panel updates launch fields and context', async () => {
fixture: 'order-basic',
formatContextJson: vi.fn(),
launchConfigChanged: false,
- manifestUrl: 'http://extension.test/extension/id',
+ manifestUrl: JSON.stringify(descriptor),
mode: 'widget' as SandboxLaunchMode,
pageCode: 'returns',
resetContextJson: vi.fn(),
@@ -74,16 +82,92 @@ test('dev panel updates launch fields and context', async () => {
},
})
+ const descriptorViewToggle = screen.getByRole('button', { name: 'JSON' })
+
+ expect(descriptorViewToggle.getAttribute('aria-pressed')).toBe('false')
+ expect(screen.queryByLabelText('JSON дескриптора')).toBeNull()
+ expect(screen.getByText(
+ 'Заполните поля дескриптора или переключитесь на JSON. Оба представления синхронизированы.'
+ )).toBeInstanceOf(HTMLSpanElement)
+ const codeInput = screen.getByLabelText('Код модуля') as HTMLInputElement
+ const baseUrlInput = screen.getByLabelText('Базовый URL') as HTMLInputElement
+ const entrypointInput = screen.getByLabelText('Entrypoint') as HTMLInputElement
+
+ expect(codeInput.value).toBe('returnsModule')
+ expect(codeInput.placeholder).toBe('Введите код модуля')
+ expect(baseUrlInput.value).toBe('http://extension.test/')
+ expect(baseUrlInput.placeholder).toBe('Введите URL расширения')
+ expect(entrypointInput.value).toBe('/extension/id/script')
+ expect(entrypointInput.placeholder).toBe('Введите entrypoint')
+ await fireEvent.update(screen.getByLabelText('Код модуля'), 'returnsModuleV2')
+ expect(props.setManifestUrl).toHaveBeenCalledWith(JSON.stringify({
+ ...descriptor,
+ code: 'returnsModuleV2',
+ }, null, 2))
+ await fireEvent.update(screen.getByLabelText('Базовый URL'), 'https://cdn.extension.test/')
+ expect(props.setManifestUrl).toHaveBeenCalledWith(JSON.stringify({
+ ...descriptor,
+ baseUrl: 'https://cdn.extension.test/',
+ }, null, 2))
+ await fireEvent.update(screen.getByLabelText('Entrypoint'), 'build/worker.js')
+ expect(props.setManifestUrl).toHaveBeenCalledWith(JSON.stringify({
+ ...descriptor,
+ entrypoint: 'build/worker.js',
+ }, null, 2))
+ const stylesheetInput = screen.getByLabelText('Stylesheet') as HTMLInputElement
+
+ expect(stylesheetInput.value).toBe('')
+ expect(stylesheetInput.placeholder).toBe('Введите stylesheet')
expect(screen.getByRole('button', {
- name: 'URL расширения может указывать на любое стороннее приложение, которое отдаёт расширение, например http://web-extensions-server.simla.local/extension/.',
+ name: 'Оставьте поле пустым, если у расширения нет CSS. Если стили есть, укажите путь к stylesheet.',
+ })).toBeInstanceOf(HTMLButtonElement)
+ await fireEvent.update(stylesheetInput, '/extension/id/stylesheet')
+ expect(props.setManifestUrl).toHaveBeenCalledWith(JSON.stringify({
+ ...descriptor,
+ stylesheet: '/extension/id/stylesheet',
+ }, null, 2))
+
+ await fireEvent.click(descriptorViewToggle)
+ expect(descriptorViewToggle.getAttribute('aria-pressed')).toBe('true')
+ expect(screen.queryByLabelText('Код модуля')).toBeNull()
+ expect(screen.queryByRole('combobox', { name: 'Режим' })).toBeNull()
+ expect(screen.queryByText('Места встраивания виджетов', { exact: true })).toBeNull()
+ expect(screen.getByRole('button', {
+ name: 'Дескриптор содержит code, baseUrl, entrypoint, stylesheet, pages и targets. Entrypoint и stylesheet могут быть относительными к baseUrl.',
})).toBeInstanceOf(HTMLButtonElement)
expect(screen.getByText(
- 'Укажите полный URL расширения по примеру в поле. Замените UUID на значение из extensionrc.json и убедитесь, что получившаяся страница открывается в браузере.'
+ 'Вставьте конфигурацию дескриптора целиком в формате JSON.'
)).toBeInstanceOf(HTMLSpanElement)
- const manifestUrlInput = screen.getByLabelText('Манифест / URL расширения') as HTMLInputElement
+ const descriptorJsonInput = screen.getByLabelText('JSON дескриптора') as HTMLTextAreaElement
+
+ expect(descriptorJsonInput.value).toBe(JSON.stringify(descriptor))
+ expect(JSON.parse(descriptorJsonInput.placeholder)).toEqual({
+ code: 'promoModule',
+ baseUrl: 'http://web-extensions-server.simla.local',
+ entrypoint: '/extension/8ebe1617-d609-43e4-b35a-fbfae011eee3/script',
+ stylesheet: '/extension/8ebe1617-d609-43e4-b35a-fbfae011eee3/stylesheet',
+ targets: [],
+ pages: ['settings'],
+ })
+
+ await rerender({
+ validationErrors: {
+ manifestUrl: 'Invalid descriptor',
+ },
+ })
+ const descriptorAlert = screen.getByRole('alert')
+
+ expect(descriptorAlert.textContent).toBe('Invalid descriptor')
+ expect(descriptorAlert.getAttribute('aria-live')).toBe('assertive')
+ await rerender({ validationErrors: {} })
+
+ await fireEvent.click(descriptorViewToggle)
+ expect(descriptorViewToggle.getAttribute('aria-pressed')).toBe('false')
+ expect(screen.queryByLabelText('JSON дескриптора')).toBeNull()
+ expect(screen.getByLabelText('Код модуля')).toBeInstanceOf(HTMLInputElement)
+ expect(screen.getByRole('combobox', { name: 'Режим' })).toBeInstanceOf(HTMLInputElement)
- expect(manifestUrlInput.placeholder).toContain('http://127.0.0.1:4175/extension/')
expect(screen.getByRole('button', {
name: 'В режиме «Виджеты» виджеты добавляются в выбранные места встраивания. В режиме «Страница» запускается страница по её коду.',
})).toBeInstanceOf(HTMLButtonElement)
@@ -156,14 +240,25 @@ test('dev panel updates launch fields and context', async () => {
expect(props.setFixture).toHaveBeenCalledWith('order-with-delivery')
expect(props.setTargetSelected).toHaveBeenCalledWith(ORDER_SANDBOX_SLOTS[0].target, false)
- await fireEvent.update(manifestUrlInput, 'http://extension.test/extension/changed')
+ await fireEvent.click(descriptorViewToggle)
+ const manifestUrlInput = screen.getByLabelText('JSON дескриптора') as HTMLTextAreaElement
+ const changedDescriptor = JSON.stringify({
+ ...descriptor,
+ entrypoint: '/extension/changed/script',
+ pages: ['settings'],
+ targets: [],
+ })
+
+ await fireEvent.update(manifestUrlInput, changedDescriptor)
+ expect(props.setMode).toHaveBeenCalledWith('page')
+ expect(props.setPageCode).toHaveBeenCalledWith('settings')
await fireEvent.update(contextJsonEditor, '{"order/card":{"number":"999C"}}')
await fireEvent.click(screen.getByRole('button', { name: 'Форматировать' }))
await fireEvent.click(screen.getByRole('button', { name: 'Отменить изменения' }))
await fireEvent.click(screen.getByRole('button', { name: 'Скачать JSON' }))
await fireEvent.click(screen.getByRole('button', { name: 'Применить' }))
- expect(props.setManifestUrl).toHaveBeenCalledWith('http://extension.test/extension/changed')
+ expect(props.setManifestUrl).toHaveBeenCalledWith(changedDescriptor)
expect(props.setContextJson).toHaveBeenCalledWith('{"order/card":{"number":"999C"}}')
expect(props.formatContextJson).toHaveBeenCalledOnce()
expect(props.resetContextJson).toHaveBeenCalledOnce()
@@ -175,6 +270,8 @@ test('dev panel updates launch fields and context', async () => {
})
expect((screen.getByRole('button', { name: 'Применить' }) as HTMLButtonElement).disabled).toBe(true)
+ await fireEvent.click(descriptorViewToggle)
+
await rerender({
applyingLaunchConfig: false,
contextJsonChanged: true,
diff --git a/packages/v1-sandbox/tests/sdk/sandbox-ui/ExtensionOnboarding.test.ts b/packages/v1-sandbox/tests/sdk/sandbox-ui/ExtensionOnboarding.test.ts
index 9be48085..8b7d42df 100644
--- a/packages/v1-sandbox/tests/sdk/sandbox-ui/ExtensionOnboarding.test.ts
+++ b/packages/v1-sandbox/tests/sdk/sandbox-ui/ExtensionOnboarding.test.ts
@@ -50,35 +50,20 @@ test('extension onboarding opens sandbox controls', async () => {
expect(within(onboarding).getByRole('heading', {
name: 'Подключите внешнее расширение',
})).toBeInstanceOf(HTMLHeadingElement)
- expect(within(onboarding).getAllByRole('listitem')).toHaveLength(3)
- expect(within(onboarding).getByText('Запустите сервер расширения.')).toBeInstanceOf(HTMLLIElement)
- expect(within(onboarding).getByText('Запустите песочницу.')).toBeInstanceOf(HTMLLIElement)
- expect(within(within(onboarding).getAllByRole('listitem')[2]).getByText(
- 'http://127.0.0.1:4175/extension/'
- )).toBeInstanceOf(HTMLElement)
+ expect(within(onboarding).getByText(/"code": "promoModule"/u)).toBeInstanceOf(HTMLElement)
+ expect(within(onboarding).getByText(/"baseUrl": "http:\/\/web-extensions-server\.simla\.local"/u)).toBeInstanceOf(HTMLElement)
+ expect(within(onboarding).getByText(/"entrypoint": "\/extension\/8ebe1617-d609-43e4-b35a-fbfae011eee3\/script"/u)).toBeInstanceOf(HTMLElement)
+ expect(within(onboarding).getByText(/"stylesheet": "\/extension\/8ebe1617-d609-43e4-b35a-fbfae011eee3\/stylesheet"/u)).toBeInstanceOf(HTMLElement)
+ expect(within(onboarding).getByText(/"pages": \[/u)).toBeInstanceOf(HTMLElement)
expect(within(onboarding).getByText(/Дополнительный сервер запускать не требуется\./u)).toBeInstanceOf(HTMLElement)
- expect(within(onboarding).getByText('Затем выберите режим: Виджеты или Страница.')).toBeInstanceOf(HTMLElement)
-
- const collapseButton = within(onboarding).getByRole('button', {
- name: 'Шаблон URL запуска',
- })
-
- expect(collapseButton.getAttribute('aria-expanded')).toBe('false')
-
- await fireEvent.click(collapseButton)
+ expect(within(onboarding).getByText(
+ 'При заполнении отдельных полей затем выберите режим: Виджеты или Страница.'
+ )).toBeInstanceOf(HTMLElement)
- expect(collapseButton.getAttribute('aria-expanded')).toBe('true')
- expect(within(onboarding).getByText('Это шаблон, а не готовая ссылка.', {
- exact: false,
- })).toBeInstanceOf(HTMLElement)
- expect(within(onboarding).getByText('%sandbox-url%')).toBeInstanceOf(HTMLElement)
- expect(within(onboarding).getByText('%extension-url%')).toBeInstanceOf(HTMLElement)
- expect(within(onboarding).getByText('%extension-id%')).toBeInstanceOf(HTMLElement)
- expect(within(onboarding).getByText('UUID расширения.')).toBeInstanceOf(HTMLElement)
- expect(within(onboarding).getByText('Пример полного URL расширения:')).toBeInstanceOf(HTMLElement)
+ expect(within(onboarding).queryByText('Легаси-шаблон URL запуска')).toBeNull()
await fireEvent.click(within(onboarding).getByRole('button', {
- name: 'Открыть песочницу',
+ name: 'Открыть дев-панель',
}))
expect(openDevPanel).toHaveBeenCalledOnce()
diff --git a/packages/v1-sandbox/tests/sdk/sandbox-ui/createSandbox.test.ts b/packages/v1-sandbox/tests/sdk/sandbox-ui/createSandbox.test.ts
index cab9dd68..7604571f 100644
--- a/packages/v1-sandbox/tests/sdk/sandbox-ui/createSandbox.test.ts
+++ b/packages/v1-sandbox/tests/sdk/sandbox-ui/createSandbox.test.ts
@@ -98,7 +98,8 @@ test('opens dev panel and validates launch config input', async () => {
expect(applyButton.disabled).toBe(true)
- const manifestInput = within(dialog).getByLabelText('Манифест / URL расширения') as HTMLInputElement
+ await fireEvent.click(within(dialog).getByRole('button', { name: 'JSON' }))
+ const manifestInput = within(dialog).getByLabelText('JSON дескриптора') as HTMLTextAreaElement
await fireEvent.update(manifestInput, 'http://extension.test/not-extension/id')
await nextTick()
@@ -108,7 +109,7 @@ test('opens dev panel and validates launch config input', async () => {
await fireEvent.click(applyButton)
expect((await within(dialog).findByRole('alert')).textContent?.trim())
- .toBe('URL должен быть вида %extension-url%/extension/%extension-id%.')
+ .toBe('Введите валидный дескриптор с полями code, baseUrl, entrypoint, stylesheet, pages и targets. Адреса ресурсов могут быть относительными к абсолютному http/https baseUrl.')
})
test('installs launch bridge and creates launch urls from current config', () => {
diff --git a/packages/v1-sandbox/tests/sdk/scenario/descriptor.test.ts b/packages/v1-sandbox/tests/sdk/scenario/descriptor.test.ts
new file mode 100644
index 00000000..ded612af
--- /dev/null
+++ b/packages/v1-sandbox/tests/sdk/scenario/descriptor.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from 'vitest'
+
+import {
+ parseSandboxExtensionDescriptor,
+ parseSandboxExtensionDescriptorJson,
+} from '@/scenario'
+
+const descriptor = {
+ baseUrl: 'https://extension.test/runtime/',
+ code: 'returns-module',
+ entrypoint: 'worker.js',
+ pages: ['returns'],
+ stylesheet: 'styles.css',
+ targets: ['order/card:common.after'],
+}
+
+describe('sandbox extension descriptor', () => {
+ test('parses a strict runtime descriptor', () => {
+ expect(parseSandboxExtensionDescriptor(descriptor)).toEqual(descriptor)
+ expect(parseSandboxExtensionDescriptorJson(JSON.stringify(descriptor))).toEqual(descriptor)
+ })
+
+ test('rejects runner and other fields', () => {
+ expect(() => parseSandboxExtensionDescriptor({
+ ...descriptor,
+ runner: 'worker',
+ })).toThrow('Invalid extension descriptor')
+ })
+
+ test('requires an absolute http/https base url and resolvable resource paths', () => {
+ expect(() => parseSandboxExtensionDescriptor({
+ ...descriptor,
+ baseUrl: '/runtime/',
+ })).toThrow('Invalid extension descriptor')
+ expect(() => parseSandboxExtensionDescriptor({
+ ...descriptor,
+ stylesheet: 'ftp://extension.test/styles.css',
+ })).toThrow('Invalid extension descriptor')
+ expect(() => parseSandboxExtensionDescriptor({
+ ...descriptor,
+ entrypoint: 'http://[invalid',
+ })).toThrow('Invalid extension descriptor')
+ expect(() => parseSandboxExtensionDescriptor({
+ ...descriptor,
+ pages: [''],
+ })).toThrow('Invalid extension descriptor')
+ })
+
+ test('rejects malformed descriptor json', () => {
+ expect(() => parseSandboxExtensionDescriptorJson('{')).toThrow(
+ 'Invalid extension descriptor JSON'
+ )
+ })
+})
diff --git a/packages/v1-sandbox/tests/sdk/scenario/launch.test.ts b/packages/v1-sandbox/tests/sdk/scenario/launch.test.ts
index 3df959b5..a28bdcc4 100644
--- a/packages/v1-sandbox/tests/sdk/scenario/launch.test.ts
+++ b/packages/v1-sandbox/tests/sdk/scenario/launch.test.ts
@@ -58,6 +58,63 @@ test('parses multiple widget targets and page mode', () => {
})
})
+test('round-trips descriptor and uses its widget targets by default', () => {
+ const descriptor = {
+ baseUrl: 'https://extension.test/runtime/',
+ code: 'descriptor-widget',
+ entrypoint: 'worker.js',
+ pages: [],
+ stylesheet: null,
+ targets: ['order/card:common.after' as const],
+ }
+ const url = updateSandboxLaunchQuery({
+ descriptor,
+ extensionUrl: 'http://legacy.test/extension/demo',
+ fixture: 'order-basic',
+ manifestUrl: 'http://legacy.test/extension/demo',
+ mode: 'widget',
+ pageCode: 'orders-dashboard',
+ targets: descriptor.targets,
+ widgetId: 'sandbox-widget',
+ }, 'http://sandbox.test/')
+ const parsed = parseSandboxLaunchConfig(new URLSearchParams({
+ descriptor: url.searchParams.get('descriptor') ?? '',
+ }))
+
+ expect(url.searchParams.has('extensionUrl')).toBe(false)
+ expect(url.searchParams.has('manifestUrl')).toBe(false)
+ expect(parsed.descriptor).toEqual(descriptor)
+ expect(parsed.targets).toEqual(['order/card:common.after'])
+})
+
+test('rejects invalid descriptors without falling back to legacy url', () => {
+ expect(() => parseSandboxLaunchConfig(new URLSearchParams({
+ descriptor: JSON.stringify({
+ entrypoint: '/relative.js',
+ pages: [],
+ stylesheet: null,
+ targets: [],
+ uuid: 'demo',
+ }),
+ manifestUrl: 'http://extension.test/extension/demo',
+ }))).toThrow('Invalid extension descriptor')
+})
+
+test('removes stale descriptor when serializing legacy config', () => {
+ const url = updateSandboxLaunchQuery({
+ extensionUrl: '',
+ fixture: 'order-basic',
+ manifestUrl: 'http://extension.test/extension/demo',
+ mode: 'widget',
+ pageCode: 'orders-dashboard',
+ targets: ['order/card:common.after'],
+ widgetId: 'sandbox-widget',
+ }, `http://sandbox.test/?descriptor=${encodeURIComponent('{}')}`)
+
+ expect(url.searchParams.has('descriptor')).toBe(false)
+ expect(url.searchParams.get('manifestUrl')).toBe('http://extension.test/extension/demo')
+})
+
test('falls back to safe defaults for empty and unsupported values', () => {
const config = parseSandboxLaunchConfig(new URLSearchParams({
extensionUrl: '',
diff --git a/packages/v1-sandbox/tests/sdk/scenario/manifest.test.ts b/packages/v1-sandbox/tests/sdk/scenario/manifest.test.ts
index 26ccda7a..f7a02106 100644
--- a/packages/v1-sandbox/tests/sdk/scenario/manifest.test.ts
+++ b/packages/v1-sandbox/tests/sdk/scenario/manifest.test.ts
@@ -40,6 +40,35 @@ const response = (
} as Response)
describe('resolveSandboxExtensionSource', () => {
+ test('uses descriptor resources directly without fetching legacy manifest', async () => {
+ const fetcher = vi.fn()
+ const descriptor = {
+ baseUrl: 'https://extension.test/runtime/',
+ code: 'returns-module',
+ entrypoint: 'worker.js',
+ pages: ['returns'],
+ stylesheet: 'styles.css',
+ targets: ['order/card:common.after' as const],
+ }
+
+ const source = await resolveSandboxExtensionSource(config({
+ descriptor,
+ manifestUrl: 'http://legacy.test/extension/demo',
+ }), { fetch: fetcher as typeof fetch })
+
+ expect(source).toEqual({
+ descriptor: {
+ ...descriptor,
+ entrypoint: 'https://extension.test/runtime/worker.js',
+ stylesheet: 'https://extension.test/runtime/styles.css',
+ },
+ entrypoint: new URL('https://extension.test/runtime/worker.js'),
+ httpBaseUrl: 'https://extension.test/runtime/',
+ manifestUrl: null,
+ })
+ expect(fetcher).not.toHaveBeenCalled()
+ })
+
test('rejects failed manifest response', async () => {
const fetcher = vi.fn(async () => response('', {
contentType: 'text/plain',
@@ -123,8 +152,7 @@ describe('resolveSandboxExtensionSource', () => {
expect(source.entrypoint.href).toBe('http://sandbox.test/extensions/html/assets/entry.js')
expect(source.httpBaseUrl).toBe('http://sandbox.test/')
- expect(source.descriptor.runner).toBe('worker')
- expect(source.descriptor.uuid).toBe('sandbox-widget')
+ expect(source.descriptor.code).toBe('sandbox-widget')
expect(fetcher).toHaveBeenCalledTimes(2)
})
@@ -160,7 +188,6 @@ describe('resolveSandboxExtensionSource', () => {
}), { fetch: fetcher as typeof fetch })
expect(source.descriptor.pages).toEqual(['returns'])
- expect(source.descriptor.runner).toBe('worker')
expect(source.descriptor.stylesheet).toBe(
'http://extension-host.test/extension/module-id/stylesheet'
)
@@ -221,7 +248,7 @@ describe('resolveSandboxExtensionSource', () => {
})
expect(source.descriptor.entrypoint).toBe('/src/direct-extension.js')
- expect(source.descriptor.uuid).toBe('sandbox-widget')
+ expect(source.descriptor.code).toBe('sandbox-widget')
expect(source.entrypoint.href).toBe('http://localhost/src/direct-extension.js')
expect(source.httpBaseUrl).toBeNull()
})
diff --git a/packages/v1-sandbox/tests/sdk/scenario/validation.test.ts b/packages/v1-sandbox/tests/sdk/scenario/validation.test.ts
index a20b8c5f..f8f170c8 100644
--- a/packages/v1-sandbox/tests/sdk/scenario/validation.test.ts
+++ b/packages/v1-sandbox/tests/sdk/scenario/validation.test.ts
@@ -11,9 +11,8 @@ const messages: DevPanelValidationMessages = {
contextJsonRootObject: 'Context JSON must be an object.',
contextJsonUnknownContext: context => `Unknown context "${context}".`,
fixture: 'Unknown fixture.',
- manifestUrlEndpoint: 'Manifest URL must include /extension/%extension-id%.',
- manifestUrlFormat: 'Manifest URL must be an absolute http/https URL.',
- manifestUrlRequired: 'Manifest URL is required.',
+ manifestUrlDescriptor: 'Extension descriptor is invalid.',
+ manifestUrlRequired: 'Extension descriptor is required.',
mode: 'Unknown mode.',
pageCodeFormat: 'Page code has an invalid format.',
pageCodeRequired: 'Page code is required.',
@@ -21,9 +20,18 @@ const messages: DevPanelValidationMessages = {
targetUnknown: target => `Unknown target "${target}".`,
}
+const descriptor = {
+ baseUrl: 'https://extension.test/runtime/',
+ code: 'returns-module',
+ entrypoint: 'worker.js',
+ pages: ['returns'],
+ stylesheet: null,
+ targets: ['order/card:common.after'],
+}
+
const validLaunchInput = {
fixture: 'order-basic',
- manifestUrl: 'http://extension.test/extension/module-id',
+ manifestUrl: JSON.stringify(descriptor),
mode: 'widget',
pageCode: 'returns',
targets: ['order/card:common.after'],
@@ -37,37 +45,64 @@ test('rejects empty manifest urls', () => {
expect(result).toEqual({
errors: {
- manifestUrl: 'Manifest URL is required.',
+ manifestUrl: 'Extension descriptor is required.',
},
success: false,
})
})
-test('rejects malformed manifest urls', () => {
+test('rejects direct extension urls', () => {
const invalidUrl = validateLaunchConfigInput({
...validLaunchInput,
manifestUrl: 'extension.test/module-id',
}, messages)
- const missingEndpoint = validateLaunchConfigInput({
- ...validLaunchInput,
- manifestUrl: 'http://extension.test/module-id',
- }, messages)
-
expect(invalidUrl).toEqual({
errors: {
- manifestUrl: 'Manifest URL must be an absolute http/https URL.',
+ manifestUrl: 'Extension descriptor is invalid.',
},
success: false,
})
- expect(missingEndpoint).toEqual({
+})
+
+test('accepts a strict extension descriptor', () => {
+ const result = validateLaunchConfigInput({
+ ...validLaunchInput,
+ manifestUrl: JSON.stringify(descriptor),
+ }, messages)
+
+ expect(result).toEqual({
+ data: {
+ ...validLaunchInput,
+ descriptor,
+ manifestUrl: '',
+ },
+ success: true,
+ })
+})
+
+test('rejects malformed or non-strict extension descriptors', () => {
+ const result = validateLaunchConfigInput({
+ ...validLaunchInput,
+ manifestUrl: JSON.stringify({
+ entrypoint: 'https://extension.test/runtime/worker.js',
+ baseUrl: 'https://extension.test/',
+ code: 'returns-module',
+ pages: [],
+ runner: 'worker',
+ stylesheet: null,
+ targets: [],
+ }),
+ }, messages)
+
+ expect(result).toEqual({
errors: {
- manifestUrl: 'Manifest URL must include /extension/%extension-id%.',
+ manifestUrl: 'Extension descriptor is invalid.',
},
success: false,
})
})
-test('rejects manifest urls with unsupported protocols', () => {
+test('rejects non-json direct urls with unsupported protocols', () => {
const result = validateLaunchConfigInput({
...validLaunchInput,
manifestUrl: 'ftp://extension.test/extension/module-id',
@@ -75,7 +110,7 @@ test('rejects manifest urls with unsupported protocols', () => {
expect(result).toEqual({
errors: {
- manifestUrl: 'Manifest URL must be an absolute http/https URL.',
+ manifestUrl: 'Extension descriptor is invalid.',
},
success: false,
})
diff --git a/packages/v1-sandbox/vite.config.e2e-extension.ts b/packages/v1-sandbox/vite.config.e2e-extension.ts
index c1c067e5..42d01a8c 100644
--- a/packages/v1-sandbox/vite.config.e2e-extension.ts
+++ b/packages/v1-sandbox/vite.config.e2e-extension.ts
@@ -41,22 +41,78 @@ const extensionFixtureServer = (): Plugin => ({
}
const url = new URL(request.url, 'http://extension.test')
+ const returnsRuntimePath = '/runtime/returnsModule'
+ const returnsAction = url.pathname.startsWith(`${returnsRuntimePath}/`)
+ ? url.pathname.slice(returnsRuntimePath.length)
+ : url.pathname
const isReturnsAction = [
'/return',
'/returns',
'/returns/save',
- ].includes(url.pathname)
+ ].includes(returnsAction)
if (request.method === 'POST' && isReturnsAction) {
setCorsHeaders(request.headers.origin, response)
readPayload(request).then(payload => {
- const result = resolveReturnsBackendRequest(url.pathname, payload)
+ const result = resolveReturnsBackendRequest(returnsAction, payload)
send(response, result.status, JSON.stringify(result.body), 'application/json; charset=utf-8')
}).catch(next)
return
}
+ const runtimeMatch = url.pathname.match(
+ /^\/runtime\/([^/]+)\/(entrypoint\.js|stylesheet\.css|assets\/.*)$/u
+ )
+
+ if (runtimeMatch) {
+ const fixture = runtimeMatch[1]
+ const action = runtimeMatch[2]
+ const entry = entries.get(fixture)
+
+ if (!entry) {
+ send(response, 404, 'Not found', 'text/plain; charset=utf-8')
+ return
+ }
+
+ setCorsHeaders(request.headers.origin, response)
+
+ if (request.method === 'OPTIONS') {
+ response.writeHead(204)
+ response.end()
+ return
+ }
+
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
+ send(response, 405, 'Method not allowed', 'text/plain; charset=utf-8')
+ return
+ }
+
+ if (action === 'entrypoint.js') {
+ sendFile(response, path.resolve(outputRoot, entry.file), request.method === 'HEAD')
+ return
+ }
+
+ if (action === 'stylesheet.css') {
+ const stylesheet = entry.css?.[0]
+
+ if (!stylesheet) {
+ send(response, 404, 'Not found', 'text/plain; charset=utf-8')
+ return
+ }
+
+ sendFile(response, path.resolve(outputRoot, stylesheet), request.method === 'HEAD')
+ return
+ }
+
+ sendFile(
+ response,
+ path.resolve(outputRoot, action),
+ request.method === 'HEAD'
+ )
+ return
+ }
+
const match = url.pathname.match(/^\/extension\/([^/]+)(\/.*)?$/u)
if (!match) {
@@ -83,6 +139,10 @@ const extensionFixtureServer = (): Plugin => ({
const action = match[2] ?? ''
const entry = entries.get(fixture)
+ if (!entry) {
+ throw new Error(`E2E extension build not found ${fixture}`)
+ }
+
if ((request.method === 'GET' || request.method === 'HEAD') && action === '') {
const manifest = [
'',
diff --git a/packages/v1-sandbox/vitest.config.playwright.ts b/packages/v1-sandbox/vitest.config.playwright.ts
index 63f3a8fc..6534ba0f 100644
--- a/packages/v1-sandbox/vitest.config.playwright.ts
+++ b/packages/v1-sandbox/vitest.config.playwright.ts
@@ -20,12 +20,14 @@ const sandboxBaseURL = process.env.SANDBOX_BASE_URL
const baseURL = sandboxBaseURL || 'http://127.0.0.1:4173'
const configuredExtensionBaseURL = process.env.SANDBOX_EXTENSION_URL
const extensionBaseURL = configuredExtensionBaseURL || 'http://127.0.0.1:4175/extension/'
+const runtimeExtensionBaseURL = 'http://127.0.0.1:4175/'
const promoModule = JSON.parse(fs.readFileSync(
path.resolve(__dirname, 'tests/__fixtures__/extensions/promoModule/extensionrc.json'),
'utf8'
)) as { uuid: string }
process.env.SANDBOX_EXTENSION_URL = extensionBaseURL
+process.env.SANDBOX_RUNTIME_EXTENSION_URL = runtimeExtensionBaseURL
const webServer = [
...(sandboxBaseURL ? [] : [{
@@ -33,11 +35,11 @@ const webServer = [
url: baseURL,
reuseExistingServer: !process.env.CI,
}]),
- ...(configuredExtensionBaseURL ? [] : [{
+ {
command: 'yarn build:e2e-extensions && yarn serve:e2e-extensions',
- url: new URL(promoModule.uuid, extensionBaseURL).href,
+ url: new URL(`extension/${promoModule.uuid}`, runtimeExtensionBaseURL).href,
reuseExistingServer: !process.env.CI,
- }]),
+ },
]
export default defineConfig({
diff --git a/packages/v1-sandbox/vitest.config.ts b/packages/v1-sandbox/vitest.config.ts
index 0124fbcb..fd945592 100644
--- a/packages/v1-sandbox/vitest.config.ts
+++ b/packages/v1-sandbox/vitest.config.ts
@@ -11,6 +11,7 @@ export default mergeConfig(basic as ViteUserConfig, defineConfig({
exclude: ['tests/**/*.test.browser.ts'],
include: ['tests/**/*.test.ts'],
reporters: ['dot'],
+ setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'istanbul',
include: ['src/**/*.{ts,tsx,vue}'],
diff --git a/packages/v1-sandbox/vitest.setup.ts b/packages/v1-sandbox/vitest.setup.ts
new file mode 100644
index 00000000..84dd5e10
--- /dev/null
+++ b/packages/v1-sandbox/vitest.setup.ts
@@ -0,0 +1,2 @@
+// https://github.com/jsdom/jsdom/issues/1695
+window.HTMLElement.prototype.scrollIntoView = () => { }
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 296bf7a1..94417214 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2078,6 +2078,7 @@ __metadata:
less: "npm:^4.4.2"
nyc: "npm:^18.0.0"
playwright: "npm:1.58.2"
+ tslib: "npm:^2.8.1"
typescript: "npm:^5.9.3"
vite: "npm:^7.3.5"
vite-plugin-dts: "npm:^4.5.4"
@@ -11171,7 +11172,7 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:^2.0.1":
+"tslib@npm:^2.0.1, tslib@npm:^2.8.1":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7