chore(merge): sync master into dev/4.11.x

This commit is contained in:
huanghuoguoguo
2026-07-31 19:29:38 +08:00
502 changed files with 77975 additions and 12729 deletions
+59 -1
View File
@@ -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 });
+128 -202
View File
@@ -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(
+167
View File
@@ -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
View File
@@ -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,
});
});
+86
View File
@@ -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/);
});