mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
test(web): verify workspace switching and quota failure boundaries
This commit is contained in:
@@ -139,3 +139,153 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||
expect(pageApiRequests).toBe(1);
|
||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const pendingAsset of [false, true]) {
|
||||
test(`Workspace switch discards ${pendingAsset ? 'late' : 'loaded'} plugin assets`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [
|
||||
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
|
||||
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
|
||||
],
|
||||
});
|
||||
await page.route('**/api/v1/plugins', async (route) => {
|
||||
const scope = route.request().headers()['x-workspace-id'];
|
||||
expect(['workspace-alpha', 'workspace-beta']).toContain(scope);
|
||||
const label = scope === 'workspace-alpha' ? 'Alpha page' : 'Beta page';
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
install_source: 'marketplace',
|
||||
install_info: {},
|
||||
debug: false,
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: {
|
||||
author: 'qa',
|
||||
name: 'scoped-page',
|
||||
version: '1.0.0',
|
||||
label: { en_US: label },
|
||||
},
|
||||
spec: {
|
||||
pages: [
|
||||
{
|
||||
id: 'overview',
|
||||
path: 'overview.html',
|
||||
label: { en_US: label },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const assetHeaders: Record<string, string>[] = [];
|
||||
let releaseAsset = () => {};
|
||||
const assetGate = new Promise<void>((resolve) => {
|
||||
releaseAsset = resolve;
|
||||
});
|
||||
let assetSettled = () => {};
|
||||
const assetFinished = new Promise<void>((resolve) => {
|
||||
assetSettled = resolve;
|
||||
});
|
||||
await page.route(
|
||||
'**/api/v1/plugins/qa/scoped-page/authenticated-assets/overview.html',
|
||||
async (route) => {
|
||||
const headers = route.request().headers();
|
||||
assetHeaders.push(headers);
|
||||
expect(['workspace-alpha', 'workspace-beta']).toContain(
|
||||
headers['x-workspace-id'],
|
||||
);
|
||||
if (pendingAsset && headers['x-workspace-id'] === 'workspace-alpha') {
|
||||
await assetGate;
|
||||
}
|
||||
const label =
|
||||
headers['x-workspace-id'] === 'workspace-alpha' ? 'Alpha' : 'Beta';
|
||||
await route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `<!doctype html><html><body><h1>${label} private page</h1></body></html>`,
|
||||
});
|
||||
if (headers['x-workspace-id'] === 'workspace-alpha') assetSettled();
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto('/home/plugin-pages?id=qa%2Fscoped-page%2Foverview');
|
||||
await page.getByRole('button', { name: /Alpha Workspace/ }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Alpha page' }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => assetHeaders.length).toBeGreaterThan(0);
|
||||
let previousAssetUrl: string | null = null;
|
||||
if (!pendingAsset) {
|
||||
await expect(
|
||||
page.frameLocator('iframe').getByRole('heading', {
|
||||
name: 'Alpha private page',
|
||||
}),
|
||||
).toBeVisible();
|
||||
previousAssetUrl = await page.locator('iframe').getAttribute('src');
|
||||
}
|
||||
expect(assetHeaders[0]['x-workspace-id']).toBe('workspace-alpha');
|
||||
expect(assetHeaders[0].authorization).toBe('Bearer playwright-token');
|
||||
|
||||
await page.getByRole('button', { name: 'Switch Workspace' }).click();
|
||||
await page.getByRole('menuitem', { name: /Beta Workspace/ }).click();
|
||||
const switcher = page.getByRole('button', { name: 'Switch Workspace' });
|
||||
await expect(page).toHaveURL('/home/monitoring');
|
||||
await expect(switcher).toContainText('Beta Workspace');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Beta page' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Alpha page' }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('iframe')).toHaveCount(0);
|
||||
if (previousAssetUrl) {
|
||||
expect(
|
||||
await page.evaluate(async (url) => {
|
||||
try {
|
||||
await fetch(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, previousAssetUrl),
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
const assetsBeforeBeta = assetHeaders.length;
|
||||
await page.getByRole('button', { name: 'Beta page' }).click();
|
||||
await expect
|
||||
.poll(() => assetHeaders.length)
|
||||
.toBeGreaterThan(assetsBeforeBeta);
|
||||
expect(assetHeaders.at(-1)?.['x-workspace-id']).toBe('workspace-beta');
|
||||
expect(assetHeaders.at(-1)?.authorization).toBe(
|
||||
'Bearer playwright-token',
|
||||
);
|
||||
const pluginFrame = page.frameLocator('iframe');
|
||||
await expect(
|
||||
pluginFrame.getByRole('heading', { name: 'Beta private page' }),
|
||||
).toBeVisible();
|
||||
releaseAsset();
|
||||
await assetFinished;
|
||||
await expect(
|
||||
pluginFrame.getByRole('heading', { name: 'Beta private page' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
pluginFrame.getByRole('heading', { name: 'Alpha private page' }),
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
releaseAsset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,3 +148,192 @@ test('quota-reached create actions are disabled and explain the current limit',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
for (const kind of ['plugin', 'skill'] as const) {
|
||||
test(`GitHub ${kind} confirmation rejects a failed quota check and can retry`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/system/info', (route) =>
|
||||
fulfill(route, {
|
||||
debug: false,
|
||||
version: 'quota-e2e',
|
||||
edition: 'community',
|
||||
cloud_service_url: 'https://space.langbot.app',
|
||||
enable_marketplace: true,
|
||||
allow_modify_login_info: true,
|
||||
disable_models_service: false,
|
||||
limitation: { max_extensions: 3 },
|
||||
outbound_ips: [],
|
||||
wizard_status: 'completed',
|
||||
wizard_progress: null,
|
||||
}),
|
||||
);
|
||||
|
||||
let checkingQuota = false;
|
||||
let quotaChecks = 0;
|
||||
let releaseQuotaCheck!: () => void;
|
||||
const quotaCheckGate = new Promise<void>((resolve) => {
|
||||
releaseQuotaCheck = resolve;
|
||||
});
|
||||
await page.route('**/api/v1/plugins', async (route) => {
|
||||
if (checkingQuota) {
|
||||
quotaChecks += 1;
|
||||
await quotaCheckGate;
|
||||
checkingQuota = false;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
json: { code: 503, message: 'Quota service unavailable' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfill(route, { plugins: [] });
|
||||
});
|
||||
|
||||
const skillUrl =
|
||||
'https://github.com/quota-fixture/extension/blob/main/SKILL.md';
|
||||
await page.route('**/api/v1/plugins/github/releases', (route) =>
|
||||
fulfill(route, {
|
||||
owner: 'quota-fixture',
|
||||
repo: 'extension',
|
||||
releases: [
|
||||
{
|
||||
id: 7,
|
||||
tag_name: 'v1.0.0',
|
||||
name: 'Quota Test Release',
|
||||
published_at: '2026-01-01T00:00:00Z',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins/github/release-assets', (route) =>
|
||||
fulfill(route, {
|
||||
assets: [
|
||||
{
|
||||
id: 42,
|
||||
name: 'quota-test.lbpkg',
|
||||
size: 1024,
|
||||
download_url: 'https://example.com/quota-test.lbpkg',
|
||||
content_type: 'application/octet-stream',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/skills/install/github/preview', (route) =>
|
||||
fulfill(route, {
|
||||
skills: [
|
||||
{
|
||||
name: 'quota-test-skill',
|
||||
display_name: 'Quota Test Skill',
|
||||
description: 'Skill used to verify quota confirmation',
|
||||
instructions: '# Quota Test Skill',
|
||||
package_root: '.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const installRequests: { path: string; method: string; body: unknown }[] =
|
||||
[];
|
||||
await page.route(
|
||||
/\/api\/v1\/(plugins|skills)\/install\/github$/,
|
||||
(route) => {
|
||||
const request = route.request();
|
||||
installRequests.push({
|
||||
path: new URL(request.url()).pathname,
|
||||
method: request.method(),
|
||||
body: request.postDataJSON(),
|
||||
});
|
||||
return fulfill(
|
||||
route,
|
||||
kind === 'plugin' ? { task_id: 'quota-test-task' } : { skills: [] },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto('/home/add-extension');
|
||||
await page.getByRole('button', { name: 'Manual Add', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Install from GitHub' }).click();
|
||||
await page
|
||||
.getByPlaceholder('GitHub repository, Release, or SKILL.md link')
|
||||
.fill(
|
||||
kind === 'plugin'
|
||||
? 'https://github.com/quota-fixture/extension'
|
||||
: skillUrl,
|
||||
);
|
||||
const confirm = page.getByRole('button', { name: 'Confirm', exact: true });
|
||||
await confirm.click();
|
||||
if (kind === 'plugin') {
|
||||
await page.getByText('Quota Test Release', { exact: true }).click();
|
||||
await page.getByText('quota-test.lbpkg', { exact: true }).click();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText('Quota Test Skill', { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
checkingQuota = true;
|
||||
await confirm.click();
|
||||
try {
|
||||
await expect.poll(() => quotaChecks).toBe(1);
|
||||
await expect(
|
||||
page.getByText(`Installing ${kind}...`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(confirm).toHaveCount(0);
|
||||
await page.keyboard.press('Enter');
|
||||
expect(quotaChecks).toBe(1);
|
||||
expect(installRequests).toEqual([]);
|
||||
} finally {
|
||||
releaseQuotaCheck();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Unable to verify the current workspace quota. Please try again.',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(installRequests).toEqual([]);
|
||||
await expect(confirm).toBeEnabled();
|
||||
|
||||
await confirm.click();
|
||||
await expect.poll(() => installRequests.length).toBe(1);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Manual Add',
|
||||
exact: true,
|
||||
includeHidden: true,
|
||||
}),
|
||||
).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(
|
||||
page.getByPlaceholder('GitHub repository, Release, or SKILL.md link'),
|
||||
).toBeHidden();
|
||||
expect(installRequests).toEqual([
|
||||
{
|
||||
path: `/api/v1/${kind === 'plugin' ? 'plugins' : 'skills'}/install/github`,
|
||||
method: 'POST',
|
||||
body:
|
||||
kind === 'plugin'
|
||||
? {
|
||||
asset_id: 42,
|
||||
release_id: 7,
|
||||
owner: 'quota-fixture',
|
||||
repo: 'extension',
|
||||
release_tag: 'v1.0.0',
|
||||
}
|
||||
: {
|
||||
asset_url: skillUrl,
|
||||
owner: 'quota-fixture',
|
||||
repo: 'extension',
|
||||
release_tag: 'main',
|
||||
},
|
||||
},
|
||||
]);
|
||||
if (kind === 'skill') {
|
||||
await expect(
|
||||
page.getByText('Skill installed successfully', { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,21 +99,6 @@ test('add-extension page disables all install entry points at the quota', () =>
|
||||
assert.match(page, /quota=\{extensionQuota\}/);
|
||||
});
|
||||
|
||||
test('extension confirmation checks fail closed and enter an in-flight state first', () => {
|
||||
const page = fs.readFileSync(addExtensionPath, 'utf8');
|
||||
|
||||
assert.match(page, /limitation\.quotaCheckFailed/);
|
||||
assert.doesNotMatch(page, /If we can't check, let backend handle it/);
|
||||
assert.match(
|
||||
page,
|
||||
/setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
|
||||
);
|
||||
assert.match(
|
||||
page,
|
||||
/setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('quota tooltip copy is localized in Simplified Chinese', () => {
|
||||
const source = fs.readFileSync(zhPath, 'utf8');
|
||||
assert.match(source, /createDisabledTooltip/);
|
||||
|
||||
Reference in New Issue
Block a user