Files
LangBot/web/tests/e2e/sandbox-scope-tooltip.spec.ts
T
RockChinQ 273b1ea3cf Merge master into dev/4.11.x and preserve plugin runner architecture
Reconcile migration branches without rewriting published revisions; retain additive Codex, monitoring, provider and platform fixes. Keep dynamic runner schemas and Host ownership, restore compatibility regressions, and preserve safe model-test error handling.
2026-09-16 08:13:07 +00:00

269 lines
9.6 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import { getBoxScopeContext } from '../../src/app/home/pipelines/components/pipeline-form/BoxScopeContext';
const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const forcedHint = '已强制使用全局沙箱,无法修改作用域。';
const customHint = '已强制使用固定沙箱作用域,无法修改作用域。';
// Exercise the real generic renderer, not a retired local-agent page. Host
// execution scope is not a plugin configuration field; see fixtures/README.md.
async function openConditionForm(page: Page, available: boolean, forced = '') {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'zh-Hans' },
});
const params = new URLSearchParams({ available: String(available), forced });
await page.goto(`/tests/e2e/fixtures/dynamic-form.html?${params}`);
const scope = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('沙箱作用域', { exact: true }) })
.getByRole('combobox');
await expect(scope).toBeVisible();
return scope;
}
async function updateContext(page: Page, context: Record<string, unknown>) {
await page.evaluate((detail) => {
window.dispatchEvent(new CustomEvent('test-form-context', { detail }));
}, context);
}
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) {
for (const hint of [unavailableHint, forcedHint, customHint]) {
await expect(page.getByRole('button', { name: hint })).toHaveCount(0);
}
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
test.describe('sandbox condition renderer compatibility (UI fixture only)', () => {
for (const scenario of [
{ name: 'Box disabled', forced: '' },
{ name: 'Box disconnected', forced: '' },
{
name: 'unavailable Box takes precedence over forced global',
forced: '{global}',
},
{
name: 'unavailable Box takes precedence over forced fixed',
forced: '{pipeline_id}',
},
]) {
test(scenario.name, async ({ page }) => {
const scope = await openConditionForm(page, false, 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} ', '{pipeline_id}']) {
test(`available context explains the restriction without rewriting config (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openConditionForm(page, true, forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
// The generic renderer must not coerce values based on a Host policy.
await expect(scope).toHaveText('每个会话(推荐)');
await expectWarning(
page,
forced.trim() === '{global}' ? forcedHint : customHint,
);
await expect(
page.getByRole('button', { name: unavailableHint }),
).toHaveCount(0);
await expect(page.getByTestId('saved-values')).toContainText(
'"box-session-id-template":"{launcher_type}_{launcher_id}"',
);
});
}
for (const forced of ['', ' ']) {
test(`available unforced context is editable (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openConditionForm(page, 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(`caller context updates the warning without remounting (${forced || 'unforced'})`, async ({
page,
}) => {
const scope = await openConditionForm(page, false, forced);
const original = await scope.elementHandle();
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await page.mouse.move(0, 0);
await updateContext(page, getBoxScopeContext(true, forced));
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);
await updateContext(page, getBoxScopeContext(false, forced));
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
expect(await original!.evaluate((element) => element.isConnected)).toBe(
true,
);
});
}
test('manifest secret and number aliases retain live show/disable conditions', async ({
page,
}) => {
await openConditionForm(page, true);
const secret = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('Plugin secret', { exact: true }) })
.locator('input');
await expect(secret).toHaveAttribute('type', 'password');
await expect(secret).toHaveCSS('pointer-events', 'auto');
await expect(page.getByRole('spinbutton')).toHaveValue('3');
await secret.fill('fixture-only-not-a-credential');
await updateContext(page, { locked: true });
await expect(secret).toHaveCSS('pointer-events', 'none');
await expectWarning(page, 'Live reason wins');
await page.mouse.move(0, 0);
await page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('Mode', { exact: true }) })
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Hidden', exact: true }).click();
await expect(secret).toHaveCount(0);
await expect(page.getByRole('spinbutton')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Live reason wins' }),
).toHaveCount(0);
});
});
test('pipeline plugin schema round-trips without resurrecting Core sandbox ownership', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'en-US' },
});
const runnerId = 'plugin:qa/conditions/default';
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI Feature' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: [
{
name: 'id',
type: 'select',
label: { en_US: 'Runner' },
default: runnerId,
options: [
{ name: runnerId, label: { en_US: 'Fixture runner' } },
],
},
],
},
{
name: runnerId,
label: { en_US: 'Plugin settings' },
config: [
{
name: 'note',
type: 'string',
label: { en_US: 'Plugin note' },
default: '',
show_if: {
field: '__system.pipeline_id',
operator: 'eq',
value: 'pipeline-scope-fixture',
},
},
],
},
],
},
],
},
},
}),
);
const pipeline = {
uuid: 'pipeline-scope-fixture',
name: 'Plugin scope fixture',
description: '',
emoji: '⚙️',
is_default: false,
config: {
ai: {
runner: { id: runnerId },
runner_config: { [runnerId]: { note: 'original' } },
},
trigger: {},
safety: {},
output: {},
},
};
let saved: typeof pipeline | undefined;
await page.route(
'**/api/v1/pipelines/pipeline-scope-fixture',
async (route) => {
if (route.request().method() === 'PUT') {
saved = route.request().postDataJSON();
await route.fulfill({ json: { code: 0, data: {} } });
} else {
await route.fulfill({ json: { code: 0, data: { pipeline } } });
}
},
);
await page.goto('/home/pipelines?id=pipeline-scope-fixture');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('region', { name: 'Configuration' }).getByRole('textbox'),
).toHaveValue('original');
await expect(page.getByText('Sandbox Scope', { exact: true })).toHaveCount(0);
await page
.getByRole('region', { name: 'Configuration' })
.getByRole('textbox')
.fill('round-trip');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect
.poll(() => saved?.config.ai)
.toEqual({
runner: { id: runnerId },
runner_config: { [runnerId]: { note: 'round-trip' } },
});
});