From 0db4d5f4909a5a1f3a70bf591c0f7b2962d97509 Mon Sep 17 00:00:00 2001 From: chamsechan Date: Mon, 26 Oct 2026 17:32:37 +0800 Subject: [PATCH] fix(studio): simplify editing and preserve pending actions --- README.md | 2 +- include/contracts/error_codes.h | 5 - tests/tooling/studio_browser_test.mjs | 488 +++++++++++++------------ tests/tooling/studio_editor_test.mjs | 3 + tests/tooling/test_pipeline_studio.py | 221 ++++------- tools/pipeline_studio/README.md | 150 ++++---- tools/pipeline_studio/acceptance.md | 70 ++-- tools/pipeline_studio/server.py | 293 ++++----------- tools/pipeline_studio/web/app.js | 391 ++++++++++---------- tools/pipeline_studio/web/editor.js | 116 +++++- tools/pipeline_studio/web/graph.js | 106 +++++- tools/pipeline_studio/web/index.html | 99 +++-- tools/pipeline_studio/web/styles.css | 53 +-- tools/pipeline_studio/web/workbench.js | 2 +- 14 files changed, 958 insertions(+), 1041 deletions(-) diff --git a/README.md b/README.md index b2df3c6a..2b4c0437 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Linux 默认构建只启用 Inferlet,也可运行无需模型的示例。其 ./tools/pipeline_studio/server.py configs/pipeline_keyword_match_rules.json --web ``` -工作台监听 `127.0.0.1`,支持连线、参数编辑、校验和运行。 +编辑器监听 `127.0.0.1`,直接编辑和保存 Pipeline JSON,支持连线、参数编辑、检查与试运行;无需管理 `.conf`。 接下来,按 [Studio 编排练习](tools/pipeline_studio/README.md#第一次编排)修改规则。用 Demo 验证修改后的方案。 ## 架构与扩展 diff --git a/include/contracts/error_codes.h b/include/contracts/error_codes.h index 443b1499..ab9bca7b 100644 --- a/include/contracts/error_codes.h +++ b/include/contracts/error_codes.h @@ -73,15 +73,10 @@ X(kValidationFailed, -3068, "VALIDATION_FAILED") \ X(kBodyTooLarge, -3069, "BODY_TOO_LARGE") \ X(kDemoNotBuilt, -3070, "DEMO_NOT_BUILT") \ - X(kDeploymentMismatch, -3071, "DEPLOYMENT_MISMATCH") \ - X(kDeploymentNotFound, -3073, "DEPLOYMENT_NOT_FOUND") \ X(kDeploymentValidationFailed, -3074, "DEPLOYMENT_VALIDATION_FAILED") \ X(kDocumentTooLarge, -3075, "DOCUMENT_TOO_LARGE") \ X(kFileExists, -3076, "FILE_EXISTS") \ X(kInvalidAuthoringRequest, -3077, "INVALID_AUTHORING_REQUEST") \ - X(kInvalidDeploymentConfig, -3078, "INVALID_DEPLOYMENT_CONFIG") \ - X(kInvalidDeploymentName, -3079, "INVALID_DEPLOYMENT_NAME") \ - X(kInvalidDeploymentPath, -3080, "INVALID_DEPLOYMENT_PATH") \ X(kInvalidInitRequest, -3081, "INVALID_INIT_REQUEST") \ X(kInvalidPipelineDocument, -3083, "INVALID_PIPELINE_DOCUMENT") \ X(kInvalidPipelineFile, -3084, "INVALID_PIPELINE_FILE") \ diff --git a/tests/tooling/studio_browser_test.mjs b/tests/tooling/studio_browser_test.mjs index 278bdb30..d55ee220 100644 --- a/tests/tooling/studio_browser_test.mjs +++ b/tests/tooling/studio_browser_test.mjs @@ -1,282 +1,308 @@ import assert from 'node:assert/strict'; -import {mkdir} from 'node:fs/promises'; +import {mkdir, readFile} from 'node:fs/promises'; import {createRequire} from 'node:module'; import {resolve} from 'node:path'; -const base = process.argv[2]; -const modulePath = process.env.STUDIO_PLAYWRIGHT_MODULE; -if (!modulePath) { - console.log('SKIP Studio browser: STUDIO_PLAYWRIGHT_MODULE is not configured'); - process.exit(0); -} -assert.ok(base, 'browser consumer requires the isolated Studio HTTP URL'); -const require = createRequire(import.meta.url); -const {chromium} = require(resolve(modulePath)); -const browser = await chromium.launch({ - headless: true, - executablePath: process.env.STUDIO_CHROMIUM_PATH || undefined, - args: process.env.STUDIO_BROWSER_NO_SANDBOX === '1' ? ['--no-sandbox'] : [], -}); -const page = await browser.newPage({viewport: {width: 1440, height: 960}}); +const base = process.argv[2], modulePath = process.env.STUDIO_PLAYWRIGHT_MODULE; +if (!modulePath) { console.log('SKIP Studio browser: STUDIO_PLAYWRIGHT_MODULE is not configured'); process.exit(0); } +const {chromium} = createRequire(import.meta.url)(resolve(modulePath)); +const browser = await chromium.launch({headless: true, executablePath: process.env.STUDIO_CHROMIUM_PATH || undefined, + args: process.env.STUDIO_BROWSER_NO_SANDBOX === '1' ? ['--no-sandbox'] : []}); +const page = await browser.newPage({viewport: {width: 1366, height: 768}}); page.setDefaultTimeout(15000); const errors = []; -const requests = []; -page.on('request', request => requests.push(new URL(request.url()).pathname)); page.on('pageerror', error => errors.push(error.message)); +let saveName = 'browser_keywords'; +const savedFile = () => `pipeline_${saveName}.json`; +page.on('dialog', dialog => dialog.accept(dialog.type() === 'prompt' ? saveName : undefined)); const jsonValue = () => page.locator('#rawJson').evaluate(element => JSON.parse(element.value)); -const tab = name => page.locator(`[data-tab="${name}"]`).click(); -async function applyDocument(document) { - await tab('json'); - await page.locator('#rawJson').fill(JSON.stringify(document, null, 2)); - await page.locator('#applyJson').click(); - await page.waitForFunction(() => document.querySelector('#rawJsonHint').hidden); +async function tab(name) { + if (['json', 'models'].includes(name)) await page.locator('.advanced-menu').evaluate(element => { element.open = true; }); + await page.locator(`[data-tab="${name}"]`).click(); } -async function savedDocument(name) { - const response = await fetch(`${base}/api/pipeline?filename=${name}`); - assert.equal(response.status, 200); - return (await response.json()).pipeline; +async function applyDocument(value) { + await tab('json'); await page.locator('#rawJson').fill(JSON.stringify(value, null, 2)); + await page.locator('#applyJson').click(); await page.waitForFunction(() => document.querySelector('#rawJsonHint').hidden); } -async function open(name) { - await page.locator('#pipelineSelect').selectOption(name); - await page.locator('#openButton').click(); - await page.waitForFunction(name => document.querySelector('#documentTitle').textContent.includes(name), name); - await page.locator('#nodes .node[data-node-id="match_keywords"]').waitFor(); +async function savedDocument(name) { + const response = await fetch(`${base}/api/pipeline?filename=${encodeURIComponent(name)}`); + assert.equal(response.status, 200); return (await response.json()).pipeline; } async function clickEdge(edge) { - // Horizontal SVG groups have zero-height bounds. Click the actual stroked path. const point = await edge.locator('.edge-hit').evaluate(path => { - const middle = path.getPointAtLength(path.getTotalLength() / 2); - const screen = new DOMPoint(middle.x, middle.y).matrixTransform(path.getScreenCTM()); - return {x: screen.x, y: screen.y}; + const p = path.getPointAtLength(path.getTotalLength() / 2), s = new DOMPoint(p.x, p.y).matrixTransform(path.getScreenCTM()); + return {x: s.x, y: s.y}; }); await page.mouse.click(point.x, point.y); - assert.match(await edge.getAttribute('class'), /\bselected\b/); +} +async function save(as = false) { + const response = page.waitForResponse(r => r.url().endsWith(as ? '/api/pipelines' : '/api/pipeline') && ['PUT', 'POST'].includes(r.request().method())); + await page.locator(as ? '#saveAsButton' : '#saveButton').click(); + assert.equal((await response).status(), 200); + await page.waitForFunction(() => document.querySelector('#dirtyBadge').textContent === '已保存'); } try { - await page.goto(`${base}/index.html`); + await page.goto(base); await page.locator('#pipelineSelect option[value="pipeline_keyword_match_rules.json"]').waitFor({state: 'attached'}); assert.equal(await page.locator('#welcomePanel').isVisible(), true); await page.locator('#welcomeCreate').click(); - assert.equal(await page.locator('.create-panel').evaluate(element => element.open), true); - assert.equal(await page.locator('#createMode').inputValue(), 'template'); - assert.equal(await page.locator('#cloneProfile').inputValue(), ''); - assert.equal(await page.locator('#createIo').isHidden(), true); - assert.equal(await page.locator('#newButton').isDisabled(), true); await page.locator('#createMode').selectOption('blank'); assert.equal(await page.locator('#createIo').isVisible(), true); await page.locator('#createMode').selectOption('template'); assert.equal(await page.locator('#createIo').isHidden(), true); - assert.equal(await page.locator('#newButton').isDisabled(), true); - - if (await page.locator('#editModeButton').getAttribute('aria-pressed') === 'true') await page.locator('#editModeButton').click(); - await open('pipeline_keyword_match_rules.json'); - const original = await jsonValue(); - assert.equal(await page.locator('#welcomePanel').isHidden(), true); - assert.equal(await page.locator('#editModeButton').getAttribute('aria-pressed'), 'false'); - assert.ok(await page.locator('#graph .port.disabled').count() > 0); - await clickEdge(page.locator('#edges .edge').first()); - assert.equal(await page.locator('#deleteEdgeButton').isDisabled(), true); - - // View operations are independent of document history and saved bytes. - const transform = await page.locator('#viewport').getAttribute('transform'); - await page.locator('#zoomInButton').click(); - assert.notEqual(await page.locator('#viewport').getAttribute('transform'), transform); - await page.locator('#actualSizeButton').click(); - assert.equal(await page.locator('#zoomLabel').textContent(), '100%'); - await page.locator('#layoutButton').click(); - assert.equal(await page.locator('#focusNodeButton').isDisabled(), true); - await page.locator('#nodes .node[data-node-id="match_keywords"]').click(); - assert.equal(await page.locator('#focusNodeButton').isEnabled(), true); - const savedBadge = await page.locator('#dirtyBadge').textContent(); - await page.locator('#focusNodeButton').click(); - assert.ok(Number.parseInt(await page.locator('#zoomLabel').textContent(), 10) >= 100); - assert.equal(await page.locator('#dirtyBadge').textContent(), savedBadge); - await page.locator('#fitButton').click(); - assert.deepEqual(await jsonValue(), original); - assert.deepEqual(await savedDocument('pipeline_keyword_match_rules.json'), original); - const canvas = await page.locator('#graph').boundingBox(); - assert.ok(canvas.width > 600 && canvas.height > 300); - - // Start from a real template, then edit its rule through the node form. - await page.locator('#newEntryButton').click(); await page.locator('#cloneProfile').selectOption('keyword_match_rules'); - assert.equal(await page.locator('#newButton').isEnabled(), true); - const initialized = page.waitForResponse(response => response.url().endsWith('/api/init')); + assert.match(await page.locator('#cloneProfile').textContent(), /keyword_match_rules/); await page.locator('#newButton').click(); - const initializedResponse = await initialized; - assert.equal(initializedResponse.status(), 200); - assert.deepEqual(initializedResponse.request().postDataJSON(), {profile: 'keyword_match_rules'}); - await page.waitForFunction(() => document.querySelector('#dirtyBadge').textContent === '未保存'); - assert.deepEqual(await jsonValue(), original); - if (await page.locator('#editModeButton').getAttribute('aria-pressed') === 'false') await page.locator('#editModeButton').click(); - assert.equal(await page.locator('#editModeButton').getAttribute('aria-pressed'), 'true'); - await page.locator('#nodes .node[data-node-id="match_keywords"]').click(); - await page.locator('#nodeName').fill('renamed'); - const categories = {BROWSER_VIP: ['VIP']}; - await page.locator('#configFields [data-field="categories"]').fill(JSON.stringify(categories)); - assert.equal((await jsonValue()).pipeline[0].name, 'match_keywords'); - await page.locator('#openRunButton').click(); - assert.equal(await page.locator('#runTab').isVisible(), true); - assert.deepEqual(await jsonValue(), original, 'opening run settings must not apply node drafts'); - await tab('properties'); - assert.equal(await page.locator('#nodeName').inputValue(), 'renamed'); - await page.locator('#applyNode').click(); - await page.locator('#nodes .node[data-node-id="renamed"]').waitFor(); - const renamed = await jsonValue(); - assert.equal(renamed.pipeline[0].name, 'renamed'); - assert.deepEqual(renamed.pipeline[0].params.categories, categories); - assert.equal(renamed.io.output[0].inputs.matches, 'renamed.matches'); + await page.locator('.node[data-node-id="match_keywords"]').waitFor(); + await page.locator('#operatorsToggle').click(); + const original = await jsonValue(); + await page.locator('.node[data-node-id="match_keywords"]').click(); + assert.equal(await page.locator('#nodeForm').isVisible(), true); + assert.equal(await page.locator('#configFields [data-collection="categories"]').isVisible(), true); + const category = page.locator('[data-collection="categories"]'); + await category.getByLabel('键', {exact: true}).fill('BROWSER_VIP'); + await category.getByLabel('值,每行一个').fill('VIP'); + await category.getByLabel('值,每行一个').press('Tab'); + await page.waitForFunction(() => JSON.parse(document.querySelector('#rawJson').value).pipeline[0].params.categories.BROWSER_VIP?.[0] === 'VIP'); + const edited = await jsonValue(); await page.locator('#undoButton').click(); - assert.deepEqual(await jsonValue(), original); + assert.notDeepEqual((await jsonValue()).pipeline[0].params.categories, edited.pipeline[0].params.categories); await page.locator('#redoButton').click(); - assert.deepEqual(await jsonValue(), renamed); + assert.deepEqual(await jsonValue(), edited); + + // A rule can be added, edited and removed without writing JSON; unset optional fields sit under 更多参数. + await page.locator('#configFields > details > summary', {hasText: '更多参数'}).click(); + await page.locator('[data-collection="rules"] > button').click(); + const rule = page.locator('[data-collection="rules"] .collection-row'); + await rule.locator('[data-field="pattern"]').fill('专席'); + await rule.locator('[data-field="pattern"]').press('Tab'); + assert.equal((await jsonValue()).pipeline[0].params.rules[0].pattern, '专席'); + const constants = rule.locator('[data-field="constants"]'); + await constants.fill('{invalid'); + await page.locator('#saveButton').click(); + assert.equal(await constants.evaluate(input => input.checkValidity()), false); + await constants.fill('{"route":"vip"}'); await constants.press('Tab'); + assert.deepEqual((await jsonValue()).pipeline[0].params.rules[0].constants, {route: 'vip'}); + await rule.getByRole('button', {name: '删除此项', exact: true}).click(); + const complete = await jsonValue(); + await save(true); + assert.deepEqual(await savedDocument(savedFile()), complete); + assert.deepEqual(await savedDocument('pipeline_keyword_match_rules.json'), original); - // Invalid JSON remains an unapplied draft and retains the rendered graph. - await tab('json'); - await page.locator('#rawJson').fill('{broken