Merge remote-tracking branch 'origin/master' into feat/rework-agent-onboarding

This commit is contained in:
fdc310
2026-08-25 22:36:44 +08:00
87 changed files with 2607 additions and 953 deletions
+2
View File
@@ -503,6 +503,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
if (path === '/api/v1/user/account-info') {
return fulfillJson(route, {
initialized: true,
authenticated_invitation_acceptance_enabled: false,
invitation_registration_enabled: true,
password_login_enabled: true,
space_login_enabled: false,
});
+107 -1
View File
@@ -93,7 +93,7 @@ test('login preserves an explicit invitation email mismatch error', async ({
});
await page.goto('/invitations/accept#token=mismatch-invitation');
await page.getByRole('button', { name: 'I already have an account' }).click();
await page.goto('/login?invitation=1');
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();
@@ -107,6 +107,111 @@ test('login preserves an explicit invitation email mismatch error', async ({
await expect(page.getByText('Login successful')).toHaveCount(0);
});
test('an OAuth-only OSS instance registers the invited email with a local password', async ({
page,
}) => {
let registration: { email?: string; password?: string } | undefined;
await installLangBotApiMocks(page, { authenticated: false });
await page.route('**/api/v1/user/account-info', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 800));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: false,
invitation_registration_enabled: true,
password_login_enabled: false,
space_login_enabled: true,
},
msg: 'ok',
}),
});
});
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: 'oss-local-registration',
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) => {
const body = JSON.parse(route.request().postData() || '{}') as {
registration?: { email?: string; password?: string };
};
registration = body.registration;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
login_required: true,
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/invitations/accept#token=oss-local-registration');
await expect(page.getByText('Playwright Workspace')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Login with LangBot Account' }),
).toHaveCount(0);
await expect(page.locator('#invite-email')).toHaveValue(
'invited@example.com',
);
await expect(page.locator('#invite-email')).toHaveAttribute('readonly', '');
await expect(page.locator('#invite-password')).toBeVisible();
await expect(page.locator('#invite-password-confirm')).toBeVisible();
for (const inputId of ['invite-password', 'invite-password-confirm']) {
const input = page.locator(`#${inputId}`);
const field = input.locator('xpath=..');
await expect(field).toHaveClass(/relative/);
await expect(field.locator('svg')).toBeVisible();
await expect(input).toHaveClass(/pl-10/);
}
await expect(
page.getByRole('button', { name: 'Create account and accept' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'I already have an account' }),
).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Login with LangBot Account' }),
).toHaveCount(0);
await page.locator('#invite-password').fill('invite-password-123');
await page.locator('#invite-password-confirm').fill('invite-password-123');
await page.getByRole('button', { name: 'Create account and accept' }).click();
await expect(page).toHaveURL(/\/login\?invitation=1$/);
expect(registration).toEqual({
email: 'invited@example.com',
password: 'invite-password-123',
});
});
test('an authenticated OSS invitation requires logout before registration', async ({
page,
}) => {
@@ -185,6 +290,7 @@ test('an authenticated Cloud Account can accept its invitation directly', async
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: true,
invitation_registration_enabled: false,
password_login_enabled: false,
space_login_enabled: true,
},
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/user/space-credits', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
credits: null,
owner_space_bound: false,
is_workspace_owner: true,
},
msg: 'ok',
}),
}),
);
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
providers: [
{
uuid: 'langbot-models-provider',
name: 'LangBot Models',
requester: 'space-chat-completions',
base_url: '',
api_keys: [],
},
],
},
msg: 'ok',
}),
}),
);
await page.route('**/api/v1/provider/requesters**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }),
}),
);
await page.route('**/api/v1/provider/models/**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }),
}),
);
await page.goto('/home?action=showModelSettings');
await expect(
page.getByRole('button', {
name: 'The Workspace owner must connect a LangBot Account for LangBot Models.',
}),
).toBeVisible();
});
+58 -4
View File
@@ -69,7 +69,55 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
body: `<!doctype html>
<html>
<body>
<h1>LangRAG Observability</h1>
<button id="save">Save</button>
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
<script>
document.querySelector('#save').addEventListener('click', async () => {
await window.langbot.api('/settings', { enabled: true }, 'POST');
document.body.dataset.saved = 'true';
});
</script>
</body>
</html>`,
});
},
);
let pageSdkRequests = 0;
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
pageSdkRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: `window.langbot = {
api(endpoint, body, method) {
return new Promise((resolve) => {
const requestId = 'request-' + Date.now();
const handler = (event) => {
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
window.removeEventListener('message', handler);
resolve(event.data.data);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
});
},
};`,
});
});
let pageApiRequests = 0;
await page.route(
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
async (route) => {
pageApiRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped({ saved: true }),
});
},
);
@@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
);
const pluginFrame = page.frameLocator('iframe');
await expect(
page
.frameLocator('iframe')
.getByRole('heading', { name: 'LangRAG Observability' }),
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
).toBeVisible();
await pluginFrame.getByRole('button', { name: 'Save' }).click();
await expect(pluginFrame.locator('body')).toHaveAttribute(
'data-saved',
'true',
);
expect(authenticatedAssetRequests).toBeGreaterThan(0);
expect(pageSdkRequests).toBe(1);
expect(pageApiRequests).toBe(1);
await expect(page.getByText('Loading...')).toHaveCount(0);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import test from 'node:test';
const localeDir = new URL('../../src/i18n/locales/', import.meta.url);
const localeFiles = readdirSync(localeDir).filter((name) =>
name.endsWith('.ts'),
);
const deprecatedAccountCopy = [
/Initialize with Space/i,
/Login with Space/i,
/Logging in with Space/i,
/Space login/i,
/Space accounts?/i,
/Bind Space Account/i,
/Authorize with Space/i,
/通过 Space 登录/,
/使用 Space 登录/,
/Space 登录/,
/Space 账户/,
/Space 帳戶/,
/绑定 Space/,
/綁定 Space/,
/Space アカウント/,
/Space でログイン/,
/cuenta de Space/i,
/cuentas de Space/i,
/cuenta Space/i,
/tài khoản Space/i,
/บัญชี Space/,
/аккаунт(?:ов|а)? Space/i,
/аккаунт Space/i,
];
test('user-facing account authentication copy uses LangBot Account terminology', () => {
const violations = [];
for (const file of localeFiles) {
const source = readFileSync(new URL(file, localeDir), 'utf8');
for (const pattern of deprecatedAccountCopy) {
if (pattern.test(source)) violations.push(`${file}: ${pattern}`);
}
}
assert.deepEqual(violations, []);
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const dialogPath = path.join(
root,
'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx',
);
const localeDir = path.join(root, 'src/i18n/locales');
const dialogSource = fs.readFileSync(dialogPath, 'utf8');
test('QR credential exchanges preserve the active Workspace scope', () => {
assert.match(dialogSource, /getActiveWorkspaceUuid/);
assert.match(
dialogSource,
/sessionWorkspaceUuidRef\.current = workspaceUuid/,
);
assert.match(
dialogSource,
/const workspaceUuid = sessionWorkspaceUuidRef\.current/,
);
assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/);
assert.match(
dialogSource,
/`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/,
);
assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/);
const workspaceHeaderUses = dialogSource.match(
/'X-Workspace-Id': workspaceUuid/g,
);
assert.equal(
workspaceHeaderUses?.length,
4,
'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope',
);
});
test('WeChat QR login never reuses Feishu progress copy', () => {
const weixinConfig = dialogSource.match(
/weixin:\s*\{[\s\S]*?apiBase:\s*'\/api\/v1\/platform\/adapters\/weixin\/login'/,
)?.[0];
assert.ok(weixinConfig, 'WeChat platform config is missing');
assert.match(weixinConfig, /connectingKey:\s*'weixin\.connecting'/);
assert.match(weixinConfig, /waitingKey:\s*'weixin\.waitingForScan'/);
assert.match(weixinConfig, /retryKey:\s*'weixin\.retry'/);
assert.doesNotMatch(weixinConfig, /feishu\./);
for (const locale of [
'en-US.ts',
'es-ES.ts',
'ja-JP.ts',
'ru-RU.ts',
'th-TH.ts',
'vi-VN.ts',
'zh-Hans.ts',
'zh-Hant.ts',
]) {
const source = fs.readFileSync(path.join(localeDir, locale), 'utf8');
const block = source.match(/weixin:\s*\{[\s\S]*?\n\s*\},/)?.[0];
assert.ok(block, `${locale} is missing the WeChat locale block`);
for (const key of ['connecting', 'waitingForScan', 'retry']) {
assert.match(
block,
new RegExp(`\\b${key}:`),
`${locale} is missing weixin.${key}`,
);
}
}
});