From 1141be09c3755ad5ea833a8d59c23e1eac49153b Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:37:25 +0000 Subject: [PATCH] fix(pipelines): show the actual sandbox scope restriction --- .../templates/metadata/pipeline/ai.yaml | 47 +++- .../dynamic-form/DynamicFormComponent.tsx | 59 +--- .../dynamic-form/DynamicFormConditions.ts | 71 +++++ .../pipeline-form/BoxScopeContext.ts | 14 + .../pipeline-form/PipelineFormComponent.tsx | 6 +- web/src/app/infra/entities/form/dynamic.ts | 7 + web/tests/e2e/sandbox-scope-tooltip.spec.ts | 232 ++++++++++++++++ web/tests/unit/sandbox-scope-tooltip.test.mjs | 252 ++++++++++++++++++ 8 files changed, 623 insertions(+), 65 deletions(-) create mode 100644 web/src/app/home/components/dynamic-form/DynamicFormConditions.ts create mode 100644 web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts create mode 100644 web/tests/e2e/sandbox-scope-tooltip.spec.ts create mode 100644 web/tests/unit/sandbox-scope-tooltip.test.mjs diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index e16a33f49..dfa588bec 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -143,18 +143,41 @@ stages: operator: eq value: false disabled_tooltip: - en_US: >- - Sandbox scope can't be changed: either the Box sandbox is disabled - or unavailable (enable it in config.yaml with box.enabled = true and - ensure the runtime is reachable), or this deployment pins all - pipelines to a fixed scope. - zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。" - zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。" - ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。" - vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi." - th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว" - es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único." - ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров." + en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope." + zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。" + zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。" + ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。" + vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi." + th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต" + es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance." + ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область." + disabled_tooltip_overrides: + - when: + field: __system.box_scope_forced_global + operator: eq + value: true + tooltip: + en_US: "A global sandbox is enforced; the scope cannot be changed." + zh_Hans: "已强制使用全局沙箱,无法修改作用域。" + zh_Hant: "已強制使用全域沙箱,無法修改作用域。" + ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。" + vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi." + th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้" + es_ES: "Se impone un sandbox global; no se puede cambiar el alcance." + ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя." + - when: + field: __system.box_scope_forced + operator: eq + value: true + tooltip: + en_US: "A fixed sandbox scope is enforced; the scope cannot be changed." + zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。" + zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。" + ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。" + vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi." + th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้" + es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance." + ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя." type: select required: false default: "{launcher_type}_{launcher_id}" diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 039f1df0e..4f398c686 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -46,30 +46,10 @@ import { } from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; - -/** - * Resolve the value referenced by a `show_if.field` string. - * - * Fields prefixed with `__system.` are looked up in the caller-supplied - * `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`). - * All other field names are resolved from the live form values first, then - * fall back to `externalDependentValues`. - */ -function resolveShowIfValue( - field: string, - watchedValues: Record, - externalDependentValues?: Record, - systemContext?: Record, -): unknown { - if (field.startsWith(SYSTEM_FIELD_PREFIX)) { - const key = field.slice(SYSTEM_FIELD_PREFIX.length); - return systemContext?.[key]; - } - if (watchedValues[field] !== undefined) { - return watchedValues[field]; - } - return externalDependentValues?.[field]; -} +import { + resolveDisabledState, + resolveShowIfValue, +} from './DynamicFormConditions'; type DynamicFormValueSpec = Pick< IDynamicFormItemSchema, @@ -675,40 +655,19 @@ export default function DynamicFormComponent({ } } - // ``disable_if`` mirrors ``show_if``'s evaluator but instead of - // hiding the field, leaves it visible and inert. Use it when the - // operator needs to see that the field exists yet cannot edit it - // under the current runtime state (e.g. sandbox-bound fields when - // Box is disabled). - let isDisabledByCondition = false; - if (config.disable_if) { - const dependValue = resolveShowIfValue( - config.disable_if.field, + // Keep locked fields visible and resolve only the applicable reason. + const { isDisabledByCondition, disabledTooltip: tooltip } = + resolveDisabledState( + config, watchedValues as Record, externalDependentValues, systemContext, ); - const cond = config.disable_if; - if (cond.operator === 'eq' && dependValue === cond.value) { - isDisabledByCondition = true; - } else if (cond.operator === 'neq' && dependValue !== cond.value) { - isDisabledByCondition = true; - } else if ( - cond.operator === 'in' && - Array.isArray(cond.value) && - cond.value.includes(dependValue) - ) { - isDisabledByCondition = true; - } - } // All fields are disabled when editing (creation_settings are // immutable) or when ``disable_if`` matches. const isFieldDisabled = !!isEditing || isDisabledByCondition; - const disabledTooltip = - isDisabledByCondition && config.disabled_tooltip - ? extractI18nObject(config.disabled_tooltip) - : ''; + const disabledTooltip = tooltip ? extractI18nObject(tooltip) : ''; const renderDisabledTooltipIcon = () => disabledTooltip ? ( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormConditions.ts b/web/src/app/home/components/dynamic-form/DynamicFormConditions.ts new file mode 100644 index 000000000..f2c44b6ce --- /dev/null +++ b/web/src/app/home/components/dynamic-form/DynamicFormConditions.ts @@ -0,0 +1,71 @@ +import { + SYSTEM_FIELD_PREFIX, + type IDynamicFormItemSchema, + type IShowIfCondition, +} from '@/app/infra/entities/form/dynamic'; + +/** System references use caller context; other fields prefer live form values. */ +export function resolveShowIfValue( + field: string, + watchedValues: Record, + externalDependentValues?: Record, + systemContext?: Record, +): unknown { + if (field.startsWith(SYSTEM_FIELD_PREFIX)) { + return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)]; + } + if (watchedValues[field] !== undefined) { + return watchedValues[field]; + } + return externalDependentValues?.[field]; +} + +export function matchesFormCondition( + condition: IShowIfCondition, + watchedValues: Record, + externalDependentValues?: Record, + systemContext?: Record, +): boolean { + const value = resolveShowIfValue( + condition.field, + watchedValues, + externalDependentValues, + systemContext, + ); + switch (condition.operator) { + case 'eq': + return value === condition.value; + case 'neq': + return value !== condition.value; + case 'in': + return Array.isArray(condition.value) && condition.value.includes(value); + default: + return false; + } +} + +export function resolveDisabledState( + config: Pick< + IDynamicFormItemSchema, + 'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides' + >, + watchedValues: Record, + externalDependentValues?: Record, + systemContext?: Record, +) { + const matches = (condition: IShowIfCondition) => + matchesFormCondition( + condition, + watchedValues, + externalDependentValues, + systemContext, + ); + const isDisabledByCondition = + !!config.disable_if && matches(config.disable_if); + const disabledTooltip = isDisabledByCondition + ? (config.disabled_tooltip_overrides?.find((override) => + matches(override.when), + )?.tooltip ?? config.disabled_tooltip) + : undefined; + return { isDisabledByCondition, disabledTooltip }; +} diff --git a/web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts b/web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts new file mode 100644 index 000000000..7c7b7fdd2 --- /dev/null +++ b/web/src/app/home/pipelines/components/pipeline-form/BoxScopeContext.ts @@ -0,0 +1,14 @@ +/** Unavailability takes priority over the deployment's scope restriction. */ +export function getBoxScopeContext( + boxAvailable: boolean, + forcedTemplate?: string, +) { + forcedTemplate = forcedTemplate?.trim(); + return { + box_available: boxAvailable, + box_scope_editable: boxAvailable && !forcedTemplate, + // Only expose forced-scope reasons when the sandbox is available. + box_scope_forced: boxAvailable && !!forcedTemplate, + box_scope_forced_global: boxAvailable && forcedTemplate === '{global}', + }; +} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 9a3c0c124..66ae7e238 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -8,6 +8,7 @@ import { import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent'; import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; +import { getBoxScopeContext } from './BoxScopeContext'; import { systemInfo } from '@/app/infra/http'; import { Button } from '@/components/ui/button'; import { useForm } from 'react-hook-form'; @@ -425,13 +426,12 @@ export default function PipelineFormComponent({ // 2. the deployment pins all pipelines to a fixed scope via // ``system.limitation.force_box_session_id_template`` (SaaS). const forcedBoxTemplate = - systemInfo.limitation?.force_box_session_id_template || ''; + systemInfo.limitation?.force_box_session_id_template?.trim() || ''; const boxScopeForced = !!forcedBoxTemplate; const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent'; const stageSystemContext = isLocalAgentStage ? { - box_available: boxAvailable, - box_scope_editable: boxAvailable && !boxScopeForced, + ...getBoxScopeContext(boxAvailable, forcedBoxTemplate), pipeline_id: pipelineId, } : undefined; diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 6f945b678..b99981fc7 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -39,6 +39,13 @@ export interface IDynamicFormItemSchema { disable_if?: IShowIfCondition; /** Tooltip shown next to the field label when ``disable_if`` is active. */ disabled_tooltip?: I18nObject; + /** Optional overrides evaluated in order when ``disable_if`` matches. + * The first matching ``when`` wins; otherwise use ``disabled_tooltip``. + * Conditions use the same operators and value lookup as ``disable_if``. */ + disabled_tooltip_overrides?: { + when: IShowIfCondition; + tooltip: I18nObject; + }[]; /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */ scopes?: string[]; diff --git a/web/tests/e2e/sandbox-scope-tooltip.spec.ts b/web/tests/e2e/sandbox-scope-tooltip.spec.ts new file mode 100644 index 000000000..ffc90a779 --- /dev/null +++ b/web/tests/e2e/sandbox-scope-tooltip.spec.ts @@ -0,0 +1,232 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +// UI fixtures only: real app/components, intercepted APIs, no production Box. +// Load the shipped metadata rather than reproducing its tooltip conditions. +const requireFromTest = createRequire(__filename); +const { load } = createRequire(requireFromTest.resolve('eslint'))( + 'js-yaml', +) as { + load: (source: string) => unknown; +}; +const aiMetadata = load( + readFileSync( + resolve( + __dirname, + '../../../src/langbot/templates/metadata/pipeline/ai.yaml', + ), + 'utf8', + ), +); +const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const forcedHint = '已强制使用全局沙箱,无法修改作用域。'; + +interface BoxState { + enabled: boolean; + available: boolean; +} + +async function openPipeline(page: Page, box: BoxState, forced = '') { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_language: 'zh-Hans' }, + }); + await page.route('**/api/v1/system/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + debug: false, + version: 'sandbox-scope-ui-fixture', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + allow_modify_login_info: true, + disable_models_service: false, + limitation: { + max_bots: -1, + max_pipelines: -1, + max_extensions: -1, + force_box_session_id_template: forced, + }, + outbound_ips: [], + wizard_status: 'completed', + wizard_progress: null, + }, + }, + }), + ); + await page.route('**/api/v1/box/status', (route) => + route.fulfill({ + json: { + code: 0, + data: { + ...box, + profile: 'UI fixture only', + recent_error_count: 0, + active_sessions: 0, + managed_processes: 0, + session_ttl_sec: 3600, + backend: { name: 'ui-fixture', available: box.available }, + }, + }, + }), + ); + await page.route(/\/api\/v1\/tools(?:\?.*)?$/, (route) => + route.fulfill({ json: { code: 0, data: { tools: [] } } }), + ); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ json: { code: 0, data: { configs: [aiMetadata] } } }), + ); + await page.route('**/api/v1/pipelines/sandbox-scope-fixture', (route) => + route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'sandbox-scope-fixture', + name: 'Sandbox scope — UI fixture only', + description: '', + emoji: '⚙️', + is_default: false, + config: { + ai: { + runner: { runner: 'local-agent' }, + 'local-agent': { + 'box-session-id-template': '{launcher_type}_{launcher_id}', + }, + }, + trigger: {}, + safety: {}, + output: {}, + }, + }, + }, + }, + }), + ); + await page.goto('/home/pipelines?id=sandbox-scope-fixture'); + await page.getByRole('button', { name: 'AI 能力', exact: true }).click(); + // DynamicForm gates this control through its wrapper's pointer-events, + // and its label targets that wrapper rather than the nested select. + const scope = page + .locator('[data-slot="form-item"]') + .filter({ has: page.getByText('沙箱作用域', { exact: true }) }) + .getByRole('combobox'); + await expect(scope).toBeVisible(); + return scope; +} + +async function expectWarning(page: Page, hint: string) { + const warning = page.getByRole('button', { name: hint, exact: true }); + await expect(warning).toBeVisible(); + await warning.hover(); + await expect(page.getByRole('tooltip')).toHaveText(hint); +} + +async function expectNoWarning(page: Page) { + await expect(page.getByRole('button', { name: unavailableHint })).toHaveCount( + 0, + ); + await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(0); + await expect(page.getByRole('tooltip')).toHaveCount(0); +} + +test.describe('sandbox scope disabled reason (UI fixtures only)', () => { + for (const scenario of [ + { name: 'Box disabled', enabled: false, available: false, forced: '' }, + { name: 'Box disconnected', enabled: true, available: false, forced: '' }, + { + name: 'unavailable Box takes precedence over forced global', + enabled: true, + available: false, + forced: '{global}', + }, + ]) { + test(scenario.name, async ({ page }) => { + const scope = await openPipeline(page, scenario, scenario.forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + await expect(page.getByRole('button', { name: forcedHint })).toHaveCount( + 0, + ); + }); + } + + for (const forced of ['{global}', ' {global} ']) { + test(`available Box with forced global explains the deployment restriction (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openPipeline( + page, + { enabled: true, available: true }, + forced, + ); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expect(scope).toHaveText('全局(所有人共享)'); + await expectWarning(page, forcedHint); + await expect( + page.getByRole('button', { name: unavailableHint }), + ).toHaveCount(0); + }); + } + + for (const forced of ['', ' ']) { + test(`available and unforced Box is editable without a disabled warning (${JSON.stringify(forced)})`, async ({ + page, + }) => { + const scope = await openPipeline( + page, + { enabled: true, available: true }, + forced, + ); + await expect(scope).toHaveCSS('pointer-events', 'auto'); + await expect(scope).toHaveText('每个会话(推荐)'); + await expectNoWarning(page); + await scope.click(); + await page + .getByRole('option', { name: '全局(所有人共享)', exact: true }) + .click(); + await expect(scope).toHaveText('全局(所有人共享)'); + await expectNoWarning(page); + }); + } + + for (const forced of ['', '{global}']) { + test(`Box status polls update the warning without remounting (${forced || 'unforced'})`, async ({ + page, + }) => { + await page.clock.install(); + const box = { enabled: true, available: false }; + const scope = await openPipeline(page, box, forced); + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await page.mouse.move(0, 0); + + const recovered = page.waitForResponse('**/api/v1/box/status'); + box.available = true; + await page.clock.fastForward(31_000); + await recovered; + if (forced) { + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, forcedHint); + } else { + await expect(scope).toHaveCSS('pointer-events', 'auto'); + await expectNoWarning(page); + } + await page.mouse.move(0, 0); + + const disconnected = page.waitForResponse('**/api/v1/box/status'); + box.available = false; + await page.clock.fastForward(31_000); + await disconnected; + await expect(scope).toHaveCSS('pointer-events', 'none'); + await expectWarning(page, unavailableHint); + await expect(page.getByRole('tooltip')).not.toContainText('强制'); + }); + } +}); diff --git a/web/tests/unit/sandbox-scope-tooltip.test.mjs b/web/tests/unit/sandbox-scope-tooltip.test.mjs new file mode 100644 index 000000000..8a0b9cc66 --- /dev/null +++ b/web/tests/unit/sandbox-scope-tooltip.test.mjs @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { load } = createRequire(require.resolve('eslint'))('js-yaml'); +const metadata = load( + fs.readFileSync( + new URL( + '../../../src/langbot/templates/metadata/pipeline/ai.yaml', + import.meta.url, + ), + 'utf8', + ), +); +const scope = metadata.stages + .find((stage) => stage.name === 'local-agent') + .config.find((item) => item.name === 'box-session-id-template'); +const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。'; +const globalForced = '已强制使用全局沙箱,无法修改作用域。'; +const customForced = '已强制使用固定沙箱作用域,无法修改作用域。'; + +function loadSource(relativePath) { + const filename = new URL(`../../src/${relativePath}`, import.meta.url); + assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`); + const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loaded = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + (name) => { + if (name === '@/app/infra/entities/form/dynamic') + return loadSource('app/infra/entities/form/dynamic.ts'); + throw new Error(`Unexpected runtime import: ${name}`); + }, + loaded, + loaded.exports, + ); + return loaded.exports; +} + +function policies() { + return { + ...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'), + ...loadSource( + 'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts', + ), + }; +} + +function scopeState(available, forcedTemplate) { + const { getBoxScopeContext, resolveDisabledState } = policies(); + return resolveDisabledState( + scope, + {}, + undefined, + getBoxScopeContext(available, forcedTemplate), + ); +} + +test('sandbox default tooltip explains only unavailability', () => { + assert.equal(scope.disabled_tooltip.zh_Hans, unavailable); +}); + +for (const [name, available, template, expected] of [ + ['Box disabled', false, '', unavailable], + ['Box disconnected', false, undefined, unavailable], + [ + 'unavailable takes precedence over forced global', + false, + '{global}', + unavailable, + ], + [ + 'unavailable takes precedence over forced custom', + false, + '{pipeline_id}', + unavailable, + ], + ['available forced global', true, '{global}', globalForced], + ['available padded forced global', true, ' {global} ', globalForced], + ['available whitespace-only editable', true, ' ', undefined], + ['available forced custom', true, '{pipeline_id}', customForced], + ['available forced literal', true, 'tenant-sandbox', customForced], + ['available editable', true, '', undefined], + ['available without limitation', true, undefined, undefined], +]) { + test(name, () => { + const state = scopeState(available, template); + assert.equal(state.isDisabledByCondition, expected !== undefined); + assert.equal(state.disabledTooltip?.zh_Hans, expected); + }); +} + +test('reason follows availability and forced-scope transitions without mutating metadata', () => { + const snapshot = structuredClone(scope); + for (const [available, template, expected] of [ + [false, '{global}', unavailable], + [true, '{global}', globalForced], + [true, '{pipeline_id}', customForced], + [true, '', undefined], + [false, '', unavailable], + [true, '', undefined], + ]) { + assert.equal( + scopeState(available, template).disabledTooltip?.zh_Hans, + expected, + ); + } + assert.deepEqual(scope, snapshot); +}); + +test('all sandbox reason variants preserve the eight metadata locales', () => { + const locales = [ + 'en_US', + 'zh_Hans', + 'zh_Hant', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'es_ES', + 'ru_RU', + ].sort(); + assert.equal(scope.disabled_tooltip_overrides?.length, 2); + const messages = [ + scope.disabled_tooltip, + ...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip), + ]; + for (const message of messages) { + assert.deepEqual(Object.keys(message).sort(), locales); + for (const locale of locales) assert.ok(message[locale].trim(), locale); + } + for (const locale of locales) { + assert.equal( + new Set(messages.map((message) => message[locale])).size, + 3, + locale, + ); + assert.equal( + scopeState(false, '{global}').disabledTooltip[locale], + messages[0][locale], + ); + assert.equal( + scopeState(true, '{global}').disabledTooltip[locale], + messages[1][locale], + ); + assert.equal( + scopeState(true, '{pipeline_id}').disabledTooltip[locale], + messages[2][locale], + ); + } +}); + +test('ordinary static disabled tooltip remains compatible', () => { + const { resolveDisabledState } = policies(); + const tooltip = { en_US: 'Read only' }; + const config = { + disable_if: { field: 'locked', operator: 'eq', value: true }, + disabled_tooltip: tooltip, + }; + assert.deepEqual(resolveDisabledState(config, { locked: true }), { + isDisabledByCondition: true, + disabledTooltip: tooltip, + }); + assert.deepEqual(resolveDisabledState(config, { locked: false }), { + isDisabledByCondition: false, + disabledTooltip: undefined, + }); + assert.equal( + resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip, + undefined, + ); + assert.equal( + resolveDisabledState({ disable_if: config.disable_if }, { locked: true }) + .disabledTooltip, + undefined, + ); +}); + +test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => { + const { matchesFormCondition, resolveDisabledState } = policies(); + const watched = { mode: 'live', empty: null, '__system.locked': false }; + const external = { mode: 'external', fallback: 3, empty: 'external' }; + const system = { locked: true }; + for (const [condition, expected] of [ + [{ field: 'mode', operator: 'eq', value: 'live' }, true], + [{ field: 'mode', operator: 'eq', value: 'external' }, false], + [{ field: 'fallback', operator: 'neq', value: 4 }, true], + [{ field: 'fallback', operator: 'in', value: [2, 3] }, true], + [{ field: 'fallback', operator: 'in', value: '3' }, false], + [{ field: 'fallback', operator: 'eq', value: '3' }, false], + [{ field: 'empty', operator: 'eq', value: null }, true], + [{ field: '__system.locked', operator: 'eq', value: true }, true], + [{ field: 'absent', operator: 'eq', value: true }, false], + ]) + assert.equal( + matchesFormCondition(condition, watched, external, system), + expected, + ); + const config = { + disable_if: { field: '__system.locked', operator: 'eq', value: true }, + disabled_tooltip: { en_US: 'Default' }, + disabled_tooltip_overrides: [ + { + when: { field: 'mode', operator: 'eq', value: 'external' }, + tooltip: { en_US: 'Wrong' }, + }, + { + when: { field: 'fallback', operator: 'in', value: [3] }, + tooltip: { en_US: 'First match' }, + }, + { + when: { field: 'mode', operator: 'neq', value: 'external' }, + tooltip: { en_US: 'Later match' }, + }, + ], + }; + assert.equal( + resolveDisabledState(config, watched, external, system).disabledTooltip + .en_US, + 'First match', + ); + assert.equal( + resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US, + 'Later match', + ); + assert.equal( + resolveDisabledState(config, watched, external, { locked: false }) + .disabledTooltip, + undefined, + ); + assert.equal( + resolveDisabledState( + { ...config, disabled_tooltip_overrides: [] }, + watched, + external, + system, + ).disabledTooltip.en_US, + 'Default', + ); + const unmatched = { + ...config, + disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]], + }; + assert.equal( + resolveDisabledState(unmatched, watched, external, system).disabledTooltip + .en_US, + 'Default', + ); +});