mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
fix(ci): align workspace fixtures and constrain mobile tours
This commit is contained in:
@@ -243,7 +243,11 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid)
|
||||
adapters = self.ap.platform_mgr.get_available_adapters_info()
|
||||
if snapshot is not None:
|
||||
adapters = [a for a in adapters if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name']))]
|
||||
adapters = [
|
||||
a
|
||||
for a in adapters
|
||||
if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name']))
|
||||
]
|
||||
return self.success(data={'adapters': adapters})
|
||||
|
||||
@self.route('/<adapter_name>', methods=['GET'])
|
||||
|
||||
@@ -7,8 +7,8 @@ from ..platform.adapter_names import canonical_adapter_name
|
||||
def platform_feature_names(adapter: str) -> set[str]:
|
||||
canonical = canonical_adapter_name(adapter)
|
||||
names = {adapter, canonical}
|
||||
if adapter.lower() == "line":
|
||||
names.update({"line", "LINE"})
|
||||
if adapter.lower() == 'line':
|
||||
names.update({'line', 'LINE'})
|
||||
if canonical.endswith('-omni'):
|
||||
names.add(canonical[:-5])
|
||||
names.add(canonical[:-5] + '-eba')
|
||||
|
||||
@@ -427,7 +427,10 @@ class Application:
|
||||
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
monitoring_enabled = auto_cleanup_cfg.get('enabled', True) or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud'
|
||||
monitoring_enabled = (
|
||||
auto_cleanup_cfg.get('enabled', True)
|
||||
or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud'
|
||||
)
|
||||
retention_days = self._get_positive_int_config(
|
||||
auto_cleanup_cfg.get('retention_days', 30),
|
||||
default=30,
|
||||
|
||||
@@ -35,6 +35,7 @@ async def box_security_api():
|
||||
'owner-token': SimpleNamespace(uuid='owner-account', user='[email protected]'),
|
||||
}
|
||||
application = Mock()
|
||||
application._message_language_cache = None
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
|
||||
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
|
||||
|
||||
@@ -88,6 +88,7 @@ async def plugin_security_api(plugin_module):
|
||||
}
|
||||
|
||||
application = Mock()
|
||||
application._message_language_cache = None
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
|
||||
|
||||
@@ -329,7 +329,7 @@ class TestModelEndpoints:
|
||||
assert response.status_code == 400
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 'model_test_failed'
|
||||
assert data['msg'] == '模型请求失败: API key invalid'
|
||||
assert data['msg'] == 'Model request failed: API key invalid'
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
|
||||
@@ -27,6 +27,7 @@ async def skills_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application._message_language_cache = None
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
@@ -76,4 +77,6 @@ async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.status_code == 403
|
||||
payload = await response.get_json()
|
||||
assert payload['code'] == 'entitlement_unavailable'
|
||||
|
||||
@@ -24,6 +24,7 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
||||
from langbot.pkg.cloud.support_admin import SupportAdminSessionService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
|
||||
from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
@@ -124,6 +125,9 @@ class _TenantUow:
|
||||
self._transaction = await self.session.begin()
|
||||
return self
|
||||
|
||||
async def execute(self, statement):
|
||||
return await self.session.execute(statement)
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
try:
|
||||
if exc_type is None:
|
||||
@@ -173,6 +177,7 @@ async def support_admin_api(tmp_path):
|
||||
tables=[
|
||||
User.__table__,
|
||||
Workspace.__table__,
|
||||
WorkspaceMetadata.__table__,
|
||||
WorkspaceExecutionState.__table__,
|
||||
WorkspaceMembership.__table__,
|
||||
SupportAdminTemporarySession.__table__,
|
||||
|
||||
@@ -46,6 +46,7 @@ async def _resolve(account_uuid: str, _workspace_uuid: str | None) -> SimpleName
|
||||
async def totp_admin_api():
|
||||
accounts = {'member-token': SimpleNamespace(uuid=MEMBER_UUID, user='[email protected]')}
|
||||
application = Mock()
|
||||
application._message_language_cache = None
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
||||
application.instance_config.data = {'system': {'allow_modify_login_info': True}}
|
||||
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
|
||||
|
||||
@@ -27,6 +27,7 @@ async def space_oauth_api():
|
||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||
)
|
||||
application = Mock()
|
||||
application._message_language_cache = None
|
||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||
application.directory_projection_service = None
|
||||
application.persistence_mgr = None
|
||||
|
||||
@@ -39,6 +39,7 @@ def make_bot(event_bindings: list[dict], config=None, agent=None):
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(
|
||||
uuid='bot-1',
|
||||
adapter='aiocqhttp',
|
||||
workspace_uuid=TEST_CONTEXT.workspace_uuid,
|
||||
event_bindings=event_bindings,
|
||||
plugin_processors=[],
|
||||
@@ -49,6 +50,7 @@ def make_bot(event_bindings: list[dict], config=None, agent=None):
|
||||
bot.logger = SimpleNamespace(info=AsyncMock(), warning=AsyncMock(), error=AsyncMock(), debug=AsyncMock())
|
||||
bot.adapter = FakeAdapter()
|
||||
bot.ap = SimpleNamespace(
|
||||
workspace_service=SimpleNamespace(get_execution_binding=AsyncMock()),
|
||||
telemetry=SimpleNamespace(execution=counters),
|
||||
agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=agent)),
|
||||
pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(return_value=None)),
|
||||
@@ -505,11 +507,13 @@ async def test_pending_event_cancelled_during_dispatch_never_uploads():
|
||||
|
||||
bot._dispatch_eba_event_to_processor = waiting_dispatch
|
||||
task = asyncio.create_task(bot._handle_platform_event(message_received_event(), FakeAdapter()))
|
||||
await started.wait()
|
||||
await counters.flush()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), timeout=5)
|
||||
await counters.flush()
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await assert_no_upload(counters, manager)
|
||||
|
||||
|
||||
|
||||
@@ -319,8 +319,7 @@ export default function AgentApiToolPicker({
|
||||
const expanded = expandedTool === tool.key;
|
||||
const parameterNames = Object.keys(
|
||||
(tool.parameters.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined) ?? {},
|
||||
Record<string, unknown> | undefined) ?? {},
|
||||
);
|
||||
return (
|
||||
<div key={tool.key} className="bg-background px-3 py-2.5">
|
||||
|
||||
@@ -20,9 +20,7 @@ const RUNNER_INSTALL_INTENT_KEY_PREFIX = 'langbot-runner-install';
|
||||
const RUNNER_INSTALL_INTENT_EVENT = 'langbot-runner-install-change';
|
||||
|
||||
export type RunnerMarketplaceErrorCode =
|
||||
| 'version-unavailable'
|
||||
| 'install-timeout'
|
||||
| 'registration-timeout';
|
||||
'version-unavailable' | 'install-timeout' | 'registration-timeout';
|
||||
|
||||
export class RunnerMarketplaceError extends Error {
|
||||
constructor(public readonly code: RunnerMarketplaceErrorCode) {
|
||||
|
||||
@@ -498,8 +498,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
.then((res) => {
|
||||
const bot = res.bot;
|
||||
const runtimeValues = bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
resolve({
|
||||
adapter: bot.adapter,
|
||||
description: bot.description,
|
||||
@@ -509,11 +508,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
event_bindings: bot.event_bindings ?? [],
|
||||
plugin_processors: bot.plugin_processors ?? [],
|
||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -144,17 +144,9 @@ interface EventBindingsEditorProps {
|
||||
}
|
||||
|
||||
type FilterField =
|
||||
| 'chat_type'
|
||||
| 'chat_id'
|
||||
| 'message_text'
|
||||
| 'message_element_types';
|
||||
'chat_type' | 'chat_id' | 'message_text' | 'message_element_types';
|
||||
type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'contains'
|
||||
| 'not_contains'
|
||||
| 'starts_with'
|
||||
| 'regex';
|
||||
'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'regex';
|
||||
|
||||
interface FilterRow {
|
||||
field: FilterField;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type PromptMessage = {
|
||||
role: string;
|
||||
content?: string | null | JsonValue[];
|
||||
|
||||
@@ -204,19 +204,17 @@ function buildToolSourceGroups(
|
||||
(rightIndex === -1 ? TOOL_SOURCE_ORDER.length : rightIndex)
|
||||
);
|
||||
})
|
||||
.map(
|
||||
([source, providerGroups]): ToolSourceGroup => ({
|
||||
key: source,
|
||||
label: sourceLabels[source] || source,
|
||||
groups: Array.from(providerGroups.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
total: Array.from(providerGroups.values()).reduce(
|
||||
(count, group) => count + group.tools.length,
|
||||
0,
|
||||
),
|
||||
}),
|
||||
);
|
||||
.map(([source, providerGroups]): ToolSourceGroup => ({
|
||||
key: source,
|
||||
label: sourceLabels[source] || source,
|
||||
groups: Array.from(providerGroups.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
total: Array.from(providerGroups.values()).reduce(
|
||||
(count, group) => count + group.tools.length,
|
||||
0,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildMCPToolOwners(servers: MCPServer[]) {
|
||||
|
||||
@@ -45,6 +45,12 @@ export function getTourPosition(
|
||||
? rect.bottom + VIEWPORT_GAP
|
||||
: rect.top - height - VIEWPORT_GAP;
|
||||
}
|
||||
// The mobile sidebar can still be sliding in when its target is measured.
|
||||
// Clamp the card independently of that temporarily off-screen target.
|
||||
left = Math.max(
|
||||
VIEWPORT_GAP,
|
||||
Math.min(left, viewportWidth - width - VIEWPORT_GAP),
|
||||
);
|
||||
top = Math.max(
|
||||
VIEWPORT_GAP,
|
||||
Math.min(top, viewportHeight - height - VIEWPORT_GAP),
|
||||
|
||||
@@ -28,8 +28,7 @@ export interface ProviderModels {
|
||||
}
|
||||
|
||||
export type TestResult =
|
||||
| { success: true; duration: number }
|
||||
| { success: false; message: string };
|
||||
{ success: true; duration: number } | { success: false; message: string };
|
||||
|
||||
export type SelectedScannedModel = {
|
||||
model: ScannedProviderModel;
|
||||
|
||||
@@ -19,11 +19,7 @@ import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
import { getBackendBaseUrl } from '@/app/infra/http/backendUrl';
|
||||
|
||||
export type QrLoginPlatform =
|
||||
| 'feishu'
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'qqofficial';
|
||||
'feishu' | 'weixin' | 'dingtalk' | 'wecombot' | 'qqofficial';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
|
||||
@@ -495,8 +495,7 @@ export default function OperationTracePanel({
|
||||
});
|
||||
const response = await backendClient.downloadFile(url);
|
||||
const disposition = response.headers['content-disposition'] as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const match = disposition?.match(/filename="?([^";\n]+)"?/);
|
||||
const filename = match?.[1] ?? `operation-logs-${Date.now()}.csv`;
|
||||
const blob = new Blob([response.data], {
|
||||
|
||||
@@ -13,9 +13,7 @@ const INSTALL_INTENT_KEY_PREFIX = 'langbot-knowledge-engine-install';
|
||||
const INSTALL_INTENT_EVENT = 'langbot-knowledge-engine-install-change';
|
||||
|
||||
export type KnowledgeEngineMarketplaceErrorCode =
|
||||
| 'version-unavailable'
|
||||
| 'install-timeout'
|
||||
| 'registration-timeout';
|
||||
'version-unavailable' | 'install-timeout' | 'registration-timeout';
|
||||
|
||||
export class KnowledgeEngineMarketplaceError extends Error {
|
||||
constructor(public readonly code: KnowledgeEngineMarketplaceErrorCode) {
|
||||
|
||||
@@ -31,11 +31,7 @@ import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
|
||||
type MCPConnectionState =
|
||||
| 'connected'
|
||||
| 'connecting'
|
||||
| 'error'
|
||||
| 'disabled'
|
||||
| 'disconnected';
|
||||
'connected' | 'connecting' | 'error' | 'disabled' | 'disconnected';
|
||||
|
||||
export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
|
||||
@@ -12,8 +12,7 @@ export interface CallLinkSource {
|
||||
|
||||
/** A record the dashboard can open for a recorded model or tool call. */
|
||||
export type CallTarget =
|
||||
| { kind: 'execution'; row: ExecutionRow }
|
||||
| { kind: 'message'; id: string };
|
||||
{ kind: 'execution'; row: ExecutionRow } | { kind: 'message'; id: string };
|
||||
|
||||
/** Calls are matched against a run's window; clock skew must not lose the link. */
|
||||
const WINDOW_SLACK_MS = 2000;
|
||||
|
||||
@@ -73,10 +73,7 @@ export interface OperationLogRecord {
|
||||
|
||||
/** Verification filter accepted by the listing endpoint. */
|
||||
export type OperationIntegrityFilter =
|
||||
| 'all'
|
||||
| 'issues'
|
||||
| 'hash_mismatch'
|
||||
| 'chain_broken';
|
||||
'all' | 'issues' | 'hash_mismatch' | 'chain_broken';
|
||||
|
||||
export interface OperationLogPage {
|
||||
records: OperationLogRecord[];
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export type WorkspaceRole =
|
||||
| 'owner'
|
||||
| 'admin'
|
||||
| 'developer'
|
||||
| 'operator'
|
||||
| 'viewer';
|
||||
'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
|
||||
|
||||
export interface Workspace {
|
||||
uuid: string;
|
||||
|
||||
@@ -391,8 +391,7 @@ export default function WizardPage() {
|
||||
|
||||
// Restore webhook URLs
|
||||
const runtimeValues = botData.bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
setWebhookUrl((runtimeValues?.webhook_full_url as string) || '');
|
||||
setExtraWebhookUrl(
|
||||
(runtimeValues?.extra_webhook_full_url as string) || '',
|
||||
@@ -735,8 +734,7 @@ export default function WizardPage() {
|
||||
try {
|
||||
const botData = await httpClient.getBot(resp.uuid);
|
||||
const runtimeValues = botData.bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
setWebhookUrl((runtimeValues?.webhook_full_url as string) || '');
|
||||
setExtraWebhookUrl(
|
||||
(runtimeValues?.extra_webhook_full_url as string) || '',
|
||||
@@ -902,8 +900,7 @@ export default function WizardPage() {
|
||||
try {
|
||||
const botData = await httpClient.getBot(createdBotUuid);
|
||||
const runtimeValues = botData.bot.adapter_runtime_values as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
setWebhookUrl((runtimeValues?.webhook_full_url as string) || '');
|
||||
setExtraWebhookUrl(
|
||||
(runtimeValues?.extra_webhook_full_url as string) || '',
|
||||
|
||||
@@ -2566,8 +2566,7 @@ const enUS = {
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2617,8 +2617,7 @@ const esES = {
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2589,8 +2589,7 @@ const ruRU = {
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2510,8 +2510,7 @@ const thTH = {
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2548,8 +2548,7 @@ const viVN = {
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -2406,8 +2406,7 @@ const zhHans = {
|
||||
upgradePlan: '切换或升级计划',
|
||||
ossSingletonDescription:
|
||||
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
||||
cloudManagedDescription:
|
||||
'此工作区托管于 LangBot Cloud。',
|
||||
cloudManagedDescription: '此工作区托管于 LangBot Cloud。',
|
||||
loadFailed: '加载工作区信息失败',
|
||||
members: '成员',
|
||||
you: '你',
|
||||
|
||||
@@ -2404,8 +2404,7 @@ const zhHant = {
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud.',
|
||||
cloudManagedDescription: 'This Workspace is hosted by LangBot Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
|
||||
@@ -272,8 +272,7 @@ function executionText(raw: unknown): string {
|
||||
/** Derive the unified execution list from a mocked monitoring payload. */
|
||||
export function buildExecutionList(monitoringData: unknown) {
|
||||
const payload = monitoringData as
|
||||
| { messages?: RawMonitoringMessage[] }
|
||||
| undefined;
|
||||
{ messages?: RawMonitoringMessage[] } | undefined;
|
||||
const items = (payload?.messages ?? [])
|
||||
.filter((message) => message.role !== 'assistant')
|
||||
.map((message) => {
|
||||
|
||||
@@ -129,7 +129,7 @@ function launchWidget() {
|
||||
FakeWebSocket.instances = [];
|
||||
const document = new FakeDocument();
|
||||
const window = {
|
||||
location: {href: "https://embed.test/"},
|
||||
location: { href: 'https://embed.test/' },
|
||||
crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' },
|
||||
sessionStorage: { getItem: () => null, setItem: () => {} },
|
||||
};
|
||||
|
||||
@@ -2,51 +2,174 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
function load(file,deps={},globals={}) {
|
||||
const source=fs.readFileSync(new URL('../../src/app/infra/websocket/'+file,import.meta.url),'utf8').replaceAll('import.meta.env.VITE_API_BASE_URL','undefined');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const m={exports:{}};new Function('require','module','exports',...Object.keys(globals),code)(id=>deps[id],m,m.exports,...Object.values(globals));return m.exports;
|
||||
function load(file, deps = {}, globals = {}) {
|
||||
const source = fs
|
||||
.readFileSync(
|
||||
new URL('../../src/app/infra/websocket/' + file, import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.replaceAll('import.meta.env.VITE_API_BASE_URL', 'undefined');
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
const m = { exports: {} };
|
||||
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(
|
||||
(id) => deps[id],
|
||||
m,
|
||||
m.exports,
|
||||
...Object.values(globals),
|
||||
);
|
||||
return m.exports;
|
||||
}
|
||||
const urls=load('url.ts');
|
||||
test('OSS and Cloud URLs follow HTTP backend scheme, port and prefix',()=>{
|
||||
for(const [base,page,expected] of [
|
||||
[undefined,'http://localhost:3000/home','ws://localhost:3000/api/ws'],
|
||||
[undefined,'https://cloud.test/home','wss://cloud.test/api/ws'],
|
||||
['http://localhost:5300','http://localhost:3000','ws://localhost:5300/api/ws'],
|
||||
['https://api.test/root/','https://ui.test','wss://api.test/root/api/ws'],
|
||||
['/backend/','https://ui.test/home','wss://ui.test/backend/api/ws'],
|
||||
['https://api.test','http://ui.test','wss://api.test/api/ws'],
|
||||
]) assert.equal(urls.backendWebSocketURL(base,page,'/api/ws').toString(),expected);
|
||||
const urls = load('url.ts');
|
||||
test('OSS and Cloud URLs follow HTTP backend scheme, port and prefix', () => {
|
||||
for (const [base, page, expected] of [
|
||||
[undefined, 'http://localhost:3000/home', 'ws://localhost:3000/api/ws'],
|
||||
[undefined, 'https://cloud.test/home', 'wss://cloud.test/api/ws'],
|
||||
[
|
||||
'http://localhost:5300',
|
||||
'http://localhost:3000',
|
||||
'ws://localhost:5300/api/ws',
|
||||
],
|
||||
['https://api.test/root/', 'https://ui.test', 'wss://api.test/root/api/ws'],
|
||||
['/backend/', 'https://ui.test/home', 'wss://ui.test/backend/api/ws'],
|
||||
['https://api.test', 'http://ui.test', 'wss://api.test/api/ws'],
|
||||
])
|
||||
assert.equal(
|
||||
urls.backendWebSocketURL(base, page, '/api/ws').toString(),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
function setup(){
|
||||
let workspace='one',next=0;const timers=new Map(),win=new EventTarget(),doc=new EventTarget();win.location={href:'https://cloud.test/home'};doc.visibilityState='visible';
|
||||
class WS{
|
||||
static CONNECTING=0;static OPEN=1;static instances=[];readyState=0;
|
||||
constructor(url){this.url=url;WS.instances.push(this)}
|
||||
send(p){this.sent=JSON.parse(p)} close(){this.readyState=3} closed(){this.readyState=3;this.onclose?.()}
|
||||
open(){this.readyState=1;this.onopen?.()} receive(data){this.onmessage?.({data:JSON.stringify(data)})}
|
||||
}
|
||||
const {WebSocketClient}=load('WebSocketClient.ts',{'./url':urls,'@/app/infra/http/workspaceContext':{getActiveWorkspaceUuid:()=>workspace}},{
|
||||
window:win,document:doc,WebSocket:WS,localStorage:{getItem:()=> 'token'},
|
||||
setTimeout:fn=>{timers.set(++next,fn);return next},clearTimeout:id=>timers.delete(id),setInterval:()=>1,clearInterval:()=>{},console:{warn(){},error(){},debug(){}},
|
||||
});
|
||||
return {client:new WebSocketClient('pipeline'),WS,win,timers,setWorkspace:v=>workspace=v};
|
||||
function setup() {
|
||||
let workspace = 'one',
|
||||
next = 0;
|
||||
const timers = new Map(),
|
||||
win = new EventTarget(),
|
||||
doc = new EventTarget();
|
||||
win.location = { href: 'https://cloud.test/home' };
|
||||
doc.visibilityState = 'visible';
|
||||
class WS {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static instances = [];
|
||||
readyState = 0;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
WS.instances.push(this);
|
||||
}
|
||||
send(p) {
|
||||
this.sent = JSON.parse(p);
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
closed() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
receive(data) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
const { WebSocketClient } = load(
|
||||
'WebSocketClient.ts',
|
||||
{
|
||||
'./url': urls,
|
||||
'@/app/infra/http/workspaceContext': {
|
||||
getActiveWorkspaceUuid: () => workspace,
|
||||
},
|
||||
},
|
||||
{
|
||||
window: win,
|
||||
document: doc,
|
||||
WebSocket: WS,
|
||||
localStorage: { getItem: () => 'token' },
|
||||
setTimeout: (fn) => {
|
||||
timers.set(++next, fn);
|
||||
return next;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
setInterval: () => 1,
|
||||
clearInterval: () => {},
|
||||
console: { warn() {}, error() {}, debug() {} },
|
||||
},
|
||||
);
|
||||
return {
|
||||
client: new WebSocketClient('pipeline'),
|
||||
WS,
|
||||
win,
|
||||
timers,
|
||||
setWorkspace: (v) => (workspace = v),
|
||||
};
|
||||
}
|
||||
test('only authenticated sockets are connected; disposal removes recovery',async()=>{
|
||||
const {client,WS,win}=setup(),pending=client.connect(),ws=WS.instances[0];ws.open();assert.equal(client.isConnected(),false);assert.equal(ws.sent.workspace_uuid,'one');
|
||||
ws.receive({type:'connected',connection_id:'id'});assert.equal(await pending,'id');assert(client.isConnected());client.disconnect();ws.closed();win.dispatchEvent(new Event('focus'));assert.equal(WS.instances.length,1);
|
||||
test('only authenticated sockets are connected; disposal removes recovery', async () => {
|
||||
const { client, WS, win } = setup(),
|
||||
pending = client.connect(),
|
||||
ws = WS.instances[0];
|
||||
ws.open();
|
||||
assert.equal(client.isConnected(), false);
|
||||
assert.equal(ws.sent.workspace_uuid, 'one');
|
||||
ws.receive({ type: 'connected', connection_id: 'id' });
|
||||
assert.equal(await pending, 'id');
|
||||
assert(client.isConnected());
|
||||
client.disconnect();
|
||||
ws.closed();
|
||||
win.dispatchEvent(new Event('focus'));
|
||||
assert.equal(WS.instances.length, 1);
|
||||
});
|
||||
test('closed handshakes settle and transient retries survive more than five failures',async()=>{
|
||||
const {client,WS,timers}=setup();const first=client.connect().catch(()=>{});WS.instances[0].closed();await first;
|
||||
for(let i=0;i<7;i++){const [id,fn]=[...timers][0];timers.delete(id);fn();WS.instances.at(-1).closed();await Promise.resolve()}
|
||||
assert.equal(WS.instances.length,8);assert.equal(timers.size,1);client.disconnect();assert.equal(timers.size,0);
|
||||
test('closed handshakes settle and transient retries survive more than five failures', async () => {
|
||||
const { client, WS, timers } = setup();
|
||||
const first = client.connect().catch(() => {});
|
||||
WS.instances[0].closed();
|
||||
await first;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const [id, fn] = [...timers][0];
|
||||
timers.delete(id);
|
||||
fn();
|
||||
WS.instances.at(-1).closed();
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(WS.instances.length, 8);
|
||||
assert.equal(timers.size, 1);
|
||||
client.disconnect();
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
test('stale closes do not affect replacement sockets and workspace changes block recovery',async()=>{
|
||||
const {client,WS,win,setWorkspace}=setup();const pending=client.connect().catch(()=>{}),old=WS.instances[0];client.disconnect();
|
||||
const current=client.connect(),ws=WS.instances[1];old.closed();await pending;ws.open();ws.receive({type:'connected',connection_id:'new'});await current;
|
||||
assert(client.isConnected());setWorkspace('two');ws.closed();win.dispatchEvent(new Event('focus'));assert.equal(WS.instances.length,2);client.disconnect();
|
||||
test('stale closes do not affect replacement sockets and workspace changes block recovery', async () => {
|
||||
const { client, WS, win, setWorkspace } = setup();
|
||||
const pending = client.connect().catch(() => {}),
|
||||
old = WS.instances[0];
|
||||
client.disconnect();
|
||||
const current = client.connect(),
|
||||
ws = WS.instances[1];
|
||||
old.closed();
|
||||
await pending;
|
||||
ws.open();
|
||||
ws.receive({ type: 'connected', connection_id: 'new' });
|
||||
await current;
|
||||
assert(client.isConnected());
|
||||
setWorkspace('two');
|
||||
ws.closed();
|
||||
win.dispatchEvent(new Event('focus'));
|
||||
assert.equal(WS.instances.length, 2);
|
||||
client.disconnect();
|
||||
});
|
||||
test('authentication rejection does not reconnect',async()=>{
|
||||
const {client,WS,timers,win}=setup();const pending=client.connect().catch(()=>{}),ws=WS.instances[0];ws.open();ws.receive({type:'error',message:'Unauthorized'});ws.closed();await pending;
|
||||
win.dispatchEvent(new Event('focus'));assert.equal(timers.size,0);assert.equal(WS.instances.length,1);client.disconnect();
|
||||
test('authentication rejection does not reconnect', async () => {
|
||||
const { client, WS, timers, win } = setup();
|
||||
const pending = client.connect().catch(() => {}),
|
||||
ws = WS.instances[0];
|
||||
ws.open();
|
||||
ws.receive({ type: 'error', message: 'Unauthorized' });
|
||||
ws.closed();
|
||||
await pending;
|
||||
win.dispatchEvent(new Event('focus'));
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(WS.instances.length, 1);
|
||||
client.disconnect();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user