Files
LangBot/web/tests/e2e/pipeline-migration.spec.ts
T

1011 lines
32 KiB
TypeScript

import { expect, test, type Page, type Route } from '@playwright/test';
import { readFileSync } from 'node:fs';
const canonicalConfig = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
);
import type {
PipelineMigrationItem,
PipelineMigrationResult,
PipelineMigrationState,
} from '../../src/app/infra/entities/api/pipeline-migration';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
const root = '/api/v1/pipelines/_/migration';
const runner = 'plugin:langbot-team/DifyAgent/default';
const row = (
pipeline_uuid: string,
state: PipelineMigrationState = 'ready',
): PipelineMigrationItem => ({
pipeline_uuid,
name: `Legacy ${pipeline_uuid}`,
state,
legacy_runner: 'dify-service-api',
target_runner_id: runner,
target_plugin: {
author: 'langbot-team',
name: 'DifyAgent',
version: '1.0.0',
},
changed_paths: ['ai.runner.id'],
blockers: [],
warnings: [],
preview_token: state === 'ready' ? `token-${pipeline_uuid}` : null,
});
const reply = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
async function setup(
page: Page,
options: { viewer?: boolean; current?: boolean; cloud?: boolean } = {},
) {
const workspace = makeWorkspaceEntry(
'workspace-playwright',
'Migration workspace',
options.cloud ? 'cloud_projection' : 'local',
);
if (options.viewer)
workspace.permissions = ['resource.view', 'workspace.view'];
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'migration-test',
session_type: 'person',
}),
);
});
});
const state = {
items: [row('one'), row('two')],
posts: [] as unknown[],
writes: [] as unknown[],
previews: 0,
polls: 0,
metadataReads: 0,
pipelineReads: 0,
executeStatus: 200,
pollLost: false,
done: true,
taskException: false,
results: [
{ pipeline_uuid: 'one', state: 'migrated', code: null },
] as PipelineMigrationResult[],
holdExecute: null as Promise<void> | null,
holdPreview: null as Promise<void> | null,
holdPoll: null as Promise<void> | null,
};
let config = options.current
? {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
runner_config: {},
},
}
: {
ai: {
runner: { runner: 'dify-service-api' },
'dify-service-api': { 'api-key': 'fixture-not-a-secret' },
},
};
await page.route(`**${root}/preview`, async (route) => {
state.previews++;
const items = structuredClone(state.items);
const scope =
route.request().headers()['x-workspace-id'] || 'workspace-playwright';
if (state.holdPreview) await state.holdPreview;
await reply(route, {
planner_version: '1',
workspace_uuid: scope,
items,
total: items.length,
});
});
await page.route(`**${root}/execute`, async (route) => {
state.posts.push(route.request().postDataJSON());
if (state.holdExecute) await state.holdExecute;
if (state.executeStatus !== 200)
return route.fulfill({
status: state.executeStatus,
json: { code: state.executeStatus, msg: 'unsafe-upstream-secret' },
});
await reply(route, { task_id: 411 });
});
await page.route('**/api/v1/system/tasks/411', async (route) => {
state.polls++;
if (state.holdPoll) await state.holdPoll;
if (state.pollLost)
return route.fulfill({ status: 404, json: { code: 404 } });
if (
state.done &&
state.results.some(
(item) => item.pipeline_uuid === 'one' && item.state === 'migrated',
)
) {
config = {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
runner_config: {},
},
};
}
await reply(route, {
id: 411,
runtime: {
done: state.done,
exception: state.taskException ? 'unsafe-upstream-secret' : null,
},
task_context: {
metadata: { kind: 'pipeline_migration', results: state.results },
},
});
});
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
state.pipelineReads++;
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
config,
is_default: false,
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
state.metadataReads++;
await route.fallback();
});
return state;
}
async function open(page: Page, detail = false) {
await page.goto(`/home/pipelines${detail ? '?id=one' : ''}`);
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
return page.getByRole('dialog', { name: 'Pipeline migration' });
}
async function selectAndConfirm(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Pipeline migration' });
await dialog
.getByRole('checkbox', { name: 'Legacy one', exact: true })
.check();
await dialog
.getByRole('checkbox', {
name: 'I confirm migration of the selected pipelines.',
})
.check();
return dialog.getByRole('button', { name: 'Migrate selected', exact: true });
}
test('opening, refreshing and cancelling never execute; selection starts empty', async ({
page,
}) => {
const state = await setup(page);
const dialog = await open(page);
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
await dialog.getByRole('button', { name: 'Refresh preview' }).click();
await expect.poll(() => state.previews).toBeGreaterThan(1);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
expect(state.posts).toEqual([]);
await page.screenshot({ path: 'test-results/pipeline-migration-review.png' });
});
test('explicit selection plus confirmation sends only IDs/tokens and suppresses double submit', async ({
page,
}) => {
const state = await setup(page);
let release!: () => void;
state.holdExecute = new Promise((resolve) => {
release = resolve;
});
const dialog = await open(page);
await dialog
.getByRole('checkbox', { name: 'Legacy one', exact: true })
.check();
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
await dialog
.getByRole('checkbox', {
name: 'I confirm migration of the selected pipelines.',
})
.check();
await dialog
.getByRole('button', { name: 'Migrate selected', exact: true })
.evaluate((button: HTMLButtonElement) => {
button.click();
button.click();
});
await expect.poll(() => state.posts.length).toBe(1);
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
expect(state.posts).toEqual([
{
confirmed: true,
items: [{ pipeline_uuid: 'one', preview_token: 'token-one' }],
},
]);
release();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Migrated',
);
});
test('view-only workspace can inspect but cannot select or execute', async ({
page,
}) => {
const state = await setup(page, { viewer: true, cloud: true });
const dialog = await open(page);
await expect(
dialog.getByText('Only workspace managers can migrate pipelines.'),
).toBeVisible();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).toBeDisabled();
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
expect(state.posts).toEqual([]);
});
test('missing plugins and blocked rows stay unselectable; existing Extensions flow and safe fields', async ({
page,
}) => {
const state = await setup(page);
state.items = [
row('one', 'needs_plugin'),
{
...row('two', 'blocked'),
blockers: [
{ code: 'unsupported_field', field: 'ai.local-agent.max-round' },
],
},
];
const dialog = await open(page);
for (const name of ['Legacy one', 'Legacy two'])
await expect(
dialog.getByRole('checkbox', { name, exact: true }),
).toBeDisabled();
await expect(dialog.getByText('ai.local-agent.max-round')).toBeVisible();
await expect(dialog.getByText(/quota/)).toBeVisible();
await expect(
dialog.getByRole('link', { name: 'Open Extensions' }),
).toHaveAttribute('href', '/home/extensions');
await expect(dialog).not.toContainText('fixture-not-a-secret');
state.items = [row('one')];
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).toBeEnabled();
expect(state.posts).toEqual([]);
});
test('stale execute requires a fresh preview and new explicit selection', async ({
page,
}) => {
const state = await setup(page);
state.executeStatus = 409;
const dialog = await open(page);
await (await selectAndConfirm(page)).click();
await expect(
dialog.getByText(
'Request not completed. Refresh the preview before selecting again.',
),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
await expect(dialog).not.toContainText('unsafe-upstream-secret');
await dialog.getByRole('button', { name: 'Refresh preview' }).click();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
expect(state.posts).toHaveLength(1);
});
test('partial task results are shown per row without a global success claim', async ({
page,
}) => {
const state = await setup(page, { cloud: true });
state.results = [
{ pipeline_uuid: 'one', state: 'migrated', code: null },
{
pipeline_uuid: 'two',
state: 'failed',
code: 'runtime_unavailable',
},
];
const dialog = await open(page);
const submit = await selectAndConfirm(page);
await dialog
.getByRole('checkbox', { name: 'Legacy two', exact: true })
.check();
await expect(submit).toBeDisabled();
await dialog
.getByRole('checkbox', {
name: 'I confirm migration of the selected pipelines.',
})
.check();
await submit.click();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Migrated',
);
await expect(dialog.getByTestId('migration-result-two')).toContainText(
'Failed',
);
await expect(
dialog.getByText('Task finished. Check each pipeline result below.'),
).toBeVisible();
await page.screenshot({
path: 'test-results/pipeline-migration-partial.png',
});
});
test('task failure stays distinct from polling loss and never displays raw exceptions', async ({
page,
}) => {
const state = await setup(page);
state.taskException = true;
state.results = [{ pipeline_uuid: 'one', state: 'failed', code: null }];
const dialog = await open(page);
await (await selectAndConfirm(page)).click();
await expect(
dialog.getByText('Task failed. Check each pipeline result below.'),
).toBeVisible();
await expect(dialog).not.toContainText('unsafe-upstream-secret');
});
test('lost polling is observation lost, refreshes read-only preview and never retries execute', async ({
page,
}) => {
const state = await setup(page);
state.pollLost = true;
const dialog = await open(page);
const before = state.previews;
await (await selectAndConfirm(page)).click();
await expect(
dialog.getByText(
'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
),
).toBeVisible();
await expect.poll(() => state.previews).toBeGreaterThan(before);
expect(state.posts).toHaveLength(1);
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
});
test('legacy detail never mounts editable runner defaults or debug autosave, metadata stays available', async ({
page,
}) => {
const state = await setup(page);
await page.goto('/home/pipelines?id=one');
await expect(
page.getByText(
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
),
).toBeVisible();
await expect(page.getByText('Preserved metadata')).toBeVisible();
await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toHaveCount(0);
expect(state.writes).toEqual([]);
expect(state.posts).toEqual([]);
});
test('completion reloads detail and runner metadata; current pipeline keeps normal editor', async ({
page,
}) => {
const state = await setup(page);
const dialog = await open(page, true);
const before = state.pipelineReads;
await (await selectAndConfirm(page)).click();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Migrated',
);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(
page.getByRole('tab', { name: 'AI', exact: true }),
).toBeVisible();
await expect.poll(() => state.pipelineReads).toBeGreaterThan(before);
await expect.poll(() => state.metadataReads).toBeGreaterThan(0);
expect(state.writes).toEqual([]);
});
test('workspace change resets selection and ignores an old polling response', async ({
page,
}) => {
const state = await setup(page);
let release!: () => void;
state.holdPoll = new Promise((resolve) => {
release = resolve;
});
await open(page);
await (await selectAndConfirm(page)).click();
await expect.poll(() => state.polls).toBe(1);
await page.evaluate(async () => {
const path = '/src/app/infra/http/currentWorkspaceStore.ts';
const store = await import(path);
const old = store.getCurrentWorkspaceSnapshot();
store.setCurrentWorkspaceSnapshot({ ...old, placement_generation: 2 });
});
await expect(
page.getByRole('dialog', { name: 'Pipeline migration' }),
).toHaveCount(0);
release();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
const dialog = page.getByRole('dialog', { name: 'Pipeline migration' });
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
await expect(dialog.getByTestId('migration-result-one')).toHaveCount(0);
expect(state.posts).toHaveLength(1);
});
test('at most fifty rows can be selected in the bounded dialog', async ({
page,
}) => {
test.setTimeout(60_000);
const state = await setup(page);
state.items = Array.from({ length: 51 }, (_, index) => row(String(index)));
const dialog = await open(page);
for (let index = 0; index < 50; index++)
await dialog
.getByRole('checkbox', { name: `Legacy ${index}`, exact: true })
.check();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy 50', exact: true }),
).toBeDisabled();
await expect(dialog.getByText('50 selected (maximum 50)')).toBeVisible();
const bounds = await dialog.boundingBox();
expect(bounds!.y).toBeGreaterThanOrEqual(0);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(
page.viewportSize()!.height,
);
expect(state.posts).toEqual([]);
});
test('all non-ready preview states are unselectable and activation is not reported as migrated', async ({
page,
}) => {
const state = await setup(page);
state.items = [
row('one'),
row('two', 'activation_pending'),
row('three', 'already_current'),
row('four', 'not_legacy'),
];
state.results = [
{ pipeline_uuid: 'one', state: 'activation_pending', code: null },
];
const dialog = await open(page);
for (const name of ['Legacy two', 'Legacy three', 'Legacy four'])
await expect(
dialog.getByRole('checkbox', { name, exact: true }),
).toBeDisabled();
await (await selectAndConfirm(page)).click();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Activation pending',
);
await expect(dialog.getByTestId('migration-result-one')).not.toContainText(
'Migrated',
);
});
test('stale per-item result requires refreshed selection, never silently retries', async ({
page,
}) => {
const state = await setup(page);
state.results = [
{ pipeline_uuid: 'one', state: 'stale', code: 'stale_preview' },
];
const dialog = await open(page);
await (await selectAndConfirm(page)).click();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Stale preview',
);
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
await dialog.getByRole('button', { name: 'Refresh preview' }).click();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
expect(state.posts).toHaveLength(1);
});
test('late preview from a previous workspace cannot populate the new workspace', async ({
page,
}) => {
const state = await setup(page);
const dialog = await open(page);
let release!: () => void;
state.holdPreview = new Promise((resolve) => {
release = resolve;
});
const before = state.previews;
await dialog.getByRole('button', { name: 'Refresh preview' }).click();
await expect.poll(() => state.previews).toBeGreaterThan(before);
state.items = [row('other')];
state.holdPreview = null;
await page.evaluate(async () => {
const storePath = '/src/app/infra/http/currentWorkspaceStore.ts';
const contextPath = '/src/app/infra/http/workspaceContext.ts';
const store = await import(storePath);
const context = await import(contextPath);
const old = store.getCurrentWorkspaceSnapshot();
context.setActiveWorkspaceUuid('workspace-other');
store.setCurrentWorkspaceSnapshot({
...old,
workspace: { ...old.workspace, uuid: 'workspace-other' },
});
});
release();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy other', exact: true }),
).not.toBeChecked();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).toHaveCount(0);
expect(state.posts).toEqual([]);
});
test('late execute admission after a workspace switch never polls in the new scope', async ({
page,
}) => {
const state = await setup(page);
let release!: () => void;
state.holdExecute = new Promise((resolve) => {
release = resolve;
});
await open(page);
await (await selectAndConfirm(page)).click();
await expect.poll(() => state.posts.length).toBe(1);
await page.evaluate(async () => {
const path = '/src/app/infra/http/currentWorkspaceStore.ts';
const store = await import(path);
const old = store.getCurrentWorkspaceSnapshot();
store.setCurrentWorkspaceSnapshot({ ...old, placement_generation: 2 });
});
release();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(
page.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
expect(state.polls).toBe(0);
});
test('network loss during execute is an unknown outcome with read-only refresh', async ({
page,
}) => {
const state = await setup(page);
await page.route(`**${root}/execute`, (route) => {
state.posts.push(route.request().postDataJSON());
return route.abort('connectionreset');
});
const dialog = await open(page);
const before = state.previews;
await (await selectAndConfirm(page)).click();
await expect(
dialog.getByText(
'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
),
).toBeVisible();
await expect.poll(() => state.previews).toBeGreaterThan(before);
expect(state.posts).toHaveLength(1);
});
test('legacy data returned on the editor second read cannot hydrate defaults or autosave', async ({
page,
}) => {
const state = await setup(page, { current: true });
let reads = 0;
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
reads++;
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: '',
emoji: '⚙️',
config:
reads <= 2
? {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
},
}
: { ai: { runner: { runner: 'local-agent' } } },
},
});
});
await page.goto('/home/pipelines?id=one');
await expect(
page.getByText(
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
),
).toBeVisible();
await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount(
0,
);
expect(state.writes).toEqual([]);
});
async function actualAgentRoute(page: Page) {
const state = await setup(page);
const agent = {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
kind: 'pipeline',
config: canonicalConfig,
};
let listReads = 0;
await page.route('**/api/v1/agents', async (route) => {
listReads++;
await reply(route, { agents: [agent] });
});
await page.route('**/api/v1/agents/one', (route) => reply(route, { agent }));
return { state, listReads: () => listReads };
}
async function clickSidebarPipeline(page: Page) {
const sidebar = page.locator('[data-slot="sidebar"]');
const entry = sidebar.getByText('Legacy one', { exact: true });
if (!(await entry.isVisible())) {
await sidebar.getByText('Processors', { exact: true }).click();
}
await entry.click();
await expect(page).toHaveURL(/\/home\/agents\?id=one$/);
}
test('SPEC sidebar navigation discovers migration and completion refreshes the actual detail', async ({
page,
}) => {
const { state, listReads } = await actualAgentRoute(page);
await page.goto('/home');
await clickSidebarPipeline(page);
await expect(
page.getByText(
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
),
).toBeVisible();
const review = page.getByRole('button', {
name: 'Review migration',
exact: true,
});
await expect(review).toHaveCount(1);
await review.click();
const dialog = page.getByRole('dialog', { name: 'Pipeline migration' });
const before = listReads();
await (await selectAndConfirm(page)).click();
await expect(
dialog.getByText('Task finished. Check each pipeline result below.'),
).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(
page.getByRole('tab', { name: 'AI', exact: true }),
).toBeVisible();
await expect.poll(listReads).toBeGreaterThan(before);
expect(state.polls).toBe(1);
expect(state.posts).toHaveLength(1);
});
test('SPEC agents list exposes one assistant that survives list to detail navigation', async ({
page,
}) => {
const { state } = await actualAgentRoute(page);
await page.goto('/home/agents');
const review = page.getByRole('button', {
name: 'Review migration',
exact: true,
});
await expect(review).toHaveCount(1);
await review.click();
await (await selectAndConfirm(page)).click();
const dialog = page.getByRole('dialog', { name: 'Pipeline migration' });
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Migrated',
);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await clickSidebarPipeline(page);
await review.click();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Migrated',
);
expect(state.polls).toBe(1);
expect(state.posts).toHaveLength(1);
});
test('SPEC canonical empty current detail allows selecting and saving a runner', async ({
page,
}) => {
const { state } = await actualAgentRoute(page);
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
config: canonicalConfig,
is_default: false,
},
});
});
await page.goto('/home/agents?id=one');
await expect(
page.getByRole('tab', { name: 'AI', exact: true }),
).toBeVisible();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
const selector = page.getByRole('combobox', { name: 'Runner', exact: true });
await selector.click();
await page.getByRole('option', { name: /Local Agent/ }).click();
const save = page.getByRole('button', { name: 'Save', exact: true });
await expect(save).toBeEnabled();
await save.click();
await expect
.poll(() => state.writes)
.toMatchObject([
{
config: {
ai: {
runner: { id: 'plugin:langbot-team/LocalAgent/default' },
},
},
},
]);
expect(state.posts).toEqual([]);
});
test('SPEC actual legacy on second read remains guarded on the sidebar route', async ({
page,
}) => {
const { state } = await actualAgentRoute(page);
let reads = 0;
await page.route('**/api/v1/pipelines/one', async (route) => {
if (route.request().method() !== 'GET')
state.writes.push(route.request().postDataJSON());
reads++;
await reply(route, {
pipeline: {
uuid: 'one',
name: 'Legacy one',
description: 'Preserved metadata',
emoji: '⚙️',
config:
reads === 1
? canonicalConfig
: { ai: { runner: { runner: 'local-agent' } } },
},
});
});
await page.goto('/home');
await clickSidebarPipeline(page);
await expect.poll(() => reads).toBeGreaterThan(1);
await expect(
page.getByText(
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
),
).toBeVisible();
await expect(page.getByRole('tab', { name: 'AI', exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toHaveCount(0);
expect(state.writes).toEqual([]);
});
for (const defect of [
'extra',
'wrong-result',
'missing',
'duplicate',
'wrong-task',
'wrong-kind',
'missing-failed',
'extra-running',
] as const) {
test(`SPEC task identity mismatch ${defect} loses observation without subset success or retry`, async ({
page,
}) => {
const state = await setup(page);
const migrated = { pipeline_uuid: 'one', state: 'migrated', code: null };
const outcomes =
defect === 'missing' || defect === 'missing-failed'
? []
: defect === 'wrong-result'
? [{ ...migrated, pipeline_uuid: 'other' }]
: defect === 'duplicate'
? [migrated, migrated]
: defect === 'extra' || defect === 'extra-running'
? [
migrated,
{ ...migrated, pipeline_uuid: 'other', state: 'failed' },
]
: [migrated];
await page.route('**/api/v1/system/tasks/411', async (route) => {
state.polls++;
await reply(route, {
id: defect === 'wrong-task' ? 999 : 411,
runtime: {
done: defect !== 'extra-running',
exception: defect === 'missing-failed' ? 'failure' : null,
},
task_context: {
metadata: {
kind:
defect === 'wrong-kind' ? 'plugin_install' : 'pipeline_migration',
results: outcomes,
},
},
});
});
const dialog = await open(page);
await (await selectAndConfirm(page)).click();
await expect(
dialog.getByText(
'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
),
).toBeVisible();
await expect(
dialog.getByText('Task finished. Check each pipeline result below.'),
).toHaveCount(0);
await expect(dialog.getByTestId('migration-result-one')).not.toContainText(
'Migrated',
);
await expect(
dialog.getByRole('button', { name: 'Migrate selected', exact: true }),
).toBeDisabled();
await page.waitForTimeout(1100);
expect(state.posts).toHaveLength(1);
expect(state.polls).toBe(1);
});
}
test('SPEC actual agents workspace change discards old polling and selection', async ({
page,
}) => {
const { state } = await actualAgentRoute(page);
let release!: () => void;
state.holdPoll = new Promise((resolve) => {
release = resolve;
});
await page.goto('/home/agents');
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await (await selectAndConfirm(page)).click();
await expect.poll(() => state.polls).toBe(1);
await page.evaluate(async () => {
const path = '/src/app/infra/http/currentWorkspaceStore.ts';
const store = await import(path);
const old = store.getCurrentWorkspaceSnapshot();
store.setCurrentWorkspaceSnapshot({ ...old, placement_generation: 2 });
});
const dialog = page.getByRole('dialog', { name: 'Pipeline migration' });
await expect(dialog).toHaveCount(0);
release();
await page
.getByRole('button', { name: 'Review migration', exact: true })
.click();
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).not.toBeChecked();
await expect(dialog.getByTestId('migration-result-one')).toHaveCount(0);
await page.waitForTimeout(1100);
expect(state.polls).toBe(1);
expect(state.posts).toHaveLength(1);
});
test('completion specific migration warnings explain changed defaults without upstream text', async ({
page,
}) => {
const state = await setup(page);
state.items[0].warnings = [
{ code: 'local.context_defaults', field: 'ai.local-agent.max-round' },
{ code: 'dify.timeout_default' },
{ code: 'secret-upstream-text' },
];
const dialog = await open(page);
await expect(dialog).toContainText(
'new context budget and summarization defaults',
);
await expect(dialog).toContainText('30-second request timeout');
await expect(dialog).toContainText('Review this setting before migration.');
await expect(dialog).not.toContainText('secret-upstream-text');
expect(state.posts).toHaveLength(0);
});
test('completion activation retry requires a fresh token, selection and explicit confirmation', async ({
page,
}) => {
const state = await setup(page);
state.items = [
{ ...row('one', 'activation_pending'), preview_token: 'activation-one' },
];
const dialog = await open(page);
expect(state.posts).toHaveLength(0);
await expect(
dialog.getByRole('checkbox', { name: 'Legacy one', exact: true }),
).toBeEnabled();
const submit = await selectAndConfirm(page);
await submit.click();
await expect(dialog.getByTestId('migration-result-one')).toContainText(
'Migrated',
);
expect(state.posts).toEqual([
{
confirmed: true,
items: [{ pipeline_uuid: 'one', preview_token: 'activation-one' }],
},
]);
await page.waitForTimeout(1100);
expect(state.posts).toHaveLength(1);
expect(state.writes).toHaveLength(0);
});
for (const [language, title, review] of [
['zh-Hans', '流水线迁移', '检查迁移'],
['ja-JP', 'パイプライン移行', '移行を確認'],
]) {
test(`localized migration dialog (${language})`, async ({ page }) => {
await setup(page);
await page.addInitScript(
(locale) => localStorage.setItem('langbot_language', locale),
language,
);
await page.goto('/home/pipelines');
await page.getByRole('button', { name: review, exact: true }).click();
const dialog = page.getByRole('dialog', { name: title });
await expect(dialog).toBeVisible();
await expect(dialog).not.toContainText('pipelineMigration.');
await expect(dialog).not.toContainText('Migrate selected');
await page.screenshot({
path: `test-results/pipeline-migration-${language}.png`,
});
});
}