From 8e447dae095be31179672bb8d88d8ca68dd9cafe Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:09:23 +0800 Subject: [PATCH] test(web): exercise marketplace installs and wizard contracts --- web/tests/e2e/crud-smoke.spec.ts | 524 ++++++++++-------- .../e2e/knowledge-engine-marketplace.spec.ts | 148 +++++ web/tests/e2e/marketplace-likes.spec.ts | 89 +++ web/tests/e2e/plugin-upgrade-recovery.spec.ts | 142 +++++ .../e2e/wizard-platform-regressions.spec.ts | 351 +++++++----- .../knowledge-engine-marketplace.test.mjs | 384 ++++++++----- web/tests/unit/marketplace-likes.test.mjs | 217 +++++--- web/tests/unit/wizard-page-bot.test.mjs | 230 ++++---- 8 files changed, 1400 insertions(+), 685 deletions(-) create mode 100644 web/tests/e2e/knowledge-engine-marketplace.spec.ts create mode 100644 web/tests/e2e/marketplace-likes.spec.ts create mode 100644 web/tests/e2e/plugin-upgrade-recovery.spec.ts diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index ba0d3b176..60eccb8eb 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -865,253 +865,329 @@ test.describe('pipeline advanced flows', () => { }); test.describe('agent runner resource selectors', () => { - test('installs an Runner from the grouped empty selector and refreshes it', async ({ - page, - }) => { - await installLangBotApiMocks(page, { authenticated: true }); + for (const kind of ['agent', 'pipeline'] as const) { + test(`installs an Runner from the grouped empty selector and refreshes it in ${kind}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); - const runnerId = 'plugin:qa/MarketplaceRunner/default'; - let installed = false; - let installRequests = 0; - let taskPolls = 0; - let marketplaceSearchBody: Record | undefined; + const runnerId = 'plugin:qa/MarketplaceRunner/default'; + let installed = false; + let finished = false; + let installRequests = 0; + let taskPolls = 0; + let marketplaceSearchBody: Record | undefined; - const apiResponse = (data: unknown) => - JSON.stringify({ - code: 0, - message: 'ok', - data, - timestamp: Date.now(), - }); - const runnerConfig = () => ({ - name: 'ai', - label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, - stages: [ - { - name: 'runner', - label: { en_US: 'Runtime', zh_Hans: '运行方式' }, - config: [ - { - name: 'id', - label: { en_US: 'Runner', zh_Hans: '运行器' }, - type: 'select', - required: true, - default: '', - options: installed - ? [ - { - name: runnerId, - label: { - en_US: 'Marketplace Runner', - zh_Hans: '市场运行器', - }, - }, - ] - : [], - }, - ], - }, - ...(installed - ? [ + const apiResponse = (data: unknown) => + JSON.stringify({ + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + }); + const runnerConfig = () => ({ + name: 'ai', + label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, + stages: [ + { + name: 'runner', + label: { en_US: 'Runtime', zh_Hans: '运行方式' }, + config: [ { - name: runnerId, - label: { - en_US: 'Marketplace Runner', - zh_Hans: '市场运行器', - }, - config: [], + name: 'id', + label: { en_US: 'Runner', zh_Hans: '运行器' }, + type: 'select', + required: true, + default: '', + options: installed + ? [ + { + name: runnerId, + label: { + en_US: 'Marketplace Runner', + zh_Hans: '市场运行器', + }, + }, + ] + : [], }, - ] - : []), - ], - }); - - await page.route('**/api/v1/agents/_/metadata', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - runner_config: runnerConfig(), - kinds: [], - }), - }), - ); - await page.route('**/api/v1/agents/agent-empty', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - agent: { - uuid: 'agent-empty', - name: 'Empty Runner Agent', - description: '', - emoji: 'A', - kind: 'agent', - config: { - runner: { id: '', 'expire-time': 0 }, - runner_config: {}, - }, - supported_event_patterns: ['*'], + ], }, - }), - }), - ); - await page.route('**/api/v1/pipelines/_/metadata', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ configs: [runnerConfig()] }), - }), - ); - await page.route('**/api/v1/plugins', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - plugins: installed + ...(installed ? [ { - manifest: { - manifest: { - metadata: { author: 'qa', name: 'MarketplaceRunner' }, - }, + name: runnerId, + label: { + en_US: 'Marketplace Runner', + zh_Hans: '市场运行器', }, + config: [], }, ] - : [], - }), - }), - ); - await page.route('**/api/v1/plugins/install/marketplace', (route) => { - installRequests += 1; - installed = true; - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ task_id: 77 }), + : []), + ], }); - }); - await page.route('**/api/v1/system/tasks/77', (route) => { - taskPolls += 1; - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: apiResponse({ - id: 77, - name: 'plugin-install-marketplace', - label: 'Marketplace Runner', - runtime: { done: true, exception: null }, - task_context: { current_action: 'complete', metadata: {} }, - }), - }); - }); - await page.route( - 'https://space.langbot.app/api/v1/marketplace/extensions/search', - async (route) => { - marketplaceSearchBody = JSON.parse( - route.request().postData() || '{}', - ) as Record; - await route.fulfill({ + + await page.route('**/api/v1/agents/_/metadata', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: apiResponse({ - extensions: [ - { - id: 1, - plugin_id: 'qa/MarketplaceRunner', - author: 'qa', - name: 'MarketplaceRunner', - label: { - en_US: 'Marketplace Runner', - zh_Hans: '市场运行器', - }, - description: { - en_US: 'Runner used by the grouped selector test.', - zh_Hans: '用于分组选择器测试的运行器。', - }, - icon: '', - repository: 'https://example.test/runner', - tags: [], - install_count: 12, - latest_version: '1.0.0', - components: { Runner: 1 }, - runner_usages: ['agent'], - status: 'live', - type: 'plugin', - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', + runner_config: runnerConfig(), + kinds: [], + }), + }), + ); + await page.route('**/api/v1/agents/agent-empty', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + agent: { + uuid: 'agent-empty', + name: 'Empty Runner Agent', + description: '', + emoji: 'A', + kind: 'agent', + config: { + runner: { id: '', 'expire-time': 0 }, + runner_config: {}, }, - ], - total: 1, + supported_event_patterns: ['*'], + }, + }), + }), + ); + await page.route('**/api/v1/pipelines/_/metadata', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ configs: [runnerConfig()] }), + }), + ); + await page.route('**/api/v1/plugins', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + plugins: installed + ? [ + { + manifest: { + manifest: { + metadata: { + author: 'qa', + name: 'MarketplaceRunner', + description: { + en_US: 'Installed plugin description', + }, + }, + }, + }, + }, + ] + : [], + }), + }), + ); + await page.route('**/api/v1/plugins/install/marketplace', (route) => { + installRequests += 1; + installed = true; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ task_id: 77 }), + }); + }); + await page.route('**/api/v1/system/tasks/77', (route) => { + taskPolls += 1; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + id: 77, + name: 'plugin-install-marketplace', + label: 'Marketplace Runner', + runtime: { done: finished, exception: null }, + task_context: { current_action: 'complete', metadata: {} }, }), }); - }, - ); - - await page.goto('/home/agents?id=agent-empty'); - await page.getByRole('tab', { name: /^Runner$/ }).click(); - - const runnerSelect = page.getByRole('combobox', { name: 'Runner' }); - const triggerBox = await runnerSelect.boundingBox(); - await runnerSelect.click(); - await expect(page.getByText('Installed Runners')).toBeVisible(); - await expect( - page.getByText('No Runner extension is installed yet.'), - ).toBeVisible(); - await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible(); - const selectorPopup = page.locator('[data-slot="select-content"]'); - await expect( - selectorPopup.getByText('Runner used by the grouped selector test.', { - exact: true, - }), - ).toBeVisible(); - const popupBox = await selectorPopup.boundingBox(); - expect(triggerBox?.width).toBeLessThanOrEqual(353); - expect(popupBox?.width ?? 0).toBeLessThanOrEqual( - (triggerBox?.width ?? 0) + 1, - ); - expect(popupBox?.width ?? 0).toBeGreaterThan((triggerBox?.width ?? 0) - 20); - await expect - .poll(() => marketplaceSearchBody) - .toMatchObject({ - component_filter: 'Runner', - type_filter: 'plugin', }); + await page.route( + 'https://space.langbot.app/api/v1/marketplace/extensions/search', + async (route) => { + marketplaceSearchBody = JSON.parse( + route.request().postData() || '{}', + ) as Record; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: apiResponse({ + extensions: [ + { + id: 1, + plugin_id: 'qa/MarketplaceRunner', + author: 'qa', + name: 'MarketplaceRunner', + label: { + en_US: 'Marketplace Runner', + zh_Hans: '市场运行器', + }, + description: { + en_US: 'Runner used by the grouped selector test.', + zh_Hans: '用于分组选择器测试的运行器。', + }, + icon: '', + repository: 'https://example.test/runner', + tags: [], + install_count: 12, + latest_version: '1.0.0', + components: { Runner: 1 }, + runner_usages: ['agent'], + status: 'live', + type: 'plugin', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + author: 'qa', + name: 'SecondRunner', + label: { en_US: 'A much longer second Runner label' }, + description: { en_US: 'Second runner description' }, + latest_version: '1.0.0', + components: { Runner: 1 }, + runner_usages: ['agent'], + install_count: 1, + type: 'plugin', + }, + ].filter( + (plugin) => !installed || plugin.name !== 'MarketplaceRunner', + ), + total: installed ? 1 : 2, + }), + }); + }, + ); - await page - .getByRole('button', { name: 'Install Marketplace Runner', exact: true }) - .click(); + await page.route('**/api/v1/pipelines/agent-empty', (route) => + route.fulfill({ + json: { + code: 0, + data: { + pipeline: { + uuid: 'agent-empty', + kind: 'pipeline', + name: 'Empty Runner Pipeline', + description: '', + emoji: 'P', + is_default: false, + config: { + ai: { + runner: { id: '', 'expire-time': 0 }, + runner_config: {}, + }, + trigger: {}, + safety: {}, + output: {}, + }, + }, + }, + }, + }), + ); + await page.goto( + kind === 'agent' + ? '/home/agents?id=agent-empty' + : '/home/pipelines?id=agent-empty', + ); + await page + .getByRole('tab', { name: kind === 'agent' ? /^Runner$/ : /^AI$/ }) + .click(); - await expect.poll(() => installRequests).toBe(1); - await expect.poll(() => taskPolls).toBeGreaterThan(0); - await expect( - page.getByRole('option', { - name: 'Marketplace Runner Runner used by the grouped selector test.', - exact: true, - }), - ).toBeVisible(); - await page - .getByRole('option', { - name: 'Marketplace Runner Runner used by the grouped selector test.', - exact: true, - }) - .click(); - await expect(runnerSelect).toContainText('Marketplace Runner'); + const runnerSelect = page.getByRole('combobox', { + name: 'Runner', + includeHidden: true, + }); + const triggerBox = await runnerSelect.boundingBox(); + await runnerSelect.click(); + await expect(page.getByText('Installed Runners')).toBeVisible(); + await expect( + page.getByText('No Runner extension is installed yet.'), + ).toBeVisible(); + await expect( + page.getByText('Runner plugins in Marketplace'), + ).toBeVisible(); + const selectorPopup = page.locator('[data-slot="select-content"]'); + await expect( + selectorPopup.getByText('Runner used by the grouped selector test.', { + exact: true, + }), + ).toBeVisible(); + const popupBox = await selectorPopup.boundingBox(); + expect(triggerBox?.width).toBeLessThanOrEqual(353); + expect(popupBox?.width ?? 0).toBeLessThanOrEqual( + (triggerBox?.width ?? 0) + 1, + ); + expect(popupBox?.width ?? 0).toBeGreaterThan( + (triggerBox?.width ?? 0) - 20, + ); + await expect + .poll(() => marketplaceSearchBody) + .toMatchObject({ + component_filter: 'Runner', + type_filter: 'plugin', + }); - await runnerSelect.click(); - await expect( - page.getByRole('option', { - name: 'Marketplace Runner Runner used by the grouped selector test.', - exact: true, - }), - ).toBeVisible(); - await expect( - page.getByRole('button', { + const install = page.getByRole('button', { name: 'Install Marketplace Runner', exact: true, - }), - ).toHaveCount(0); - }); + }); + const otherInstall = page.getByRole('button', { + name: 'Install A much longer second Runner label', + exact: true, + }); + const positions = await Promise.all([ + install.boundingBox(), + otherInstall.boundingBox(), + ]); + expect(positions.every(Boolean)).toBe(true); + expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual( + 1, + ); + expect( + Math.abs(positions[0]!.width - positions[1]!.width), + ).toBeLessThanOrEqual(1); + await install.click(); + await expect(install).toContainText('%'); + await expect(otherInstall).toBeDisabled(); + await expect(runnerSelect).not.toContainText('Marketplace Runner'); + finished = true; + + await expect.poll(() => installRequests).toBe(1); + await expect.poll(() => taskPolls).toBeGreaterThan(0); + await expect( + page.getByRole('option', { + name: 'Marketplace Runner Installed plugin description', + exact: true, + }), + ).toBeVisible(); + await expect(runnerSelect).not.toContainText('Marketplace Runner'); + await expect( + page.getByRole('button', { + name: 'Install Marketplace Runner', + exact: true, + }), + ).toHaveCount(0); + await expect(otherInstall).toBeEnabled(); + await page + .getByRole('option', { + name: 'Marketplace Runner Installed plugin description', + exact: true, + }) + .click(); + await expect(runnerSelect).toContainText('Marketplace Runner'); + }); + } test('uses the compact Runner marketplace selector in pipeline AI settings', async ({ page, diff --git a/web/tests/e2e/knowledge-engine-marketplace.spec.ts b/web/tests/e2e/knowledge-engine-marketplace.spec.ts new file mode 100644 index 000000000..032d10ff3 --- /dev/null +++ b/web/tests/e2e/knowledge-engine-marketplace.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const reload of [false, true]) { + test(`knowledge engine installation keeps fields and requires explicit selection${reload ? ' after reload' : ''}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let finished = false, + installs = 0; + const searches: Record[] = []; + const engine = { + plugin_id: 'qa/engine', + name: { en_US: 'Installed QA Engine' }, + description: { en_US: 'Registered knowledge engine description' }, + creation_schema: [], + retrieval_schema: [], + capabilities: [], + }; + await page.route('**/api/v1/knowledge/engines', (route) => + route.fulfill({ + json: { code: 0, data: { engines: finished ? [engine] : [] } }, + }), + ); + await page.route('**/api/v1/marketplace/**/search', async (route) => { + searches.push(route.request().postDataJSON()); + const plugin = (name: string, label: string) => ({ + author: 'qa', + name, + label: { en_US: label }, + description: { en_US: `${label} description` }, + latest_version: '2.0.0', + type: 'plugin', + components: { KnowledgeEngine: 1 }, + install_count: 10, + }); + await route.fulfill({ + json: { + code: 0, + data: { + total: 2, + extensions: [ + plugin('engine', 'QA Engine'), + plugin('long-engine', 'A much longer engine display label'), + ], + }, + }, + }); + }); + await page.route('**/api/v1/plugins/install/marketplace', async (route) => { + installs++; + expect(route.request().postDataJSON()).toEqual({ + plugin_author: 'qa', + plugin_name: 'engine', + plugin_version: '2.0.0', + }); + await route.fulfill({ json: { code: 0, data: { task_id: 42 } } }); + }); + await page.route('**/api/v1/system/tasks/42', (route) => + route.fulfill({ + json: { + code: 0, + data: { + id: 42, + name: 'plugin-install-marketplace', + runtime: { done: finished, exception: null }, + task_context: { + current_action: 'Downloading', + metadata: { progress_percent: 20 }, + }, + }, + }, + }), + ); + await page.goto('/home/knowledge?id=new'); + await page.locator('input[name="name"]').fill('Engine choice test'); + await expect(page.locator('input[name="description"]')).toBeVisible(); + const selector = page.getByRole('combobox', { + name: 'Knowledge Engine', + exact: true, + }); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect( + page.getByText('Knowledge engine is required', { exact: true }), + ).toBeVisible(); + await selector.click(); + const first = page.getByRole('button', { + name: 'Install QA Engine', + exact: true, + }); + const second = page.getByRole('button', { + name: 'Install A much longer engine display label', + exact: true, + }); + await expect(first).toBeVisible(); + const positions = await Promise.all([ + first.boundingBox(), + second.boundingBox(), + ]); + expect(positions.every(Boolean)).toBe(true); + expect(Math.abs(positions[0]!.x - positions[1]!.x)).toBeLessThanOrEqual(1); + expect( + Math.abs(positions[0]!.width - positions[1]!.width), + ).toBeLessThanOrEqual(1); + await first.click(); + await expect(first).toContainText('%'); + await expect(second).toBeDisabled(); + if (reload) { + await expect.poll(() => installs).toBe(1); + await page.reload(); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await page.locator('input[name="name"]').fill('Recovered engine choice'); + await expect(selector).toHaveText('Select Knowledge Engine'); + } + finished = true; + await expect( + page.getByText('QA Engine is installed and ready to select', { + exact: true, + }), + ).toBeVisible({ timeout: 15000 }); + await page.keyboard.press('Escape'); + await expect(selector).toHaveText('Select Knowledge Engine'); + if (reload) + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect( + page.getByText('Knowledge engine is required', { exact: true }), + ).toBeVisible(); + await selector.click(); + const option = page.getByRole('option', { name: /Installed QA Engine/ }); + await expect( + option.getByText('Registered knowledge engine description'), + ).toBeVisible(); + await option.click(); + await expect(selector).toHaveText('Installed QA Engine'); + await expect( + page.getByText('Knowledge engine is required', { exact: true }), + ).toHaveCount(0); + const selectedBox = await selector.boundingBox(); + expect(selectedBox!.height).toBeLessThanOrEqual(40); + expect(installs).toBe(1); + expect(searches.length).toBeGreaterThan(0); + for (const request of searches) + expect(request).toMatchObject({ + component_filter: 'KnowledgeEngine', + type_filter: 'plugin', + }); + }); +} diff --git a/web/tests/e2e/marketplace-likes.spec.ts b/web/tests/e2e/marketplace-likes.spec.ts new file mode 100644 index 000000000..49a7e51f4 --- /dev/null +++ b/web/tests/e2e/marketplace-likes.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +test('marketplace defaults to hot sorting and updates the same browser like on its card', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + const searches: Record[] = [], + updates: unknown[] = []; + let fingerprint = ''; + await page.route('**/api/v1/marketplace/extensions/search', async (route) => { + searches.push(route.request().postDataJSON()); + await route.fulfill({ + json: { + code: 0, + data: { + total: 1, + extensions: [ + { + author: 'qa', + name: 'liked', + label: { en_US: 'QA Likes' }, + description: { en_US: 'Like test plugin' }, + latest_version: '1.0.0', + type: 'plugin', + status: 'live', + tags: [], + components: {}, + install_count: 5, + like_count: 12, + hot_score: 17, + }, + ], + }, + }, + }); + }); + await page.route( + '**/api/v1/marketplace/extensions/likes/status', + async (route) => { + fingerprint = route.request().postDataJSON().fingerprint; + expect(fingerprint).toEqual(expect.any(String)); + expect(fingerprint.length).toBeGreaterThan(0); + await route.fulfill({ json: { code: 0, data: { extensions: [] } } }); + }, + ); + await page.route( + '**/api/v1/marketplace/extensions/plugin/qa/liked/like', + async (route) => { + const body = route.request().postDataJSON(); + updates.push(body); + expect(route.request().method()).toBe('PUT'); + expect(body.fingerprint).toBe(fingerprint); + await route.fulfill({ + json: { + code: 0, + data: { + type: 'plugin', + extension_id: 'qa/liked', + liked: body.liked, + like_count: body.liked ? 13 : 12, + }, + }, + }); + }, + ); + await page.goto('/home/add-extension'); + await expect(page.getByText('QA Likes', { exact: true })).toBeVisible(); + await expect.poll(() => fingerprint).not.toBe(''); + const like = page.getByRole('button', { name: 'Like', exact: true }); + await expect(like).toHaveAttribute('aria-pressed', 'false'); + await expect(like).toHaveText('12'); + expect(searches.length).toBeGreaterThan(0); + expect(searches[0]).toMatchObject({ + sort_by: 'hot_score', + sort_order: 'DESC', + }); + await like.click(); + const unlike = page.getByRole('button', { name: 'Unlike', exact: true }); + await expect(unlike).toHaveAttribute('aria-pressed', 'true'); + await expect(unlike).toHaveText('13'); + await unlike.click(); + await expect(like).toHaveAttribute('aria-pressed', 'false'); + await expect(like).toHaveText('12'); + expect(updates).toEqual([ + { fingerprint, liked: true }, + { fingerprint, liked: false }, + ]); +}); diff --git a/web/tests/e2e/plugin-upgrade-recovery.spec.ts b/web/tests/e2e/plugin-upgrade-recovery.spec.ts new file mode 100644 index 000000000..e508de22e --- /dev/null +++ b/web/tests/e2e/plugin-upgrade-recovery.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +for (const entry of ['list', 'sidebar'] as const) { + test(`plugin upgrade from ${entry} recovers its operation and progress after reload`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + let upgrades = 0, + phase: 'checking' | 'validating' | 'launching' | 'done' | 'failed' = + 'checking'; + const task = () => ({ + id: 80, + name: 'plugin-upgrade-qa/upgradable', + label: 'qa/upgradable', + runtime: { + done: phase === 'done' || phase === 'failed', + exception: phase === 'failed' ? 'Signature rejected fixture' : null, + }, + task_context: { + current_action: { + checking: 'Checking update', + validating: 'Validating package', + launching: 'Waiting for plugin to become ready', + done: 'Plugin updated', + failed: 'Validating package', + }[phase], + metadata: { plugin_name: 'qa/upgradable', operation: 'upgrade' }, + }, + }); + const plugin = { + install_source: 'marketplace', + install_info: {}, + debug: false, + manifest: { + manifest: { + metadata: { + author: 'qa', + name: 'upgradable', + label: { en_US: 'Upgrade Fixture' }, + version: '1.0.0', + description: { en_US: 'Upgrade fixture plugin' }, + }, + spec: {}, + }, + }, + }; + await page.route('**/api/v1/plugins', (route) => + route.fulfill({ json: { code: 0, data: { plugins: [plugin] } } }), + ); + await page.route('**/api/v1/extensions', (route) => + route.fulfill({ + json: { code: 0, data: { extensions: [{ type: 'plugin', plugin }] } }, + }), + ); + await page.route( + '**/api/v1/plugins/qa/upgradable/upgrade', + async (route) => { + upgrades++; + expect(route.request().method()).toBe('POST'); + await route.fulfill({ json: { code: 0, data: { task_id: 80 } } }); + }, + ); + await page.route('**/api/v1/system/tasks?**', (route) => + route.fulfill({ + json: { code: 0, data: { tasks: upgrades ? [task()] : [] } }, + }), + ); + await page.route('**/api/v1/system/tasks/80', (route) => + route.fulfill({ json: { code: 0, data: task() } }), + ); + await page.goto('/home/extensions'); + if (entry === 'list') { + const card = page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('Upgrade Fixture', { exact: true }) }); + await card.getByRole('button').last().click(); + } else { + const item = page + .locator('[data-slot="sidebar-menu-sub-item"]') + .filter({ has: page.getByRole('link', { name: /Upgrade Fixture/ }) }); + await item.hover(); + await item.getByRole('button').click(); + } + await page + .getByRole('menuitem', { name: 'Update Plugin', exact: true }) + .click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Confirm Update', exact: true }) + .click(); + let dialog = page.getByRole('dialog'); + await expect( + dialog.getByRole('heading', { + name: 'Updating qa/upgradable', + exact: true, + }), + ).toBeVisible(); + await expect.poll(() => upgrades).toBe(1); + phase = 'validating'; + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '45', + ); + await page.reload(); + await page.getByRole('button', { name: /Plugin Tasks/ }).click(); + await page.getByText('qa/upgradable', { exact: true }).click(); + dialog = page.getByRole('dialog'); + await expect( + dialog.getByRole('heading', { + name: 'Updating qa/upgradable', + exact: true, + }), + ).toBeVisible(); + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '45', + ); + phase = 'launching'; + await expect + .poll(async () => + Number( + await dialog.getByRole('progressbar').getAttribute('aria-valuenow'), + ), + ) + .toBeGreaterThanOrEqual(88); + phase = entry === 'list' ? 'done' : 'failed'; + if (phase === 'done') { + await expect( + dialog.getByText('Plugin updated successfully', { exact: true }), + ).toBeVisible(); + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '100', + ); + } else + await expect( + dialog.getByText('Signature rejected fixture', { exact: true }), + ).toBeVisible(); + expect(upgrades).toBe(1); + }); +} diff --git a/web/tests/e2e/wizard-platform-regressions.spec.ts b/web/tests/e2e/wizard-platform-regressions.spec.ts index 7859e6acd..d74e2863b 100644 --- a/web/tests/e2e/wizard-platform-regressions.spec.ts +++ b/web/tests/e2e/wizard-platform-regressions.spec.ts @@ -364,6 +364,39 @@ test.describe('wizard and QR platform regressions', () => { .toBe(true); }); + test('a normal Page Bot embed has no wizard notice and stays closed until opened', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.goto('/login'); + const source = fs + .readFileSync( + path.resolve(process.cwd(), '../src/langbot/templates/embed/widget.js'), + 'utf8', + ) + .replaceAll('__LANGBOT_LOCALE__', 'en_US') + .replaceAll('__LANGBOT_BOT_UUID__', 'bot-1') + .replaceAll('__LANGBOT_BASE_URL__', 'https://embed.test') + .replaceAll('__LANGBOT_TURNSTILE_SITE_KEY__', '') + .replaceAll('__LANGBOT_BUBBLE_ICON__', 'chat'); + await page.addScriptTag({ content: source }); + const root = page.locator('#langbot-widget-root'); + await expect(root).toBeAttached(); + expect( + await root.evaluate( + (element) => + element.shadowRoot?.querySelectorAll('.lb-test-notice').length, + ), + ).toBe(0); + expect( + await root.evaluate((element) => + element.shadowRoot + ?.querySelector('.lb-panel') + ?.classList.contains('lb-visible'), + ), + ).toBe(false); + }); + test('binds HTTP Bot before its signed inbound verification request', async ({ page, }) => { @@ -446,6 +479,16 @@ test.describe('wizard and QR platform regressions', () => { async (route) => { inboundTestCount += 1; expect(route.request().method()).toBe('POST'); + expect(route.request().postDataJSON()).toEqual({ + message: 'Explicit HTTP verification', + }); + expect(route.request().headers().authorization).toBe( + 'Bearer playwright-token', + ); + expect(route.request().headers()['x-workspace-id']).toBe( + 'workspace-playwright', + ); + expect(boundPipelineUuid).toBe('pipeline-1'); await route.fulfill({ status: 200, contentType: 'application/json', @@ -467,152 +510,186 @@ test.describe('wizard and QR platform regressions', () => { await expect.poll(() => boundPipelineUuid).toBe('pipeline-1'); await expect.poll(() => savedInboundSecret).toMatch(/^[a-f0-9]{64}$/); + await page + .getByRole('button', { name: 'Send Test Message', exact: true }) + .locator('..') + .locator('input') + .fill(' Explicit HTTP verification '); await page.getByRole('button', { name: 'Send Test' }).click(); await expect.poll(() => inboundTestCount).toBe(1); }); - test('creates only a message pipeline after message verification and valid Runner configuration', async ({ - page, - }) => { - await installLangBotApiMocks(page, { - authenticated: true, - withAdapterEvents: true, - }); - const processorRequests: string[] = []; - page.on('request', (request) => { - const path = new URL(request.url()).pathname; - if ( - request.method() === 'POST' && - ['/api/v1/pipelines', '/api/v1/agents'].includes(path) - ) { - processorRequests.push(path); - } - }); - let messageReceived = false; - await page.route('**/api/v1/platform/bots/*/logs', async (route) => { - await route.fulfill({ - contentType: 'application/json', - body: ok({ - logs: messageReceived - ? [ - { - seq_id: 1, - timestamp: Date.now() / 1000, - level: 'info', - text: 'Received message', - images: [], - message_session_id: 'person_123', - }, - ] - : [], - total_count: messageReceived ? 1 : 0, - }), + for (const accountType of ['local', 'space'] as const) { + test(`creates only a message pipeline after message verification and valid Runner configuration for ${accountType}`, async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, }); - }); - await page.route('**/api/v1/pipelines/_/metadata', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: ok({ - configs: [ - { - name: 'ai', - label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, - stages: [ - { - name: 'plugin:langbot-team/LocalAgent/default', - config: [ - { - name: 'model', - type: 'model-fallback-selector', - required: true, - default: { primary: 'llm-valid', fallbacks: [] }, - }, - ], - }, - { - name: 'runner', - label: { en_US: 'Runtime', zh_Hans: '运行方式' }, - config: [ - { - name: 'id', - label: { en_US: 'Runner', zh_Hans: '运行器' }, - type: 'select', - required: true, - default: 'external-runner', - options: [ - { - name: 'external-runner', - label: { - en_US: 'External Runner', - zh_Hans: '外部运行器', - }, - }, - ], - }, - ], - }, - { - name: 'external-runner', - label: { - en_US: 'External Runner', - zh_Hans: '外部运行器', - }, - config: [ - { - id: 'api-key', - name: 'api-key', - label: { en_US: 'API Key', zh_Hans: 'API 密钥' }, - type: 'secret', - required: true, - default: 'your-api-key', - }, - ], - }, - ], + await page.route('**/api/v1/user/info', (route) => + route.fulfill({ + json: { + code: 0, + data: { + account_uuid: + accountType === 'space' ? 'account-playwright' : null, + user: 'admin@example.com', + account_type: accountType, + has_password: true, }, - ], + }, }), + ); + const processorRequests: string[] = []; + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if ( + request.method() === 'POST' && + ['/api/v1/pipelines', '/api/v1/agents'].includes(path) + ) { + processorRequests.push(path); + } }); + let messageReceived = false; + await page.route('**/api/v1/platform/bots/*/logs', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: ok({ + logs: messageReceived + ? [ + { + seq_id: 1, + timestamp: Date.now() / 1000, + level: 'info', + text: 'Received message', + images: [], + message_session_id: 'person_123', + }, + ] + : [], + total_count: messageReceived ? 1 : 0, + }), + }); + }); + await page.route('**/api/v1/pipelines/_/metadata', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: ok({ + configs: [ + { + name: 'ai', + label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' }, + stages: [ + { + name: 'plugin:langbot-team/LocalAgent/default', + config: [ + { + name: 'model', + type: 'model-fallback-selector', + required: true, + default: { primary: 'llm-valid', fallbacks: [] }, + }, + ], + }, + { + name: 'runner', + label: { en_US: 'Runtime', zh_Hans: '运行方式' }, + config: [ + { + name: 'id', + label: { en_US: 'Runner', zh_Hans: '运行器' }, + type: 'select', + required: true, + default: 'external-runner', + options: [ + { + name: 'external-runner', + label: { + en_US: 'External Runner', + zh_Hans: '外部运行器', + }, + }, + ], + }, + ], + }, + { + name: 'external-runner', + label: { + en_US: 'External Runner', + zh_Hans: '外部运行器', + }, + config: [ + { + id: 'api-key', + name: 'api-key', + label: { en_US: 'API Key', zh_Hans: 'API 密钥' }, + type: 'secret', + required: true, + default: 'your-api-key', + }, + ], + }, + ], + }, + ], + }), + }); + }); + + await page.goto('/wizard'); + await page.getByText('Playwright Adapter', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); + await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); + await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + messageReceived = true; + await expect( + page.getByRole('button', { name: 'Next', exact: true }), + ).toBeEnabled(); + const localWarning = page.getByText( + 'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.', + { exact: true }, + ); + if (accountType === 'local') await expect(localWarning).toBeVisible(); + else await expect(localWarning).toHaveCount(0); + await page.getByRole('button', { name: 'Next' }).click(); + await expect( + page.getByText('Use the default setup', { exact: true }), + ).toBeVisible(); + await page + .getByText('Connect an External Agent', { exact: true }) + .click(); + await page + .locator('[data-slot="card"]') + .filter({ has: page.getByText('External Runner', { exact: true }) }) + .getByRole('button', { name: 'Use This Runner' }) + .click(); + + const deployButton = page.getByRole('button', { + name: 'Create & Deploy', + }); + await expect(deployButton).toBeDisabled(); + await page.getByRole('textbox').fill('app-real-api-key'); + await expect(deployButton).toBeEnabled(); + const bindingRequest = page.waitForRequest( + (request) => + request.method() === 'PUT' && + new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', + ); + await deployButton.click(); + const body = (await bindingRequest).postDataJSON(); + expect(body.config.ai.runner.id).toBe('external-runner'); + expect(body.config.ai.runner_config['external-runner']['api-key']).toBe( + 'app-real-api-key', + ); + await expect( + page.getByRole('button', { name: 'Back to Workbench' }), + ).toBeVisible(); + expect(processorRequests).toEqual(['/api/v1/pipelines']); }); - - await page.goto('/wizard'); - await page.getByText('Playwright Adapter', { exact: true }).click(); - await page.getByRole('button', { name: 'Confirm, Create Bot' }).click(); - await page.getByRole('button', { name: 'Save & Enable Bot' }).click(); - await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); - messageReceived = true; - await page.getByRole('button', { name: 'Next' }).click(); - await expect( - page.getByText('Use the default setup', { exact: true }), - ).toBeVisible(); - await page.getByText('Connect an External Agent', { exact: true }).click(); - await page - .locator('[data-slot="card"]') - .filter({ has: page.getByText('External Runner', { exact: true }) }) - .getByRole('button', { name: 'Use This Runner' }) - .click(); - - const deployButton = page.getByRole('button', { name: 'Create & Deploy' }); - await expect(deployButton).toBeDisabled(); - await page.getByRole('textbox').fill('app-real-api-key'); - await expect(deployButton).toBeEnabled(); - const bindingRequest = page.waitForRequest( - (request) => - request.method() === 'PUT' && - new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1', - ); - await deployButton.click(); - const body = (await bindingRequest).postDataJSON(); - expect(body.config.ai.runner.id).toBe('external-runner'); - expect(body.config.ai.runner_config['external-runner']['api-key']).toBe( - 'app-real-api-key', - ); - await expect( - page.getByRole('button', { name: 'Back to Workbench' }), - ).toBeVisible(); - expect(processorRequests).toEqual(['/api/v1/pipelines']); - }); + } for (const qrPlatform of qrPlatforms) { test(`${qrPlatform.label} requests and displays its QR code`, async ({ diff --git a/web/tests/unit/knowledge-engine-marketplace.test.mjs b/web/tests/unit/knowledge-engine-marketplace.test.mjs index a803ba3c3..d75f34ceb 100644 --- a/web/tests/unit/knowledge-engine-marketplace.test.mjs +++ b/web/tests/unit/knowledge-engine-marketplace.test.mjs @@ -1,146 +1,264 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; -const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); -const webRoot = path.resolve(currentDirectory, '../..'); - -function readSource(relativePath) { - return fs.readFileSync(path.join(webRoot, relativePath), 'utf8'); +function setup({ + tasks = [{ runtime: { done: true } }], + engines = [{ engines: [] }], + cloud, +} = {}) { + const source = fs.readFileSync( + new URL( + '../../src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts', + import.meta.url, + ), + 'utf8', + ); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const storage = new Map(), + calls = []; + let workspace = 'alpha', + now = 0, + taskIndex = 0, + engineIndex = 0; + const http = { + getPlugins: async () => ({ + plugins: [ + { + manifest: { + manifest: { metadata: { author: 'qa', name: 'existing' } }, + }, + }, + ], + }), + installPluginFromMarketplace: async (...args) => { + calls.push(['install', ...args]); + return { task_id: 42 }; + }, + getAsyncTask: async (id) => { + calls.push(['task', id]); + return tasks[Math.min(taskIndex++, tasks.length - 1)]; + }, + getKnowledgeEngines: async () => { + calls.push(['engines']); + return engines[Math.min(engineIndex++, engines.length - 1)]; + }, + }; + const deps = { + '@/app/infra/http': { getCloudServiceClient: async () => cloud }, + '@/app/infra/http/HttpClient': { httpClient: http }, + '@/app/infra/http/workspaceContext': { + getActiveWorkspaceUuid: () => workspace, + }, + }; + const mod = { exports: {} }; + new Function( + 'require', + 'module', + 'exports', + 'window', + 'sessionStorage', + 'CustomEvent', + 'Date', + 'setTimeout', + code, + )( + (name) => { + assert.ok(name in deps, name); + return deps[name]; + }, + mod, + mod.exports, + new EventTarget(), + { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + CustomEvent, + { now: () => now }, + (callback, ms) => { + now += ms; + queueMicrotask(callback); + }, + ); + return { + api: mod.exports, + storage, + calls, + setWorkspace: (value) => { + workspace = value; + }, + }; } +const plugin = { + author: 'qa', + name: 'engine', + label: { en_US: 'QA Engine' }, + latest_version: '2.0.0', +}; +const engine = { plugin_id: 'qa/engine', name: 'Registered QA Engine' }; -const kbFormSource = readSource( - 'src/app/home/knowledge/components/kb-form/KBForm.tsx', -); -const selectSource = readSource( - 'src/app/home/knowledge/components/kb-form/KnowledgeEngineSelect.tsx', -); -const runnerSelectSource = readSource( - 'src/app/home/agents/components/RunnerSelect.tsx', -); -const agentFormSource = readSource( - 'src/app/home/agents/components/AgentFormComponent.tsx', -); -const pipelineFormSource = readSource( - 'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx', -); -const runnerMarketplaceSource = readSource( - 'src/app/home/agents/runner-marketplace.ts', -); -const marketplaceInstallButtonSource = readSource( - 'src/app/home/components/MarketplaceInstallButton.tsx', -); -const marketplaceSource = readSource( - 'src/app/home/knowledge/components/kb-form/knowledge-engine-marketplace.ts', -); -const taskContextSource = readSource( - 'src/app/home/plugins/components/plugin-install-task/PluginInstallTaskContext.tsx', -); -const progressDialogSource = readSource( - 'src/app/home/plugins/components/plugin-install-task/PluginInstallProgressDialog.tsx', -); -const installedPluginsSource = readSource( - 'src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx', -); -const homeSidebarSource = readSource( - 'src/app/home/components/home-sidebar/HomeSidebar.tsx', -); - -test('keeps the knowledge-base fields visible without an installed engine', () => { - assert.match(kbFormSource, / { - assert.match( - marketplaceSource, - /KNOWLEDGE_ENGINE_COMPONENT_FILTER = 'KnowledgeEngine'/, +test('catalog loads every page with the engine filter and excludes unrelated components', async () => { + const searches = []; + const { api } = setup({ + cloud: { + searchMarketplaceExtensions: async (args) => { + searches.push(args); + return { + total: 201, + plugins: + args.page === 1 + ? [ + { + ...plugin, + components: { KnowledgeEngine: 1 }, + install_count: 5, + }, + ] + : args.page === 2 + ? [ + { + name: 'runner', + components: { Runner: 1 }, + install_count: 999, + }, + ] + : [ + { + ...plugin, + name: 'popular', + components: { KnowledgeEngine: 1 }, + install_count: 20, + }, + ], + }; + }, + }, + }); + const result = await api.loadKnowledgeEngineCatalog(); + assert.deepEqual( + searches.map((value) => value.page), + [1, 2, 3], ); - assert.match(marketplaceSource, /installPluginFromMarketplace\(/); - assert.match(marketplaceSource, /getKnowledgeEngines\(\)/); - assert.match(marketplaceSource, /sessionStorage\.setItem\(/); - assert.match(selectSource, /resumePendingKnowledgeEngineInstall\(/); - assert.match(selectSource, /onInstalled\(installed\)/); - assert.match(selectSource, /component=KnowledgeEngine/); -}); - -test('tracks plugin upgrades as recoverable multistep async tasks', () => { - assert.match(taskContextSource, /name\.startsWith\('plugin-upgrade-'\)/); - assert.match(taskContextSource, /operation: PluginTaskOperation/); - assert.match(taskContextSource, /computeStageProgress/); - assert.match(taskContextSource, /INSTALL_PROGRESS_CAP/); - assert.match(progressDialogSource, /InstallStage\.CHECKING/); - assert.match(progressDialogSource, /InstallStage\.VALIDATING/); - assert.match(progressDialogSource, /InstallStage\.LAUNCHING/); - assert.match(progressDialogSource, /plugins\.installProgress\.updateTitle/); - for (const source of [installedPluginsSource, homeSidebarSource]) { - assert.match( - source, - /upgradePlugin\([\s\S]*?add(?:Plugin)?Task\([\s\S]*?operation: 'upgrade'/, - ); - assert.match( - source, - /pluginTaskKey\(res\.task_id, 'marketplace', 'upgrade'\)/, - ); - } -}); - -test('keeps marketplace install actions on one fixed vertical column', () => { - for (const source of [selectSource, runnerSelectSource]) { - assert.match(source, /grid-cols-\[1\.75rem_minmax\(0,1fr\)_4rem\]/); - assert.match(source, / { - assert.match(selectSource, /function SelectedEngineContent/); - assert.match( - selectSource, - /selectedEngine \? \([\s\S]*? value.name), + ['popular', 'engine'], ); - assert.match( - selectSource, - /flex min-w-0 flex-1 items-center gap-2 text-left/, - ); - assert.match(kbFormSource, /form\.clearErrors\('ragEngineId'\)/); - assert.match(kbFormSource, /form\.trigger\('ragEngineId'\)/); - assert.doesNotMatch(kbFormSource, /field\.onChange\(value\)/); + assert.deepEqual(result.installedPluginIds, ['qa/existing']); }); -test('installs marketplace components from inline progress buttons without selecting them', () => { - for (const source of [selectSource, runnerSelectSource]) { - assert.match(source, /const handleInstall = useCallback/); - assert.match(source, /installing=\{activePluginId === pluginId\}/); - assert.match(source, /progress=\{installProgress\}/); - assert.doesNotMatch(source, /MARKETPLACE_VALUE_PREFIX/); - } - assert.match(marketplaceInstallButtonSource, / { + const { api, storage, calls } = setup({ + tasks: [{ runtime: { done: false } }, { runtime: { done: true } }], + engines: [{ engines: [{ plugin_id: 'qa/other' }] }, { engines: [engine] }], + }); + let created; + const installed = await api.installMarketplaceKnowledgeEngine(plugin, { + scope: 'kb:new', + onTaskCreated: (id) => { + created = id; + assert.equal(api.readPendingKnowledgeEngineInstall('kb:new').taskId, 42); + }, + }); + assert.equal(installed, engine); + assert.equal(created, 42); + assert.deepEqual(calls, [ + ['install', 'qa', 'engine', '2.0.0'], + ['task', 42], + ['task', 42], + ['engines'], + ['engines'], + ]); + assert.equal(storage.size, 0); }); -test('shows plugin descriptions for installed Runner entries', () => { - assert.match(runnerMarketplaceSource, /installedPluginDescriptions/); - assert.match(runnerMarketplaceSource, /metadata\.description/); - assert.match(runnerSelectSource, /installedRunnerDescription\(/); - assert.match(runnerSelectSource, /function InstalledRunnerOptionContent/); - assert.match(runnerSelectSource, /marketplacePlugin\?\.description/); - assert.match(runnerSelectSource, /grid-cols-\[1\.75rem_minmax\(0,1fr\)\]/); - assert.doesNotMatch(runnerSelectSource, /description=\{option\.name\}/); +test('a pending engine install resumes only in its original workspace and form scope', async () => { + const { api, storage, calls, setWorkspace } = setup({ + engines: [{ engines: [engine] }], + }); + storage.set( + 'langbot-knowledge-engine-install:alpha:kb:new', + JSON.stringify({ + taskId: 42, + pluginId: 'qa/engine', + pluginAuthor: 'qa', + pluginName: 'engine', + pluginLabel: 'QA Engine', + scope: 'kb:new', + startedAt: 0, + }), + ); + setWorkspace('beta'); + assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:new'), null); + setWorkspace('alpha'); + assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:other'), null); + assert.equal(await api.resumePendingKnowledgeEngineInstall('kb:new'), engine); + assert.deepEqual(calls, [['task', 42], ['engines']]); + assert.equal(storage.size, 0); +}); + +test('failed tasks clear their intent and never query component registration', async () => { + const { api, calls, storage } = setup({ + tasks: [{ runtime: { done: true, exception: 'Package invalid' } }], + }); + await assert.rejects( + api.installMarketplaceKnowledgeEngine(plugin, { scope: 'kb:new' }), + /Package invalid/, + ); + assert.deepEqual(calls, [ + ['install', 'qa', 'engine', '2.0.0'], + ['task', 42], + ]); + assert.equal(storage.size, 0); +}); + +test('an unfinished task times out with a recoverable intent, while missing registration clears it', async () => { + const pending = setup({ tasks: [{ runtime: { done: false } }] }); + await assert.rejects( + pending.api.installMarketplaceKnowledgeEngine(plugin, { scope: 'kb:new' }), + (error) => error.code === 'install-timeout', + ); + assert.equal( + pending.api.readPendingKnowledgeEngineInstall('kb:new').taskId, + 42, + ); + assert.equal( + pending.calls.some((value) => value[0] === 'engines'), + false, + ); + const unregistered = setup(); + await assert.rejects( + unregistered.api.installMarketplaceKnowledgeEngine(plugin, { + scope: 'kb:new', + }), + (error) => error.code === 'registration-timeout', + ); + assert.equal(unregistered.storage.size, 0); +}); + +test('a plugin without a version never starts an install task', async () => { + const { api, calls } = setup(); + await assert.rejects( + api.installMarketplaceKnowledgeEngine( + { ...plugin, latest_version: '' }, + { scope: 'kb:new' }, + ), + (error) => error.code === 'version-unavailable', + ); + assert.deepEqual(calls, []); }); diff --git a/web/tests/unit/marketplace-likes.test.mjs b/web/tests/unit/marketplace-likes.test.mjs index 09283e628..38e8c8972 100644 --- a/web/tests/unit/marketplace-likes.test.mjs +++ b/web/tests/unit/marketplace-likes.test.mjs @@ -1,86 +1,157 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; -const webRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../..', -); -const read = (relativePath) => - fs.readFileSync(path.join(webRoot, relativePath), 'utf8'); +function load(relative, deps, globals = {}) { + const source = fs.readFileSync( + new URL('../../src/' + relative, import.meta.url), + 'utf8', + ); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + const mod = { exports: {} }; + new Function('require', 'module', 'exports', ...Object.keys(globals), code)( + (name) => { + assert.ok(name in deps, name); + return deps[name]; + }, + mod, + mod.exports, + ...Object.values(globals), + ); + return mod.exports; +} -test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => { - const market = read( - 'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx', +test('likes reuse the browser fingerprint, update cached state and notify only until unsubscribed', async () => { + const calls = [], + events = []; + const client = { + getMarketplaceLikedExtensions: async (fingerprint) => { + calls.push(['status', fingerprint]); + return { + extensions: [{ type: 'plugin', extension_id: 'qa/already-liked' }], + }; + }, + setMarketplaceExtensionLike: async (...args) => { + calls.push(['toggle', ...args]); + return { liked: args[4], like_count: args[4] ? 8 : 7 }; + }, + }; + const api = load( + 'app/home/plugins/components/plugin-market/marketplace-likes.ts', + { + '@fingerprintjs/fingerprintjs': { + default: { + load: async (options) => { + calls.push(['fingerprint', options]); + return { get: async () => ({ visitorId: 'browser-id' }) }; + }, + }, + }, + '@/app/infra/http': { getCloudServiceClient: async () => client }, + }, + { window: new EventTarget(), CustomEvent }, ); - const card = read( - 'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx', + assert.equal( + await api.getMarketplaceExtensionLiked(undefined, 'qa', 'already-liked'), + true, ); - const cardVO = read( - 'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts', + assert.equal( + await api.getMarketplaceExtensionLiked('skill', 'qa', 'already-liked'), + false, ); - const entity = read('src/app/infra/entities/plugin/index.ts'); - const client = read('src/app/infra/http/CloudServiceClient.ts'); - const likes = read( - 'src/app/home/plugins/components/plugin-market/marketplace-likes.ts', + const unsubscribe = api.subscribeMarketplaceLikeChanges((change) => + events.push(change), ); - - assert.match( - market, - /hot_score_desc/, - 'market must expose and default to hot-score sorting', + assert.deepEqual( + await api.toggleMarketplaceExtensionLike(undefined, 'qa', 'new', true), + { key: 'plugin:qa/new', liked: true, likeCount: 8 }, ); - assert.match( - market, - /sortBy:\s*['"]hot_score['"]/, - 'hot option must use the shared API field', + assert.equal( + await api.getMarketplaceExtensionLiked('plugin', 'qa', 'new'), + true, ); - assert.match( - entity, - /like_count\??:\s*number/, - 'marketplace extension responses must carry like counts', - ); - assert.match( - cardVO, - /likeCount:\s*number/, - 'market cards must carry like counts', - ); - assert.match(card, /\bHeart\b/, 'market cards must render a like affordance'); - assert.match( - card, - /toggleMarketplaceExtensionLike/, - 'market cards must support liking and unliking', - ); - assert.match( - client, - /marketplace\/extensions\/likes/, - 'client must load browser likes', - ); - assert.match( - client, - /setMarketplaceExtensionLike\(/, - 'client must update likes through the shared API', - ); - assert.match( - client, - /data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/, - 'older Space servers must fall back from hot score to install sorting', - ); - assert.match( - likes, - /@fingerprintjs\/fingerprintjs/, - 'anonymous likes must use browser fingerprinting', - ); - assert.match( - likes, - /FingerprintJS\.load\(\{/, - 'fingerprint agent must be initialized with options', - ); - assert.match( - likes, - /monitoring:\s*false/, - 'fingerprinting must not send vendor monitoring requests', + await api.toggleMarketplaceExtensionLike('plugin', 'qa', 'new', false); + assert.equal( + await api.getMarketplaceExtensionLiked('plugin', 'qa', 'new'), + false, ); + unsubscribe(); + await api.toggleMarketplaceExtensionLike('skill', 'qa', 'new', true); + assert.deepEqual(events, [ + { key: 'plugin:qa/new', liked: true, likeCount: 8 }, + { key: 'plugin:qa/new', liked: false, likeCount: 7 }, + ]); + assert.deepEqual(calls, [ + ['fingerprint', { monitoring: false }], + ['status', 'browser-id'], + ['toggle', 'plugin', 'qa', 'new', 'browser-id', true], + ['toggle', 'plugin', 'qa', 'new', 'browser-id', false], + ['toggle', 'skill', 'qa', 'new', 'browser-id', true], + ]); +}); + +test('legacy marketplace search converts hot sorting while preserving filters', async () => { + const calls = []; + class BaseHttpClient { + async post(path, body) { + calls.push([path, body]); + if (path === '/api/v1/marketplace/extensions/search') + throw new Error('Legacy server'); + return { + plugins: [{ author: 'qa', name: 'legacy', type: 'plugin' }], + total: 1, + }; + } + } + const { CloudServiceClient } = load('app/infra/http/CloudServiceClient.ts', { + './BaseHttpClient': { BaseHttpClient }, + '@/app/infra/entities/plugin': {}, + }); + const result = await new CloudServiceClient().searchMarketplaceExtensions({ + query: 'needle', + page: 2, + page_size: 10, + sort_by: 'hot_score', + sort_order: 'DESC', + type_filter: 'plugin', + tags_filter: ['qa'], + }); + assert.deepEqual( + result.plugins.map((value) => value.name), + ['legacy'], + ); + assert.deepEqual(calls, [ + [ + '/api/v1/marketplace/extensions/search', + { + query: 'needle', + page: 2, + page_size: 10, + sort_by: 'hot_score', + sort_order: 'DESC', + type_filter: 'plugin', + tags_filter: ['qa'], + }, + ], + [ + '/api/v1/marketplace/plugins/search', + { + query: 'needle', + page: 2, + page_size: 10, + sort_by: 'install_count', + sort_order: 'DESC', + component_filter: undefined, + tags_filter: ['qa'], + type_filter: 'plugin', + runner_usage: undefined, + }, + ], + ]); }); diff --git a/web/tests/unit/wizard-page-bot.test.mjs b/web/tests/unit/wizard-page-bot.test.mjs index aea775a7f..08ac81a86 100644 --- a/web/tests/unit/wizard-page-bot.test.mjs +++ b/web/tests/unit/wizard-page-bot.test.mjs @@ -1,126 +1,120 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; -import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; -const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); -const wizardSource = fs.readFileSync( - path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'), - 'utf8', -); -const runnerMarketplaceSource = fs.readFileSync( - path.resolve( - currentDirectory, - '../../src/app/home/agents/runner-marketplace.ts', - ), - 'utf8', -); -const widgetSource = fs.readFileSync( - path.resolve( - currentDirectory, - '../../../src/langbot/templates/embed/widget.js', - ), - 'utf8', -); +function load(source, deps = {}, globals = {}) { + const code = ts.transpileModule( + fs.readFileSync(new URL('../../src/' + source, import.meta.url), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText; + const mod = { exports: {} }; + new Function('require', 'module', 'exports', ...Object.keys(globals), code)( + (id) => { + assert.ok(id in deps, id); + return deps[id]; + }, + mod, + mod.exports, + ...Object.values(globals), + ); + return mod.exports; +} -test('shows the test-only notice only when the wizard opts in', () => { - assert.match( - wizardSource, - /widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/, +test('wizard Runner recovery waits for the matching plugin registration without installing twice', async () => { + const scope = 'wizard-ai-runner'; + const pending = { + scope, + taskId: 12, + pluginId: 'qa/runner', + pluginAuthor: 'qa', + pluginName: 'runner', + pluginLabel: 'QA Runner', + startedAt: -1000000, + }; + const storage = new Map([ + [`langbot-runner-install:alpha:${scope}`, JSON.stringify(pending)], + ]); + const calls = [], + progress = []; + let workspace = 'beta', + tick = 0, + polls = 0; + const option = { + name: 'plugin:qa/runner/default', + label: { en_US: 'Registered Runner' }, + }; + const configTab = { + name: 'ai', + stages: [ + { + name: 'runner', + config: [ + { + name: 'id', + options: [{ name: 'plugin:qa/runner-neighbor/default' }], + }, + ], + }, + ], + }; + const httpClient = { + getAsyncTask: async (id) => { + calls.push(['task', id]); + return { runtime: { done: ++polls >= 2 } }; + }, + getGeneralPipelineMetadata: async () => { + calls.push(['metadata']); + if (calls.filter((value) => value[0] === 'metadata').length === 2) + configTab.stages[0].config[0].options.push(option); + return { configs: [configTab] }; + }, + installPluginFromMarketplace: async () => + assert.fail('recovery must not reinstall'), + }; + const api = load( + 'app/home/agents/runner-marketplace.ts', + { + '@/app/infra/http/HttpClient': { httpClient }, + '@/app/infra/http': {}, + '@/app/infra/http/workspaceContext': { + getActiveWorkspaceUuid: () => workspace, + }, + '@/app/infra/entities/plugin': {}, + }, + { + window: new EventTarget(), + CustomEvent, + sessionStorage: { + getItem: (key) => storage.get(key) ?? null, + removeItem: (key) => storage.delete(key), + }, + Date: { now: () => tick }, + setTimeout: (callback, ms) => { + tick += ms; + queueMicrotask(callback); + }, + }, ); - assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/); - assert.match( - wizardSource, - /testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/, + assert.equal(await api.resumePendingRunnerInstall(scope), null); + workspace = 'alpha'; + assert.equal(await api.resumePendingRunnerInstall('other'), null); + const recovered = await api.resumePendingRunnerInstall(scope, (task) => + progress.push(task.runtime.done), ); - assert.match(widgetSource, /getAttribute\("data-test-notice"\)/); - assert.match(widgetSource, /if \(scriptTestNotice\)/); - assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/); -}); - -test('opens the Page Bot preview after every successful wizard save', () => { - assert.match(wizardSource, /script\.dataset\.autoOpen = 'true'/); - assert.match( - wizardSource, - /setPageBotPreviewRequest\(\(request\) => request \+ 1\)/, - ); - - assert.match(widgetSource, /getAttribute\("data-auto-open"\) === "true"/); - assert.match(widgetSource, /root\.langbotOpen = function \(\)/); - assert.match(widgetSource, /if \(scriptAutoOpen\) root\.langbotOpen\(\)/); -}); - -test('binds every wizard bot to its provisional pipeline before verification', () => { - assert.match( - wizardSource, - /if \(!previewPipelineUuid\) \{[\s\S]*?httpClient\.createPipeline\(/, - ); - assert.match( - wizardSource, - /event_pattern: 'message\.received',[\s\S]*?target_type: 'pipeline',[\s\S]*?target_uuid: previewPipelineUuid/, - ); - assert.doesNotMatch( - wizardSource, - /selectedAdapter === 'web_page_bot' && !previewPipelineUuid/, - ); -}); - -test('keeps the 4.11 Runner marketplace installation flow', () => { - assert.match(wizardSource, /loadRunnerCatalog\(\)/); - assert.match( - wizardSource, - /installMarketplaceRunner\(plugin, \{[\s\S]*?scope: WIZARD_RUNNER_INSTALL_SCOPE/, - ); - assert.match(wizardSource, /resumePendingRunnerInstall\(/); - assert.match(runnerMarketplaceSource, /RUNNER_COMPONENT_FILTER = 'Runner'/); - assert.match(runnerMarketplaceSource, /installPluginFromMarketplace\(/); - assert.match( - runnerMarketplaceSource, - /const prefix = runnerPluginPrefix\(\{/, - ); - assert.match(runnerMarketplaceSource, /option\.name\.startsWith\(prefix\)/); - assert.match(runnerMarketplaceSource, /registrationDeadline/); - assert.match(runnerMarketplaceSource, /sessionStorage\.setItem\(/); -}); - -test('requires the selected Runner mandatory configuration before finishing', () => { - assert.match( - wizardSource, - /isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/, - ); - assert.match(wizardSource, /aiChoice === 'external'/); - assert.match( - wizardSource, - /!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/, - ); -}); - -test('requires an observed message before configuring the runner', () => { - assert.match( - wizardSource, - /createdBotUuid !== null && botSaved && messageReceived/, - ); - assert.match(wizardSource, /requiresMessageVerification/); - assert.match(wizardSource, /onMessageReceived=\{handleMessageReceived\}/); -}); - -test('warns local-account users after the bot receives an IM message', () => { - assert.match( - wizardSource, - /messageReceived && userInfo\?\.account_type !== 'space'/, - ); - assert.match( - wizardSource, - /wizard\.botConfig\.messageReceivedLocalAccountWarning/, - ); - assert.match(wizardSource, / { - assert.match( - wizardSource, - /testHttpBotInbound\(createdBotUuid, testMessage\.trim\(\)\)/, - ); - assert.match(wizardSource, /wizard\.botConfig\.sendHttpTest/); + assert.equal(recovered.runner, option); + assert.equal(recovered.configTab, configTab); + assert.deepEqual(progress, [false, true]); + assert.deepEqual(calls, [ + ['task', 12], + ['task', 12], + ['metadata'], + ['metadata'], + ]); + assert.equal(storage.size, 0); });