mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
test(web): replace account source checks with browser assertions
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +137,125 @@ 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);
|
||||
});
|
||||
|
||||
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]));
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/app/login/page.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('normal Cloud login uses the standard Space OAuth callback path', () => {
|
||||
assert.doesNotMatch(source, /cloudEntry/);
|
||||
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
|
||||
});
|
||||
|
||||
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
|
||||
assert.doesNotMatch(source, /cloudEntry/);
|
||||
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
|
||||
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
|
||||
});
|
||||
@@ -10,45 +10,6 @@ const root = path.resolve(
|
||||
);
|
||||
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/);
|
||||
assert.match(source, /isWorkspaceOwner && \(\s*<Button/);
|
||||
});
|
||||
|
||||
test('workspace member controls never offer ownership transfer', () => {
|
||||
const source = read(
|
||||
'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
|
||||
|
||||
@@ -14,23 +14,6 @@ function readSource(relativePath) {
|
||||
const homeSidebarSource = readSource(
|
||||
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
|
||||
);
|
||||
const botFormSource = readSource(
|
||||
'src/app/home/bots/components/bot-form/BotForm.tsx',
|
||||
);
|
||||
const dynamicFormSource = readSource(
|
||||
'src/app/home/components/dynamic-form/DynamicFormComponent.tsx',
|
||||
);
|
||||
const kbFormSource = readSource(
|
||||
'src/app/home/knowledge/components/kb-form/KBForm.tsx',
|
||||
);
|
||||
const settingsDialogSource = readSource(
|
||||
'src/app/home/components/settings-dialog/SettingsDialog.tsx',
|
||||
);
|
||||
const pluginPageSource = readSource('src/app/home/plugin-pages/page.tsx');
|
||||
const authenticatedPluginResourceSource = readSource(
|
||||
'src/hooks/useAuthenticatedPluginResource.ts',
|
||||
);
|
||||
|
||||
test('hides the entire workspace switcher slot for a singleton local workspace', () => {
|
||||
assert.match(homeSidebarSource, /useWorkspaceBootstrap/);
|
||||
assert.match(
|
||||
@@ -42,94 +25,3 @@ test('hides the entire workspace switcher slot for a singleton local workspace',
|
||||
/\{showWorkspaceSwitcher\s*&&\s*\(\s*<div className="px-2[^>]*>\s*<WorkspaceSwitcher/,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps bot forms spaced with responsive setup and detail columns', () => {
|
||||
const fieldset = botFormSource.match(
|
||||
/<fieldset\s[\s\S]*?disabled=\{isLoading\}/,
|
||||
)?.[0];
|
||||
assert.ok(fieldset, 'the form must retain its loading-disabled fieldset');
|
||||
assert.match(
|
||||
fieldset,
|
||||
/initBotId\s*\?\s*'grid gap-4 lg:h-full lg:min-h-0 lg:grid-cols-\[minmax\(0,2fr\)_minmax\(0,3fr\)\] lg:grid-rows-\[minmax\(0,1fr\)\]'\s*:\s*'grid items-start gap-6 lg:grid-cols-\[minmax\(16rem,0\.7fr\)_minmax\(0,2fr\)\]'/,
|
||||
);
|
||||
assert.match(kbFormSource, /<form[\s\S]*?className="space-y-6"/);
|
||||
});
|
||||
|
||||
test('hides the manifest connection switch behind the dedicated selector', () => {
|
||||
assert.match(
|
||||
botFormSource,
|
||||
/webhookField\?\.show_if\?\.field\s*\?\s*\[webhookField\.show_if\.field\]/,
|
||||
);
|
||||
assert.match(
|
||||
botFormSource,
|
||||
/hiddenItemNames=\{connectionModeControllerNames\}/,
|
||||
);
|
||||
assert.match(
|
||||
dynamicFormSource,
|
||||
/if \(hiddenItemNames\?\.includes\(config\.name\)\) return null/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
dynamicFormSource.match(
|
||||
/const editableItems = useMemo\([\s\S]*?\n \);/,
|
||||
)?.[0] ?? '',
|
||||
/hiddenItemNames/,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not expose storage analysis in Cloud settings or via a deep link', () => {
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/canViewStorageAnalysis\s*&&\s*\(\s*<DropdownMenuItem[\s\S]*?openSettings\('storageAnalysis'\)/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/const canViewStorageAnalysis\s*=\s*currentWorkspace\?\.workspace\.source\s*!==\s*'cloud_projection'\s*&&\s*canViewAudit/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/item\.id === 'storageAnalysis'[\s\S]*?return canViewStorageAnalysis/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/section === 'storageAnalysis' && !canViewStorageAnalysis/,
|
||||
);
|
||||
assert.match(
|
||||
settingsDialogSource,
|
||||
/section === 'storageAnalysis' &&\s*canViewStorageAnalysis &&\s*\(\s*<StorageAnalysisPanel/,
|
||||
);
|
||||
});
|
||||
|
||||
test('renders storage analysis with the storage icon in the user menu', () => {
|
||||
assert.match(
|
||||
homeSidebarSource,
|
||||
/canViewStorageAnalysis\s*&&\s*\(\s*<DropdownMenuItem[\s\S]*?<HardDrive\s*\/>[\s\S]*?\{t\('storageAnalysis\.title'\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test('loads plugin pages through the authenticated Workspace-scoped asset route', () => {
|
||||
assert.match(pluginPageSource, /useAuthenticatedPluginAsset/);
|
||||
assert.match(
|
||||
pluginPageSource,
|
||||
/useAuthenticatedPluginAsset\(\s*author,\s*pluginName,\s*pagePath,?\s*\)/,
|
||||
);
|
||||
assert.match(pluginPageSource, /src=\{assetUrl\}/);
|
||||
assert.doesNotMatch(pluginPageSource, /getPluginAssetURL\(/);
|
||||
assert.match(pluginPageSource, /plugins\.loadFailed/);
|
||||
assert.match(pluginPageSource, /loadedAssetUrl !== assetUrl/);
|
||||
});
|
||||
|
||||
test('revokes and reloads authenticated plugin resources when the Workspace changes', () => {
|
||||
assert.match(authenticatedPluginResourceSource, /useCurrentWorkspace/);
|
||||
assert.match(
|
||||
authenticatedPluginResourceSource,
|
||||
/const workspaceUuid = currentWorkspace\?\.workspace\.uuid;/,
|
||||
);
|
||||
assert.match(
|
||||
authenticatedPluginResourceSource,
|
||||
/\[author, name, filepath, resourceKey\]/,
|
||||
);
|
||||
assert.match(
|
||||
authenticatedPluginResourceSource,
|
||||
/resource\.key === resourceKey \? resource\.url : ''/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user