feat(web): unify guided tours and complete translations

This commit is contained in:
RockChinQ
2026-09-19 22:14:29 +08:00
parent e7ad051cf6
commit 503fec4d6e
25 changed files with 5012 additions and 605 deletions
+246 -1
View File
@@ -70,8 +70,15 @@ test('knowledge guide starts only after creation opens the detail page', async (
await expect(
guide.getByRole('heading', { name: 'Review the knowledge engine' }),
).toBeVisible();
await expect(guide.getByRole('button', { name: 'Previous' })).toHaveCount(0);
await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled();
const highlight = await page
.getByTestId('knowledge-detail-guide-highlight')
.elementHandle();
await guide.getByRole('button', { name: 'Next' }).click();
expect(await highlight!.evaluate((element) => element.isConnected)).toBe(
true,
);
await expect(
guide.getByRole('heading', { name: 'Configure engine parameters' }),
@@ -84,6 +91,24 @@ test('knowledge guide starts only after creation opens the detail page', async (
name: 'Save the knowledge base configuration',
}),
).toBeVisible();
await guide.getByRole('button', { name: 'Previous' }).click();
await expect(
guide.getByRole('heading', { name: 'Configure engine parameters' }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
localStorage.getItem('langbot_knowledge_detail_guide_v1'),
),
)
.toBe('engine-parameters');
await guide.getByRole('button', { name: 'Previous' }).click();
await expect(
guide.getByRole('heading', { name: 'Review the knowledge engine' }),
).toBeVisible();
await expect(guide.getByRole('button', { name: 'Previous' })).toHaveCount(0);
await guide.getByRole('button', { name: 'Next' }).click();
await guide.getByRole('button', { name: 'Next' }).click();
await page
.locator('[data-guide="knowledge-engine-parameters"]')
.getByRole('textbox')
@@ -316,7 +341,7 @@ test('bot adapter gallery keeps categories and fits desktop and mobile', async (
.toBe(true);
});
test('runner guide covers selection, parameters, and event tools', async ({
test('runner guide covers selection, parameters, event tools, and event debugging', async ({
page,
}) => {
await installLangBotApiMocks(page, {
@@ -372,6 +397,19 @@ test('runner guide covers selection, parameters, and event tools', async ({
await expect(
guide.getByRole('heading', { name: 'Set events and tools' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Test event handling' }),
).toBeVisible();
await expect(page.locator('[data-guide="agent-event-debug"]')).toBeVisible();
await expect(
guide.getByText(/platform actions are simulated/i),
).toBeVisible();
await guide.getByRole('button', { name: 'Previous' }).click();
await expect(
guide.getByRole('heading', { name: 'Set events and tools' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(guide.getByRole('button', { name: 'Finish' })).toBeEnabled();
await guide.getByRole('button', { name: 'Finish' }).click();
await expect(guide).toHaveCount(0);
@@ -417,3 +455,210 @@ test('processor creation page does not render the runner detail guide', async ({
await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible();
await expect(page.locator('input[name="name"]')).toBeVisible();
});
test('pipeline guide follows its configuration sections, debug, history and bot binding', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_pipeline_setup_guide_v1: 'trigger' },
});
await page.goto('/home/agents?id=pipeline-guide');
const guide = page.getByTestId('pipeline-setup-guide');
await expect(
guide.getByRole('heading', { name: 'Choose which messages to answer' }),
).toBeVisible();
const highlight = await page
.getByTestId('pipeline-setup-guide-highlight')
.elementHandle();
await guide.getByRole('button', { name: 'Next', exact: true }).click();
await expect(
page.locator('[data-guide="pipeline-section-ai"]'),
).toHaveAttribute('data-state', 'active');
await expect(
guide.getByRole('heading', { name: 'Configure AI capabilities' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(
page.locator('[data-guide="pipeline-section-trigger"]'),
).toHaveAttribute('data-state', 'active');
expect(await highlight!.evaluate((element) => element.isConnected)).toBe(
true,
);
for (const title of [
'Configure AI capabilities',
'Adjust replies',
'Set safety controls',
'Choose available extensions',
'Manage the pipeline',
'Test a conversation',
'Review run history',
'Save and connect a bot',
]) {
await guide.getByRole('button', { name: 'Next', exact: true }).click();
await expect(
guide.getByRole('heading', { name: title, exact: true }),
).toBeVisible();
if (title === 'Review run history') {
await page.locator('[data-guide="pipeline-form-monitoring"]').click();
await expect(guide).toHaveCount(0);
await page
.getByRole('tab', { name: 'Configure & debug', exact: true })
.click();
await expect(
guide.getByRole('heading', { name: title, exact: true }),
).toBeVisible();
}
}
await expect(guide).toContainText('Message received');
await guide.getByRole('button', { name: 'Finish', exact: true }).click();
await expect(guide).toHaveCount(0);
expect(
await page.evaluate(() =>
localStorage.getItem('langbot_pipeline_setup_guide_v1'),
),
).toBe('completed');
});
test('pipeline guide restores its section without appearing on creation', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_pipeline_setup_guide_v1: 'output' },
});
await page.goto('/home/pipelines?id=new');
await expect(page.getByTestId('pipeline-setup-guide')).toHaveCount(0);
await page.goto('/home/agents?id=pipeline-guide');
const guide = page.getByTestId('pipeline-setup-guide');
await expect(
guide.getByRole('heading', { name: 'Adjust replies' }),
).toBeVisible();
await expect(
page.locator('[data-guide="pipeline-section-output"]'),
).toHaveAttribute('data-state', 'active');
await guide.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(
page.locator('[data-guide="pipeline-section-ai"]'),
).toHaveAttribute('data-state', 'active');
});
for (const available of [true, false]) {
test(`plugin processor guide handles ${available ? 'parameters, debug, logs and binding' : 'an unavailable plugin without inaccessible steps'}`, async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_plugin_processor_setup_guide_v1: 'select' },
});
const ref = 'plugin:qa/welcome/default';
await page.route('**/api/v1/agents**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/_/metadata')) {
await route.fulfill({
json: {
code: 0,
data: {
kinds: [],
platform_tools: [],
event_processors: available
? [
{
id: ref,
label: { en_US: 'Welcome' },
supported_event_patterns: ['group.member_joined'],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting' },
default: 'Hello',
},
],
plugin_author: 'qa',
plugin_name: 'welcome',
},
]
: [],
},
},
});
} else if (path.endsWith('/runs')) {
await route.fulfill({
json: {
code: 0,
data: { items: [], has_more: false, next_cursor: null, total: 0 },
},
});
} else if (path.endsWith('/processor-guide')) {
await route.fulfill({
json: {
code: 0,
data: {
agent: {
uuid: 'processor-guide',
kind: 'event_processor',
name: 'Welcome processor',
component_ref: ref,
supported_event_patterns: ['group.member_joined'],
config: {
runner: { id: ref },
runner_config: { [ref]: { greeting: 'Hello' } },
},
},
},
},
});
} else await route.fallback();
});
await page.goto('/home/agents?id=processor-guide');
const guide = page.getByTestId('plugin-processor-setup-guide');
await expect(
guide.getByRole('heading', { name: 'Choose a plugin Runner' }),
).toBeVisible();
await expect(guide.getByRole('link')).toHaveAttribute(
'href',
/runner_usage=event/,
);
if (available) {
await guide.getByRole('button', { name: 'Next', exact: true }).click();
await expect(
guide.getByRole('heading', { name: 'Configure plugin parameters' }),
).toBeVisible();
await expect(
page
.locator('[data-guide="event-processor-form-config"]')
.getByRole('textbox'),
).toBeVisible();
await guide.getByRole('button', { name: 'Next', exact: true }).click();
await expect(
guide.getByRole('heading', { name: 'Test plugin event handling' }),
).toBeVisible();
await expect(guide).toContainText('Platform actions are simulated');
}
await guide.getByRole('button', { name: 'Next', exact: true }).click();
await expect(
guide.getByRole('heading', { name: 'Review execution results' }),
).toBeVisible();
await expect(
page.locator('[data-guide="event-processor-form-tab-logs"]'),
).toHaveAttribute('data-state', 'active');
await guide.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(
page.locator('[data-guide="event-processor-form-tab-config"]'),
).toHaveAttribute('data-state', 'active');
await guide.getByRole('button', { name: 'Next', exact: true }).click();
if (available) {
await guide.getByRole('button', { name: 'Next', exact: true }).click();
await expect(
guide.getByRole('heading', { name: 'Save and bind to a bot' }),
).toBeVisible();
await expect(guide).toContainText('no per-event routing needed');
await expect(
page.locator('[data-guide="event-processor-form-tab-config"]'),
).toHaveAttribute('data-state', 'active');
}
await guide.getByRole('button', { name: 'Finish', exact: true }).click();
await expect(guide).toHaveCount(0);
});
}
+2
View File
@@ -1318,6 +1318,8 @@ export async function installLangBotApiMocks(
'langbot_bot_detail_guide_v1',
'langbot_runner_setup_guide_v1',
'langbot_knowledge_detail_guide_v1',
'langbot_pipeline_setup_guide_v1',
'langbot_plugin_processor_setup_guide_v1',
];
for (const guideKey of contextualGuides) {
if (!Object.hasOwn(storage, guideKey)) {
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('sidebar introduction takes priority without losing the page guide step', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: {
langbot_sidebar_guide_v1: '0',
langbot_pipeline_setup_guide_v1: 'trigger',
},
});
await page.goto('/home/agents?id=pipeline-guide');
const sidebar = page.getByTestId('sidebar-guide');
const contextual = page.getByTestId('pipeline-setup-guide');
await expect(sidebar.getByRole('dialog')).toBeVisible();
await expect(contextual).toHaveCount(1);
await expect(contextual).toBeHidden();
await sidebar.getByRole('button', { name: 'Skip' }).click();
await expect(sidebar).toHaveCount(0);
await expect(contextual.getByRole('dialog')).toBeVisible();
await expect(contextual).toHaveAttribute('data-active-step', 'trigger');
await contextual.getByRole('button', { name: 'Next' }).click();
await expect(contextual).not.toHaveAttribute('data-active-step', 'trigger');
});
for (const theme of ['light', 'dark']) {
for (const sidebar of [false, true]) {
test(`${sidebar ? 'sidebar' : 'contextual'} guide keeps its highlight ring and backdrop in ${theme} mode`, async ({
page,
}, testInfo) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: {
'langbot-theme': theme,
...(sidebar
? { langbot_sidebar_guide_v1: '0' }
: { langbot_pipeline_setup_guide_v1: 'trigger' }),
},
});
await page.goto(
sidebar ? '/home/monitoring' : '/home/agents?id=pipeline-guide',
);
const id = sidebar ? 'sidebar-guide' : 'pipeline-setup-guide';
const guide = page.getByTestId(id);
await expect(guide.getByRole('dialog')).toBeVisible();
await expect(page.locator('html')).toHaveClass(theme);
const highlight = page.getByTestId(`${id}-highlight`);
const appearance = await highlight.evaluate((element) => {
const style = getComputedStyle(element);
const probe = document.createElement('span');
probe.style.color = style.getPropertyValue('--tw-ring-color');
element.append(probe);
const ring = getComputedStyle(probe).color;
probe.remove();
return { shadow: style.boxShadow, ring };
});
// A standalone backdrop shadow used to override Tailwind's ring shadows.
expect(appearance.ring).not.toBe('');
expect(appearance.shadow).toContain(appearance.ring);
expect(appearance.shadow).toContain('9999px');
if (theme === 'dark') {
expect(appearance.shadow).toContain('rgba(0, 0, 0, 0.72)');
}
await guide.getByRole('dialog').evaluate(async (element) => {
await Promise.all(
element.getAnimations().map((animation) => animation.finished),
);
});
await page.screenshot({ path: testInfo.outputPath('guide.png') });
});
}
}
for (const [language, label] of [
['en-US', 'User guide'],
['zh-Hans', '使用指引'],
['zh-Hant', '使用指引'],
['ja-JP', '使い方ガイド'],
['es-ES', 'Guía de uso'],
['ru-RU', 'Руководство'],
['th-TH', 'คำแนะนำการใช้งาน'],
['vi-VN', 'Hướng dẫn sử dụng'],
]) {
test(`shared guide controls fit a narrow viewport in ${language}`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 600 });
await installLangBotApiMocks(page, {
authenticated: true,
language,
storage: { langbot_sidebar_guide_v1: '1', 'langbot-theme': 'dark' },
});
await page.goto('/home/monitoring');
const guide = page.getByTestId('sidebar-guide');
const dialog = guide.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(guide.getByText(label, { exact: true })).toBeVisible();
await expect(
guide.locator('[data-guided-tour-action="previous"]'),
).toBeVisible();
await expect(
guide.locator('[data-guided-tour-action="next"]'),
).toBeFocused();
const box = await dialog.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
expect(box!.y + box!.height).toBeLessThanOrEqual(600);
expect(
await dialog.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
).toBe(true);
await guide.locator('[data-guided-tour-action="skip"]').click();
await expect(guide).toHaveCount(0);
});
}
+79 -12
View File
@@ -3,17 +3,17 @@ import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const guideSteps = [
'Dashboard',
'Bots',
'Processors',
'Knowledge bases',
'Installed extensions',
'Review activity',
'Connect chat platforms',
'Configure processors',
'Manage knowledge bases',
'Manage installed extensions',
'Add extensions',
'Model configuration',
'API integration',
'Configure models',
'Configure API access',
];
test('sidebar guide blocks navigation until every step is confirmed', async ({
test('sidebar guide blocks background navigation until completed', async ({
page,
}) => {
await installLangBotApiMocks(page, {
@@ -26,7 +26,38 @@ test('sidebar guide blocks navigation until every step is confirmed', async ({
const guide = page.getByTestId('sidebar-guide');
await expect(guide).toHaveCount(1);
await expect(guide.getByRole('dialog')).toBeVisible();
await expect(guide.getByText('1 of 8')).toBeVisible();
await expect(guide.getByText('Step 1 of 8')).toBeVisible();
await expect(guide.getByRole('button', { name: 'Previous' })).toHaveCount(0);
const highlight = await page
.getByTestId('sidebar-guide-highlight')
.elementHandle();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(guide.getByText('Step 2 of 8')).toBeVisible();
expect(await highlight!.evaluate((element) => element.isConnected)).toBe(
true,
);
await expect
.poll(async () => {
const rect = await page
.getByTestId('sidebar-guide-highlight')
.boundingBox();
const target = await page
.locator('[data-sidebar-guide="bots"]')
.boundingBox();
return Math.abs(rect!.y - target!.y);
})
.toBeLessThan(1);
await guide.getByRole('button', { name: 'Next' }).focus();
await page.keyboard.press('Shift+Tab');
await expect(guide.getByRole('button', { name: 'Previous' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(guide.getByText('Step 1 of 8')).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => localStorage.getItem('langbot_sidebar_guide_v1')),
)
.toBe('0');
const dashboardTarget = page.locator('[data-sidebar-guide="monitoring"]');
const targetBox = await dashboardTarget.boundingBox();
@@ -48,8 +79,21 @@ test('sidebar guide blocks navigation until every step is confirmed', async ({
guide.getByRole('heading', { name: title, exact: true }),
).toBeVisible();
const buttonName =
index === guideSteps.length - 1 ? 'Finish tour' : 'Got it';
if (index === guideSteps.length - 1) {
await guide.getByRole('button', { name: 'Previous' }).click();
await expect(
guide.getByRole('heading', {
name: 'Configure models',
exact: true,
}),
).toBeVisible();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: title, exact: true }),
).toBeVisible();
}
const buttonName = index === guideSteps.length - 1 ? 'Finish' : 'Next';
await guide.getByRole('button', { name: buttonName }).click();
}
@@ -78,7 +122,7 @@ test('sidebar guide popover stays inside a narrow viewport', async ({
await page.goto('/home/monitoring');
const dialog = page.getByRole('dialog', { name: 'Dashboard' });
const dialog = page.getByRole('dialog', { name: 'Review activity' });
await expect(dialog).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox).not.toBeNull();
@@ -87,3 +131,26 @@ test('sidebar guide popover stays inside a narrow viewport', async ({
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(390);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(760);
});
test('sidebar guide supports the shared skip and Escape controls', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_sidebar_guide_v1: '0' },
});
await page.goto('/home/monitoring');
const guide = page.getByTestId('sidebar-guide');
await expect(guide.getByRole('button', { name: 'Skip' })).toBeVisible();
await expect(guide.getByRole('button', { name: 'Next' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(guide).toHaveCount(0);
await expect
.poll(() => page.evaluate(() => document.body.style.overflow))
.not.toBe('hidden');
await expect
.poll(() =>
page.evaluate(() => localStorage.getItem('langbot_sidebar_guide_v1')),
)
.toBe('completed');
});