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