mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
chore(merge): sync master into dev/4.11.x
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
async function save(page: Page) {
|
||||
const button = page.getByRole('button', { name: /^Save$/ });
|
||||
@@ -66,6 +69,61 @@ async function forceFormSubmit(page: Page, formSelector: string) {
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('viewer keeps ordinary bot and pipeline monitoring access', async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-viewer',
|
||||
'Viewer Workspace',
|
||||
'local',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Viewer Test Bot');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Proves monitoring is ordinary resource visibility.');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Viewer Pipeline');
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Viewer monitoring permission regression.');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
|
||||
workspace.membership.role = 'viewer';
|
||||
workspace.permissions = ['member.view', 'resource.view', 'workspace.view'];
|
||||
|
||||
await page.goto('/home/bots?id=bot-1');
|
||||
await expect(page.getByRole('tab', { name: 'Logs' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Sessions' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
await page.getByRole('tab', { name: 'Logs' }).click();
|
||||
await expect(page.getByText('No logs yet')).toBeVisible();
|
||||
|
||||
await page.goto('/home/pipelines?id=pipeline-1');
|
||||
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Refresh Data' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export Data' })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
|
||||
@@ -21,29 +21,6 @@ interface PipelineMock {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
kind: 'agent';
|
||||
component_ref: string;
|
||||
config: JsonRecord;
|
||||
enabled: boolean;
|
||||
supported_event_patterns: string[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ToolMock {
|
||||
name: string;
|
||||
description: string;
|
||||
human_desc: string;
|
||||
parameters: JsonRecord;
|
||||
source: 'builtin' | 'plugin' | 'mcp' | 'skill';
|
||||
source_name?: string;
|
||||
source_id?: string;
|
||||
}
|
||||
|
||||
interface KnowledgeBaseMock {
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -87,12 +64,35 @@ interface BotMock {
|
||||
use_pipeline_uuid?: string;
|
||||
pipeline_routing_rules: unknown[];
|
||||
adapter_runtime_values: JsonRecord;
|
||||
event_bindings: unknown[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceEntryMock {
|
||||
workspace: {
|
||||
uuid: string;
|
||||
instance_uuid: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: 'personal' | 'team';
|
||||
status: 'active';
|
||||
source: 'local' | 'cloud_projection';
|
||||
};
|
||||
membership: {
|
||||
uuid: string;
|
||||
workspace_uuid: string;
|
||||
account_uuid: string;
|
||||
email: string;
|
||||
role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
|
||||
status: 'active';
|
||||
joined_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
permissions: string[];
|
||||
placement_generation: number;
|
||||
}
|
||||
|
||||
interface LangBotApiMockState {
|
||||
agents: AgentMock[];
|
||||
authenticated: boolean;
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
@@ -103,8 +103,8 @@ interface LangBotApiMockState {
|
||||
sessionAnalyses: Record<string, unknown>;
|
||||
sessionMessages: Record<string, unknown[]>;
|
||||
skills: SkillMock[];
|
||||
tools: ToolMock[];
|
||||
withRunnerToolSelector: boolean;
|
||||
workspaces: WorkspaceEntryMock[];
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
@@ -136,6 +136,59 @@ function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function makeWorkspaceEntry(
|
||||
uuid: string,
|
||||
name: string,
|
||||
source: 'local' | 'cloud_projection' = 'cloud_projection',
|
||||
): WorkspaceEntryMock {
|
||||
const createdAt = now();
|
||||
return {
|
||||
workspace: {
|
||||
uuid,
|
||||
instance_uuid: 'instance-playwright',
|
||||
name,
|
||||
slug: uuid,
|
||||
type: 'team',
|
||||
status: 'active',
|
||||
source,
|
||||
},
|
||||
membership: {
|
||||
uuid: `membership-${uuid}`,
|
||||
workspace_uuid: uuid,
|
||||
account_uuid: 'account-playwright',
|
||||
email: 'admin@example.com',
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joined_at: createdAt,
|
||||
created_at: createdAt,
|
||||
},
|
||||
permissions: [
|
||||
'api_key.manage',
|
||||
'audit.view',
|
||||
'data.export',
|
||||
'member.invite',
|
||||
'member.remove',
|
||||
'member.update_role',
|
||||
'member.view',
|
||||
'owner.transfer',
|
||||
'provider_secret.manage',
|
||||
'resource.manage',
|
||||
'resource.view',
|
||||
'runtime.operate',
|
||||
'workspace.view',
|
||||
],
|
||||
placement_generation: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultWorkspaceEntry(): WorkspaceEntryMock {
|
||||
return makeWorkspaceEntry(
|
||||
'workspace-playwright',
|
||||
'Playwright Workspace',
|
||||
'local',
|
||||
);
|
||||
}
|
||||
|
||||
function nextId(state: LangBotApiMockState, prefix: string) {
|
||||
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
|
||||
return `${prefix}-${state.counters[prefix]}`;
|
||||
@@ -210,20 +263,7 @@ function makePipeline(
|
||||
name: String(data.name || ''),
|
||||
description: String(data.description || ''),
|
||||
config: (data.config as JsonRecord | undefined) || {
|
||||
ai: {
|
||||
runner: {
|
||||
id: 'plugin:langbot-team/LocalAgent/default',
|
||||
'expire-time': 0,
|
||||
},
|
||||
runner_config: {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
model: {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ai: {},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
@@ -234,40 +274,6 @@ function makePipeline(
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgent(data: JsonRecord, uuid: string): AgentMock {
|
||||
return {
|
||||
uuid,
|
||||
name: String(data.name || uuid),
|
||||
description: String(data.description || ''),
|
||||
emoji: String(data.emoji || '🤖'),
|
||||
kind: 'agent',
|
||||
component_ref: String(
|
||||
data.component_ref || 'plugin:langbot-team/LocalAgent/default',
|
||||
),
|
||||
config: (data.config as JsonRecord | undefined) || {
|
||||
runner: {
|
||||
id: 'plugin:langbot-team/LocalAgent/default',
|
||||
'expire-time': 0,
|
||||
},
|
||||
runner_config: {
|
||||
'plugin:langbot-team/LocalAgent/default': {
|
||||
model: {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
},
|
||||
'enable-all-tools': false,
|
||||
tools: ['unavailable_plugin_tool'],
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: data.enabled !== false,
|
||||
supported_event_patterns: (data.supported_event_patterns as
|
||||
| string[]
|
||||
| undefined) || ['*'],
|
||||
updated_at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineMetadata(withRunnerToolSelector = false) {
|
||||
return {
|
||||
configs: [
|
||||
@@ -286,21 +292,21 @@ function pipelineMetadata(withRunnerToolSelector = false) {
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'runner.id',
|
||||
name: 'id',
|
||||
id: 'runner',
|
||||
name: 'runner',
|
||||
label: {
|
||||
en_US: 'Runner',
|
||||
zh_Hans: '运行器',
|
||||
},
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'plugin:langbot-team/LocalAgent/default',
|
||||
default: 'local-agent',
|
||||
options: [
|
||||
{
|
||||
name: 'plugin:langbot-team/LocalAgent/default',
|
||||
name: 'local-agent',
|
||||
label: {
|
||||
en_US: 'Local Agent',
|
||||
zh_Hans: '本地 Agent',
|
||||
en_US: 'Built-in Agent',
|
||||
zh_Hans: '内置 Agent',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -308,14 +314,14 @@ function pipelineMetadata(withRunnerToolSelector = false) {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'plugin:langbot-team/LocalAgent/default',
|
||||
name: 'local-agent',
|
||||
label: {
|
||||
en_US: 'Local Agent',
|
||||
zh_Hans: '本地 Agent',
|
||||
en_US: 'Built-in Agent',
|
||||
zh_Hans: '内置 Agent',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'plugin:langbot-team/LocalAgent/default.model',
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
label: {
|
||||
en_US: 'Model',
|
||||
@@ -351,19 +357,6 @@ function pipelineMetadata(withRunnerToolSelector = false) {
|
||||
};
|
||||
}
|
||||
|
||||
function agentMetadata() {
|
||||
return {
|
||||
runner_config: pipelineMetadata(true).configs[0],
|
||||
kinds: [
|
||||
{
|
||||
name: 'agent',
|
||||
supported_event_patterns: ['*'],
|
||||
message_only: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function providerModelList() {
|
||||
return {
|
||||
models: [
|
||||
@@ -468,7 +461,6 @@ function makeBot(
|
||||
: undefined,
|
||||
pipeline_routing_rules:
|
||||
(data.pipeline_routing_rules as unknown[] | undefined) || [],
|
||||
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
|
||||
adapter_runtime_values: {
|
||||
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
|
||||
extra_webhook_full_url: '',
|
||||
@@ -526,21 +518,33 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
if (path === '/api/v1/user/account-info') {
|
||||
return fulfillJson(route, {
|
||||
initialized: true,
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
password_login_enabled: true,
|
||||
space_login_enabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/check-token') {
|
||||
return fulfillJson(route, { token: '' });
|
||||
return fulfillJson(route, {
|
||||
token: state.authenticated ? 'playwright-token' : '',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/auth') {
|
||||
state.authenticated = true;
|
||||
return fulfillJson(route, { token: 'playwright-token' });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/space/callback') {
|
||||
state.authenticated = true;
|
||||
return fulfillJson(route, {
|
||||
token: 'playwright-space-token',
|
||||
user: 'admin@example.com',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/info') {
|
||||
return fulfillJson(route, {
|
||||
account_uuid: 'account-playwright',
|
||||
user: 'admin@example.com',
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
@@ -551,6 +555,24 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, { credits: null });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/workspaces/bootstrap') {
|
||||
return fulfillJson(route, { workspaces: state.workspaces });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/workspaces/current') {
|
||||
const selectedWorkspaceUuid = request.headers()['x-workspace-id'];
|
||||
const entry = state.workspaces.find(
|
||||
(item) => item.workspace.uuid === selectedWorkspaceUuid,
|
||||
);
|
||||
return fulfillJson(route, entry || state.workspaces[0]);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/workspaces') {
|
||||
return fulfillJson(route, {
|
||||
workspaces: state.workspaces.map((entry) => entry.workspace),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/adapters') {
|
||||
return fulfillJson(route, { adapters: mockAdapters() });
|
||||
}
|
||||
@@ -570,7 +592,7 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
|
||||
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
|
||||
if (botLogsMatch) {
|
||||
return fulfillJson(route, { logs: [], total: 0 });
|
||||
return fulfillJson(route, { logs: [], total_count: 0 });
|
||||
}
|
||||
|
||||
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
|
||||
@@ -606,88 +628,6 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, { models: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/agents/_/metadata') {
|
||||
return fulfillJson(route, agentMetadata());
|
||||
}
|
||||
|
||||
if (path === '/api/v1/agents') {
|
||||
if (method === 'POST') {
|
||||
const data = parseJsonBody(route);
|
||||
if (data.kind === 'pipeline') {
|
||||
const pipeline = makePipeline(state, data);
|
||||
state.pipelines = [
|
||||
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
|
||||
pipeline,
|
||||
];
|
||||
return fulfillJson(route, { uuid: pipeline.uuid, kind: 'pipeline' });
|
||||
}
|
||||
|
||||
const agentId = nextId(state, 'agent');
|
||||
const agent = makeAgent(data, agentId);
|
||||
state.agents = [
|
||||
...state.agents.filter((item) => item.uuid !== agentId),
|
||||
agent,
|
||||
];
|
||||
return fulfillJson(route, { uuid: agentId, kind: 'agent' });
|
||||
}
|
||||
|
||||
return fulfillJson(route, {
|
||||
agents: [
|
||||
...state.agents,
|
||||
...state.pipelines.map((pipeline) => ({
|
||||
...pipeline,
|
||||
kind: 'pipeline',
|
||||
component_ref: 'pipeline',
|
||||
enabled: true,
|
||||
supported_event_patterns: ['message.*'],
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
|
||||
if (agentMatch) {
|
||||
const agentId = decodeURIComponent(agentMatch[1]);
|
||||
|
||||
if (method === 'PUT') {
|
||||
const agent = makeAgent(parseJsonBody(route), agentId);
|
||||
state.agents = [
|
||||
...state.agents.filter((item) => item.uuid !== agentId),
|
||||
agent,
|
||||
];
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
state.agents = state.agents.filter((item) => item.uuid !== agentId);
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (agentId.startsWith('pipeline-')) {
|
||||
let pipeline = state.pipelines.find((item) => item.uuid === agentId);
|
||||
if (!pipeline) {
|
||||
pipeline = makePipeline(state, { name: agentId }, agentId);
|
||||
state.pipelines = [...state.pipelines, pipeline];
|
||||
}
|
||||
return fulfillJson(route, {
|
||||
agent: {
|
||||
...pipeline,
|
||||
kind: 'pipeline',
|
||||
component_ref: 'pipeline',
|
||||
enabled: true,
|
||||
supported_event_patterns: ['message.*'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let agent = state.agents.find((item) => item.uuid === agentId);
|
||||
if (!agent) {
|
||||
agent = makeAgent({ name: agentId }, agentId);
|
||||
state.agents = [...state.agents, agent];
|
||||
}
|
||||
return fulfillJson(route, { agent });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines/_/metadata') {
|
||||
return fulfillJson(route, pipelineMetadata(state.withRunnerToolSelector));
|
||||
}
|
||||
@@ -744,8 +684,6 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
available_plugins: [],
|
||||
bound_mcp_servers: [],
|
||||
available_mcp_servers: state.mcpServers,
|
||||
bound_mcp_resources: [],
|
||||
mcp_resource_agent_read_enabled: true,
|
||||
bound_skills: [],
|
||||
available_skills: state.skills,
|
||||
});
|
||||
@@ -811,10 +749,6 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/tools') {
|
||||
return fulfillJson(route, { tools: state.tools });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/plugins') {
|
||||
return fulfillJson(route, { plugins: [] });
|
||||
}
|
||||
@@ -1143,6 +1077,7 @@ export async function installLangBotApiMocks(
|
||||
sessionMessages?: Record<string, unknown[]>;
|
||||
storage?: JsonRecord;
|
||||
withRunnerToolSelector?: boolean;
|
||||
workspaces?: WorkspaceEntryMock[];
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
@@ -1153,9 +1088,10 @@ export async function installLangBotApiMocks(
|
||||
sessionMessages,
|
||||
storage = {},
|
||||
withRunnerToolSelector = false,
|
||||
workspaces = [defaultWorkspaceEntry()],
|
||||
} = options;
|
||||
const state: LangBotApiMockState = {
|
||||
agents: [],
|
||||
authenticated,
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
@@ -1166,18 +1102,8 @@ export async function installLangBotApiMocks(
|
||||
sessionAnalyses: sessionAnalyses || {},
|
||||
sessionMessages: sessionMessages || {},
|
||||
skills: [],
|
||||
tools: [
|
||||
{
|
||||
name: 'available_plugin_tool',
|
||||
description: 'Available plugin tool',
|
||||
human_desc: 'Available plugin tool',
|
||||
parameters: {},
|
||||
source: 'plugin',
|
||||
source_name: 'langbot-app/TestTools',
|
||||
source_id: 'langbot-app/TestTools',
|
||||
},
|
||||
],
|
||||
withRunnerToolSelector,
|
||||
workspaces,
|
||||
};
|
||||
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
test('terminal invitation errors refresh on a new fragment and allow account switching', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
storage: {
|
||||
token: 'playwright-token',
|
||||
userEmail: 'another-account@example.com',
|
||||
},
|
||||
});
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
const body = JSON.parse(route.request().postData() || '{}') as {
|
||||
token?: string;
|
||||
};
|
||||
const code =
|
||||
body.token === 'revoked-invitation'
|
||||
? 'invitation_revoked'
|
||||
: 'invitation_used';
|
||||
await route.fulfill({
|
||||
status: 410,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code, msg: code }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=used-invitation');
|
||||
await expect(
|
||||
page.getByText('This invitation was already used.'),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||
),
|
||||
)
|
||||
.toBeNull();
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.location.hash = 'token=revoked-invitation';
|
||||
});
|
||||
await expect(page.getByText('This invitation was revoked.')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to sign in' }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByText('Welcome')).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
token: localStorage.getItem('token'),
|
||||
userEmail: localStorage.getItem('userEmail'),
|
||||
})),
|
||||
).toEqual({ token: null, userEmail: null });
|
||||
});
|
||||
|
||||
test('login preserves an explicit invitation email mismatch error', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: false });
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'mismatch-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 'invitation_email_mismatch',
|
||||
msg: 'Invitation email does not match the Account',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=mismatch-invitation');
|
||||
await page.getByRole('button', { name: 'I already have an account' }).click();
|
||||
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
/\/invitations\/accept\?error=invitation_email_mismatch$/,
|
||||
);
|
||||
await expect(
|
||||
page.getByText('This invitation belongs to a different email address.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Login successful')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an authenticated OSS invitation requires logout before registration', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
storage: {
|
||||
token: 'playwright-token',
|
||||
userEmail: 'invited@example.com',
|
||||
},
|
||||
});
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'logout-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=logout-invitation');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sign out and return to this invitation',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login\?invitation=1$/);
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
token: localStorage.getItem('token'),
|
||||
userEmail: localStorage.getItem('userEmail'),
|
||||
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||
})),
|
||||
).toEqual({
|
||||
token: null,
|
||||
userEmail: null,
|
||||
invitation: 'logout-invitation',
|
||||
});
|
||||
});
|
||||
+132
-2
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
test('local account login reaches the authenticated home shell', async ({
|
||||
page,
|
||||
@@ -14,9 +17,136 @@ test('local account login reaches the authenticated home shell', async ({
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/home$/);
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
await expect(page.getByText('Home').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an existing Account token bootstraps the singleton without a selector loop', async ({
|
||||
page,
|
||||
}) => {
|
||||
const bootstrapWorkspaceHeaders: Array<string | undefined> = [];
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/api/v1/workspaces/bootstrap') {
|
||||
bootstrapWorkspaceHeaders.push(request.headers()['x-workspace-id']);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
expect(bootstrapWorkspaceHeaders.length).toBeGreaterThan(0);
|
||||
expect(bootstrapWorkspaceHeaders.every((header) => !header)).toBe(true);
|
||||
});
|
||||
|
||||
test('multi-Workspace login waits for an explicit selection', async ({
|
||||
page,
|
||||
}) => {
|
||||
const accountScopedRequests: { path: string; workspace?: string }[] = [];
|
||||
const selectedWorkspaceHeaders: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
const workspace = request.headers()['x-workspace-id'];
|
||||
if (
|
||||
path === '/api/v1/user/auth' ||
|
||||
path === '/api/v1/user/check-token' ||
|
||||
path === '/api/v1/workspaces/bootstrap'
|
||||
) {
|
||||
accountScopedRequests.push({ path, workspace });
|
||||
}
|
||||
if (path === '/api/v1/workspaces/current' && workspace) {
|
||||
selectedWorkspaceHeaders.push(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page, {
|
||||
storage: {
|
||||
langbot_active_workspace_uuid: 'workspace-from-another-account',
|
||||
},
|
||||
workspaces: [
|
||||
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
|
||||
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('Enter email address').fill('admin@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/workspaces\/select/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Choose a Workspace' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Alpha Workspace')).toBeVisible();
|
||||
await expect(page.getByText('Beta Workspace')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Beta Workspace/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
const workspaceSwitcher = page
|
||||
.getByRole('button', {
|
||||
name: /Switch Workspace/,
|
||||
})
|
||||
.first();
|
||||
await expect(workspaceSwitcher).toBeVisible();
|
||||
await workspaceSwitcher.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Workspace Settings' }),
|
||||
).toBeVisible();
|
||||
expect(selectedWorkspaceHeaders).toContain('workspace-beta');
|
||||
expect(accountScopedRequests.length).toBeGreaterThan(0);
|
||||
expect(accountScopedRequests.every((request) => !request.workspace)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Space OAuth bootstraps a singleton before entering home', async ({
|
||||
page,
|
||||
}) => {
|
||||
const selectedWorkspaceHeaders: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
const workspace = request.headers()['x-workspace-id'];
|
||||
if (path === '/api/v1/workspaces/current' && workspace) {
|
||||
selectedWorkspaceHeaders.push(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page);
|
||||
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(selectedWorkspaceHeaders).toContain('workspace-playwright');
|
||||
});
|
||||
|
||||
test('Space OAuth sends a multi-Workspace Account to the chooser', async ({
|
||||
page,
|
||||
}) => {
|
||||
const bootstrapWorkspaceHeaders: Array<string | undefined> = [];
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/api/v1/workspaces/bootstrap') {
|
||||
bootstrapWorkspaceHeaders.push(request.headers()['x-workspace-id']);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page, {
|
||||
workspaces: [
|
||||
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
|
||||
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
|
||||
],
|
||||
});
|
||||
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspaces\/select/, { timeout: 5_000 });
|
||||
await expect(page.getByText('Alpha Workspace')).toBeVisible();
|
||||
await expect(page.getByText('Beta Workspace')).toBeVisible();
|
||||
expect(bootstrapWorkspaceHeaders.length).toBeGreaterThan(0);
|
||||
expect(bootstrapWorkspaceHeaders.every((header) => !header)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
|
||||
);
|
||||
|
||||
function loadNormalizer() {
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('DynamicFormSaveValues must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
||||
}
|
||||
|
||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
||||
const specs = [
|
||||
{ name: 'single-line', type: 'string', default: '' },
|
||||
{ name: 'multiline', type: 'text', default: '' },
|
||||
{ name: 'string-list', type: 'array[string]', default: [] },
|
||||
{ name: 'count', type: 'integer', default: 0 },
|
||||
];
|
||||
const values = {
|
||||
'single-line': '\t hello world \n',
|
||||
multiline: ' keep multiline whitespace \n',
|
||||
'string-list': [' first ', ' second '],
|
||||
count: 3,
|
||||
};
|
||||
|
||||
assert.deepEqual(normalizeDynamicFormValuesForSave(specs, values), {
|
||||
'single-line': 'hello world',
|
||||
multiline: ' keep multiline whitespace \n',
|
||||
'string-list': [' first ', ' second '],
|
||||
count: 3,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
const read = (relativePath) =>
|
||||
fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||
|
||||
test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => {
|
||||
const market = read(
|
||||
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
|
||||
);
|
||||
const card = read(
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
|
||||
);
|
||||
const cardVO = read(
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts',
|
||||
);
|
||||
const entity = read('src/app/infra/entities/plugin/index.ts');
|
||||
const client = read('src/app/infra/http/CloudServiceClient.ts');
|
||||
const likes = read(
|
||||
'src/app/home/plugins/components/plugin-market/marketplace-likes.ts',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
market,
|
||||
/hot_score_desc/,
|
||||
'market must expose and default to hot-score sorting',
|
||||
);
|
||||
assert.match(
|
||||
market,
|
||||
/sortBy:\s*['"]hot_score['"]/,
|
||||
'hot option must use the shared API field',
|
||||
);
|
||||
assert.match(
|
||||
entity,
|
||||
/like_count\??:\s*number/,
|
||||
'marketplace extension responses must carry like counts',
|
||||
);
|
||||
assert.match(
|
||||
cardVO,
|
||||
/likeCount:\s*number/,
|
||||
'market cards must carry like counts',
|
||||
);
|
||||
assert.match(card, /\bHeart\b/, 'market cards must render a like affordance');
|
||||
assert.match(
|
||||
card,
|
||||
/toggleMarketplaceExtensionLike/,
|
||||
'market cards must support liking and unliking',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/marketplace\/extensions\/likes/,
|
||||
'client must load browser likes',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/setMarketplaceExtensionLike\(/,
|
||||
'client must update likes through the shared API',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/,
|
||||
'older Space servers must fall back from hot score to install sorting',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/@fingerprintjs\/fingerprintjs/,
|
||||
'anonymous likes must use browser fingerprinting',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/FingerprintJS\.load\(\{/,
|
||||
'fingerprint agent must be initialized with options',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/monitoring:\s*false/,
|
||||
'fingerprinting must not send vendor monitoring requests',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('invited local registration returns to login instead of authenticating', () => {
|
||||
const source = read('src/app/invitations/accept/page.tsx');
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/beginAuthenticatedSession\([\s\S]{0,120}response\.token/,
|
||||
);
|
||||
assert.match(source, /navigate\('\/login\?invitation=1'/);
|
||||
});
|
||||
|
||||
test('authenticated invitation page offers logout while retaining invitation', () => {
|
||||
const source = read('src/app/invitations/accept/page.tsx');
|
||||
assert.match(source, /workspace\.logoutAndReturn/);
|
||||
assert.match(source, /setPendingInvitationToken\(token\)/);
|
||||
});
|
||||
|
||||
test('Space OAuth callback distinguishes unknown and unbound accounts by stable codes', () => {
|
||||
const source = read('src/app/auth/space/callback/page.tsx');
|
||||
assert.match(source, /space_account_not_registered/);
|
||||
assert.match(source, /space_account_binding_required/);
|
||||
});
|
||||
|
||||
test('models panel derives LangBot Models billing state from workspace owner', () => {
|
||||
const source = read('src/app/home/components/models-dialog/ModelsPanel.tsx');
|
||||
assert.match(source, /getWorkspaceSpaceBilling/);
|
||||
assert.doesNotMatch(source, /getSpaceCredits\(\)/);
|
||||
assert.match(source, /membership\.role === 'owner'/);
|
||||
});
|
||||
|
||||
test('provider card represents owner and member owner-bound states explicitly', () => {
|
||||
const source = read(
|
||||
'src/app/home/components/models-dialog/components/ProviderCard.tsx',
|
||||
);
|
||||
assert.match(source, /isWorkspaceOwner/);
|
||||
assert.match(source, /ownerSpaceBound/);
|
||||
assert.match(source, /models\.ownerMustBindSpace/);
|
||||
assert.match(source, /models\.usesOwnerSpaceBilling/);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(currentDirectory, '../..');
|
||||
|
||||
function readSource(relativePath) {
|
||||
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
const homeLayoutSource = readSource('src/app/home/layout.tsx');
|
||||
const homeSidebarSource = readSource(
|
||||
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||
);
|
||||
const workspaceSettingsPanelSource = readSource(
|
||||
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
|
||||
);
|
||||
const workspaceSwitcherSource = readSource(
|
||||
'src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx',
|
||||
);
|
||||
|
||||
test('renders WorkspaceSwitcher only from HomeSidebar', () => {
|
||||
assert.doesNotMatch(homeLayoutSource, /<WorkspaceSwitcher\b/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /<WorkspaceSwitcher\b/);
|
||||
assert.equal(homeSidebarSource.match(/<WorkspaceSwitcher\b/g)?.length, 1);
|
||||
});
|
||||
|
||||
test('places WorkspaceSwitcher between the sidebar header and Home navigation', () => {
|
||||
const headerEnd = homeSidebarSource.indexOf('</SidebarHeader>');
|
||||
const switcher = homeSidebarSource.indexOf('<WorkspaceSwitcher');
|
||||
const contentStart = homeSidebarSource.indexOf('<SidebarContent');
|
||||
const homeGroup = homeSidebarSource.indexOf(
|
||||
"<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>",
|
||||
);
|
||||
|
||||
assert.notEqual(headerEnd, -1);
|
||||
assert.notEqual(switcher, -1);
|
||||
assert.notEqual(contentStart, -1);
|
||||
assert.notEqual(homeGroup, -1);
|
||||
assert.ok(
|
||||
headerEnd < switcher,
|
||||
'WorkspaceSwitcher must follow SidebarHeader',
|
||||
);
|
||||
assert.ok(
|
||||
switcher < contentStart && switcher < homeGroup,
|
||||
'WorkspaceSwitcher must precede SidebarContent and the Home group',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
|
||||
assert.match(
|
||||
workspaceSwitcherSource,
|
||||
/if \(!currentWorkspace\) return null;/,
|
||||
);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
|
||||
assert.doesNotMatch(
|
||||
homeSidebarSource,
|
||||
/currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200}<WorkspaceSwitcher/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Cloud workspace member management in Workspace Settings', () => {
|
||||
assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /#workspace-members/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /cloudMembersURL/);
|
||||
assert.doesNotMatch(workspaceSettingsPanelSource, /canManageCloudMembers/);
|
||||
});
|
||||
|
||||
test('keeps workspace plan and settings controls on the workspace row', () => {
|
||||
assert.match(workspaceSwitcherSource, /entry\.plan_name/);
|
||||
assert.match(
|
||||
workspaceSwitcherSource,
|
||||
/aria-label=\{t\('workspace\.settings'\)\}/,
|
||||
);
|
||||
assert.match(workspaceSwitcherSource, /className="size-8"/);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.currentPlan/);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.upgradePlan/);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.roles/);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /entry\.membership\.role/);
|
||||
});
|
||||
|
||||
test('moves Cloud plan upgrades into Workspace Settings', () => {
|
||||
assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/);
|
||||
assert.match(workspaceSettingsPanelSource, /cloudPortalURL/);
|
||||
assert.match(workspaceSettingsPanelSource, /step=plan/);
|
||||
assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
|
||||
});
|
||||
|
||||
test('aligns the workspace trigger with navigation entries on both sides and truncates long names', () => {
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/<div className="px-2[^>]*>[\s\S]*<WorkspaceSwitcher className="w-full/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
homeSidebarSource,
|
||||
/WorkspaceSwitcher className="[^"]*w-4\/5/,
|
||||
);
|
||||
assert.match(workspaceSwitcherSource, /h-9/);
|
||||
assert.match(workspaceSwitcherSource, /w-64/);
|
||||
assert.doesNotMatch(workspaceSwitcherSource, /min-w-80/);
|
||||
assert.match(workspaceSwitcherSource, /max-w-\[7rem\][^>]*truncate/);
|
||||
});
|
||||
|
||||
test('uses an infrastructure-level Box health endpoint in monitoring', () => {
|
||||
const statusCardSource = readSource(
|
||||
'src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx',
|
||||
);
|
||||
const backendClientSource = readSource('src/app/infra/http/BackendClient.ts');
|
||||
assert.match(backendClientSource, /getBoxRuntimeStatus/);
|
||||
assert.match(statusCardSource, /getBoxRuntimeStatus/);
|
||||
});
|
||||
Reference in New Issue
Block a user