fix(ci): align workspace fixtures and constrain mobile tours

This commit is contained in:
RockChinQ
2026-10-15 05:31:04 +00:00
parent b8d292c94d
commit 625ee67113
37 changed files with 243 additions and 143 deletions
@@ -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'])
+2 -2
View File
@@ -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')
+4 -1
View File
@@ -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(
+1 -1
View File
@@ -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) {
+1 -5
View File
@@ -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;
+1 -4
View File
@@ -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 -5
View File
@@ -1,9 +1,5 @@
export type WorkspaceRole =
| 'owner'
| 'admin'
| 'developer'
| 'operator'
| 'viewer';
'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
export interface Workspace {
uuid: string;
+3 -6
View File
@@ -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) || '',
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -2406,8 +2406,7 @@ const zhHans = {
upgradePlan: '切换或升级计划',
ossSingletonDescription:
'当前自托管实例只有一个工作区,但可以包含多个用户。',
cloudManagedDescription:
'此工作区托管于 LangBot Cloud。',
cloudManagedDescription: '此工作区托管于 LangBot Cloud。',
loadFailed: '加载工作区信息失败',
members: '成员',
you: '你',
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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: () => {} },
};
+164 -41
View File
@@ -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();
});