Merge pull request #2699 from langbot-app/test/quality-account-ui-oracles-20261017

test(web): replace account and privacy source checks with browser behavior
This commit is contained in:
huanghuoguoguo
2026-10-17 20:35:41 +08:00
committed by GitHub
9 changed files with 503 additions and 242 deletions
+103
View File
@@ -0,0 +1,103 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const invitation of [false, true]) {
test(`Cloud login starts standard OAuth, invitation=${invitation}`, async ({
page,
}) => {
await installLangBotApiMocks(page);
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
edition: 'cloud',
deployment_mode: 'cloud',
cloud_service_url: 'https://space.langbot.app',
wizard_status: 'completed',
},
},
}),
);
await page.route('**/api/v1/user/account-info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
initialized: true,
password_login_enabled: false,
space_login_enabled: true,
authenticated_invitation_acceptance_enabled: true,
},
},
}),
);
if (invitation)
await page.addInitScript(() =>
sessionStorage.setItem(
'langbot_pending_invitation_token',
'invited-token',
),
);
let redirectUri: string | null = null;
await page.route('**/api/v1/user/space/authorize-url**', async (route) => {
redirectUri = new URL(route.request().url()).searchParams.get(
'redirect_uri',
);
await route.fulfill({
json: { code: 0, data: { authorize_url: '/oauth-provider' } },
});
});
await page.route('**/oauth-provider', (route) =>
route.fulfill({
contentType: 'text/html',
body: '<h1>OAuth provider</h1>',
}),
);
await page.goto(invitation ? '/login?invitation=1' : '/login');
await page
.getByRole('button', { name: 'Login with LangBot Account', exact: true })
.click();
await expect(page).toHaveURL(/\/oauth-provider$/);
expect(redirectUri).toBe(new URL('/auth/space/callback', page.url()).href);
expect(
await page.evaluate(() =>
sessionStorage.getItem('langbot_pending_invitation_token'),
),
).toBe(invitation ? 'invited-token' : null);
});
}
for (const [code, title, message] of [
[
'space_account_not_registered',
'Account not registered',
'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
],
[
'space_account_binding_required',
'LangBot Account connection required',
'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
],
]) {
test(`OAuth callback explains ${code}`, async ({ page }) => {
await installLangBotApiMocks(page);
await page.route('**/api/v1/user/space/callback', (route) =>
route.fulfill({
status: 403,
json: { code, msg: 'Generic upstream message' },
}),
);
await page.goto('/auth/space/callback?code=code&state=state');
await expect(page.getByText(title, { exact: true })).toBeVisible();
await expect(page.getByText(message, { exact: true })).toBeVisible();
await expect(
page.getByText('Generic upstream message', { exact: true }),
).toHaveCount(0);
await page
.getByRole('button', { name: 'Back to Login', exact: true })
.click();
await expect(page).toHaveURL(/\/login$/);
expect(await page.evaluate(() => localStorage.getItem('token'))).toBeNull();
});
}
+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const width of [1440, 390]) {
test(`bot setup and detail columns stay separated at ${width}px`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 1000 });
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
const layout = page.locator('#bot-form > fieldset');
const basic = layout.locator('[data-guide="bot-basic"]');
const adapter = page.getByTestId('adapter-gallery');
await expect(basic).toBeVisible();
await expect(adapter).toBeVisible();
const basicBox = (await basic.boundingBox())!;
const adapterBox = (await adapter.boundingBox())!;
if (width >= 1024)
expect(adapterBox.x).toBeGreaterThan(basicBox.x + basicBox.width);
else expect(adapterBox.y).toBeGreaterThan(basicBox.y + basicBox.height);
await adapter.getByRole('button', { name: /Playwright Adapter/ }).click();
await page.locator('input[name="name"]').fill('Layout bot');
await page.getByRole('button', { name: /^Submit$/ }).click();
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
const routing = page.locator('[data-guide="bot-routing"]');
await expect(routing).toBeVisible();
const cards = await layout.locator(':scope > [data-slot="card"]').all();
expect(cards).toHaveLength(2);
const left = (await cards[0].boundingBox())!;
const right = (await cards[1].boundingBox())!;
if (width >= 1024) expect(right.x).toBeGreaterThan(left.x + left.width);
else expect(right.y).toBeGreaterThan(left.y + left.height);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await page.goto('/home/knowledge?id=new');
const form = page.locator('#kb-form');
await expect(form).toBeVisible();
const children = await form.locator(':scope > *').all();
expect(children.length).toBeGreaterThan(1);
for (let i = 1; i < children.length; i++) {
const previous = await children[i - 1].boundingBox();
const current = await children[i].boundingBox();
if (previous && current)
expect(current.y - previous.y - previous.height).toBeGreaterThanOrEqual(
20,
);
}
});
}
test('the dedicated connection selector hides its manifest controller and persists its value', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/adapters', (route) =>
route.fulfill({
json: {
code: 0,
data: {
adapters: [
{
name: 'connection-adapter',
label: { en_US: 'Connection adapter' },
description: { en_US: 'Both connection modes' },
spec: {
categories: ['testing'],
config: [
{
name: 'transport',
label: { en_US: 'Raw transport controller' },
type: 'select',
required: true,
default: 'persistent',
options: [
{ name: 'persistent', label: { en_US: 'Persistent' } },
{
name: 'webhook',
label: { en_US: 'Webhook transport' },
},
],
},
{
name: 'webhook_url',
label: { en_US: 'Webhook address' },
type: 'webhook-url',
show_if: {
field: 'transport',
operator: 'eq',
value: 'webhook',
},
},
{
name: 'credential',
label: { en_US: 'Adapter credential' },
type: 'string',
required: true,
default: '',
},
],
},
},
],
},
},
}),
);
await page.goto('/home/bots?id=new');
await page
.getByTestId('adapter-gallery')
.getByRole('button', { name: /Connection adapter/ })
.click();
await page.getByRole('button', { name: /^Submit$/ }).click();
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.getByRole('radio', { name: /^Webhook/ }).click();
await expect(page.getByText(/^Raw transport controller/)).toHaveCount(0);
await expect(
page.getByText('Webhook address', { exact: true }),
).toBeVisible();
await page.locator('input[name="credential"]').fill('persist-me');
const save = page.waitForRequest(
(request) =>
request.method() === 'PUT' &&
request.url().endsWith('/api/v1/platform/bots/bot-1'),
);
await page.getByRole('button', { name: /^Save and enable$/ }).click();
expect((await save).postDataJSON().adapter_config).toEqual({
transport: 'webhook',
credential: 'persist-me',
});
});
@@ -61,3 +61,52 @@ test('Cloud never exposes or requests storage analysis', async ({ page }) => {
);
expect(storageAnalysisRequests).toBe(0);
});
test('an OSS owner opens storage analysis from its storage-icon menu item', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let requests = 0;
await page.route('**/api/v1/system/storage-analysis', async (route) => {
requests++;
await route.fulfill({
json: {
code: 0,
data: {
generated_at: '2026-10-17T00:00:00Z',
total_size_bytes: 123,
sections: [],
cleanup_policy: {
uploaded_file_retention_days: 7,
log_retention_days: 14,
},
database: {
type: 'sqlite',
monitoring_counts: {},
binary_storage: { count: 0, size_bytes: 0 },
},
cleanup_candidates: { uploaded_files: [], log_files: [] },
tasks: {},
},
},
});
});
await page.goto('/home/bots');
await page.getByRole('button', { name: /admin@example\.com/i }).click();
const menuItem = page.getByRole('menuitem', {
name: 'Storage Analysis',
exact: true,
});
await expect(menuItem).toBeVisible();
await expect(menuItem.locator('svg.lucide-hard-drive')).toBeVisible();
await menuItem.click();
await expect(
page
.getByRole('dialog')
.getByRole('button', { name: 'Refresh', exact: true }),
).toBeVisible();
await expect(
page.getByRole('dialog').getByText('123 B', { exact: true }),
).toBeVisible();
await expect.poll(() => requests).toBeGreaterThan(0);
});
+4
View File
@@ -214,6 +214,7 @@ test('an OAuth-only OSS instance registers the invited email with a local passwo
email: '[email protected]',
password: 'invite-password-123',
});
expect(await page.evaluate(() => localStorage.getItem('token'))).toBeNull();
});
test('an authenticated OSS invitation requires logout before registration', async ({
@@ -402,8 +403,10 @@ test('Space OAuth accepts a pending invitation with the freshly authenticated ac
});
let acceptanceAuthorization = '';
let acceptanceBody: unknown;
await page.route('**/api/v1/invitations/accept', async (route) => {
acceptanceAuthorization = route.request().headers().authorization ?? '';
acceptanceBody = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
@@ -424,6 +427,7 @@ test('Space OAuth accepts a pending invitation with the freshly authenticated ac
timeout: 5_000,
});
expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token');
expect(acceptanceBody).toEqual({ token: 'matching-invitation' });
expect(
await page.evaluate(() => ({
token: localStorage.getItem('token'),
+95 -61
View File
@@ -1,64 +1,98 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
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: [],
for (const role of ['owner', 'viewer'] as const) {
for (const bound of [false, true]) {
test(`LangBot Models uses Workspace billing for ${role}, owner bound=${bound}`, async ({
page,
}) => {
const workspace = makeWorkspaceEntry(
'billing-workspace',
'Billing Workspace',
'local',
);
workspace.membership.role = role;
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
const billingHeaders: Array<Record<string, string>> = [];
await page.route('**/api/v1/user/space-credits', async (route) => {
billingHeaders.push(route.request().headers());
await route.fulfill({
json: {
code: 0,
data: {
credits: bound ? 12500 : null,
owner_space_bound: bound,
is_workspace_owner: role === 'owner',
},
],
},
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.getByText(
'The Workspace owner must connect a LangBot Account for LangBot Models.',
{ exact: true },
),
).toBeVisible();
});
},
});
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: {
code: 0,
data: {
providers: [
{
uuid: 'langbot-models-provider',
name: 'LangBot Models',
requester: 'space-chat-completions',
base_url: '',
api_keys: [],
},
],
},
},
}),
);
await page.route('**/api/v1/provider/requesters**', (route) =>
route.fulfill({ json: { code: 0, data: { requesters: [] } } }),
);
await page.route('**/api/v1/provider/models/**', (route) =>
route.fulfill({ json: { code: 0, data: { models: [] } } }),
);
await page.goto('/home?action=showModelSettings');
await expect.poll(() => billingHeaders.length).toBeGreaterThan(0);
expect(billingHeaders[0]).toMatchObject({
authorization: 'Bearer playwright-token',
'x-workspace-id': 'billing-workspace',
});
const dialog = page.getByRole('dialog');
await expect(
dialog.getByText(
'The Workspace owner must connect a LangBot Account for LangBot Models.',
{ exact: true },
),
).toHaveCount(bound ? 0 : 1);
await expect(
dialog.getByRole('button', {
name: 'Login with LangBot Account',
exact: true,
}),
).toHaveCount(role === 'owner' && !bound ? 1 : 0);
await expect(
dialog.getByText('2.50 Credits', { exact: true }),
).toHaveCount(bound ? 1 : 0);
if (bound) {
await expect(
dialog
.getByText('2.50 Credits', { exact: true })
.locator('..')
.getByRole('button'),
).toHaveCount(role === 'owner' ? 1 : 0);
}
await expect(
dialog.getByText(
"Uses the Workspace owner's LangBot Account billing and credits.",
{ exact: true },
),
).toHaveCount(role === 'viewer' && bound ? 1 : 0);
});
}
}
+119
View File
@@ -62,6 +62,7 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
});
let authenticatedAssetRequests = 0;
const assetHeaders: Array<Record<string, string>> = [];
let pageSdkRequests = 0;
let pageApiRequests = 0;
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
@@ -91,6 +92,7 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
async (route) => {
authenticatedAssetRequests += 1;
assetHeaders.push(route.request().headers());
await route.fulfill({
status: 200,
contentType: 'text/html',
@@ -135,6 +137,10 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
'true',
);
expect(authenticatedAssetRequests).toBeGreaterThan(0);
expect(assetHeaders[0]).toMatchObject({
authorization: 'Bearer playwright-token',
'x-workspace-id': 'workspace-cloud',
});
expect(pageSdkRequests).toBe(1);
expect(pageApiRequests).toBe(1);
await expect(page.getByText('Loading...')).toHaveCount(0);
@@ -289,3 +295,116 @@ for (const pendingAsset of [false, true]) {
}
});
}
test('plugin asset hook replaces and revokes URLs on a same-document Workspace change', async ({
page,
}) => {
const first = makeWorkspaceEntry('asset-first', 'First', 'local');
const second = makeWorkspaceEntry('asset-second', 'Second', 'local');
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [first],
});
const headers: Array<Record<string, string>> = [];
await page.route(
'**/api/v1/plugins/test/asset/authenticated-assets/page.html',
async (route) => {
headers.push(route.request().headers());
await route.fulfill({
contentType: 'text/html',
body: `<h1>${headers.at(-1)?.['x-workspace-id']}</h1>`,
});
},
);
await page.goto('/home/bots');
await expect
.poll(() =>
page.evaluate(async () => {
const path = '/src/app/infra/http/currentWorkspaceStore.ts';
return (await import(path)).getCurrentWorkspaceSnapshot()?.workspace
.uuid;
}),
)
.toBe('asset-first');
await page.evaluate(async () => {
const resources = performance
.getEntriesByType('resource')
.map((entry) => entry.name);
const reactPath = resources.find((url) =>
new URL(url).pathname.endsWith('/react.js'),
)!;
const domPath = resources.find((url) =>
new URL(url).pathname.endsWith('/react-dom_client.js'),
)!;
const React = (await import(reactPath)).default;
const domModule = await import(domPath);
const { createRoot } = domModule.default || domModule;
const hookPath = '/src/hooks/useAuthenticatedPluginResource.ts';
const { useAuthenticatedPluginAsset } = await import(hookPath);
const host = document.createElement('main');
host.id = 'asset-hook';
document.getElementById('root')!.style.display = 'none';
document.body.appendChild(host);
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => {
host.dataset.revoked = JSON.stringify([
...JSON.parse(host.dataset.revoked || '[]'),
url,
]);
revoke(url);
};
function Resource() {
const resource = useAuthenticatedPluginAsset(
'test',
'asset',
'page.html',
);
return React.createElement(
'output',
{ 'data-testid': 'asset-url' },
resource.url,
);
}
function Harness() {
const [mounted, setMounted] = React.useState(true);
return React.createElement(
React.Fragment,
null,
mounted && React.createElement(Resource),
React.createElement(
'button',
{ onClick: () => setMounted(false) },
'Unmount resource',
),
);
}
createRoot(host).render(React.createElement(Harness));
});
const output = page.getByTestId('asset-url');
await expect(output).toHaveText(/^blob:/);
const oldURL = await output.textContent();
await page.evaluate(async (workspace) => {
const contextPath = '/src/app/infra/http/workspaceContext.ts';
const storePath = '/src/app/infra/http/currentWorkspaceStore.ts';
const context = await import(contextPath);
const store = await import(storePath);
context.setActiveWorkspaceUuid(workspace.workspace.uuid);
store.setCurrentWorkspaceSnapshot(workspace);
}, second);
await expect(output).toHaveText(/^blob:/);
await expect(output).not.toHaveText(oldURL!);
await expect
.poll(() => headers.map((item) => item['x-workspace-id']))
.toEqual(['asset-first', 'asset-second']);
expect(
headers.every((item) => item.authorization === 'Bearer playwright-token'),
).toBe(true);
const newURL = await output.textContent();
await expect
.poll(() => page.locator('#asset-hook').getAttribute('data-revoked'))
.toBe(JSON.stringify([oldURL]));
await page.getByRole('button', { name: 'Unmount resource' }).click();
await expect(output).toHaveCount(0);
await expect
.poll(() => page.locator('#asset-hook').getAttribute('data-revoked'))
.toBe(JSON.stringify([oldURL, newURL]));
});