diff --git a/web/tests/e2e/plugin-page-auth.spec.ts b/web/tests/e2e/plugin-page-auth.spec.ts index 384567120..ce8ab0a88 100644 --- a/web/tests/e2e/plugin-page-auth.spec.ts +++ b/web/tests/e2e/plugin-page-auth.spec.ts @@ -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[] = []; + let releaseAsset = () => {}; + const assetGate = new Promise((resolve) => { + releaseAsset = resolve; + }); + let assetSettled = () => {}; + const assetFinished = new Promise((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: `

${label} private page

`, + }); + 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(); + } + }); +} diff --git a/web/tests/e2e/quota-create-actions.spec.ts b/web/tests/e2e/quota-create-actions.spec.ts index 2d275c6e2..8c1c64251 100644 --- a/web/tests/e2e/quota-create-actions.spec.ts +++ b/web/tests/e2e/quota-create-actions.spec.ts @@ -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((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(); + } + }); +} diff --git a/web/tests/unit/quota-create-actions.test.mjs b/web/tests/unit/quota-create-actions.test.mjs index 8bea7a364..49a1d63c2 100644 --- a/web/tests/unit/quota-create-actions.test.mjs +++ b/web/tests/unit/quota-create-actions.test.mjs @@ -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/);