소스 검색

feat: TestRunPlugin (#984)

July 2 달 전
부모
커밋
fd6967c85c
43개의 변경된 파일1504개의 추가작업 그리고 89개의 파일을 삭제
  1. 73 79
      common/config/rush/pnpm-lock.yaml
  2. 17 0
      packages/plugins/test-run-plugin/.eslintrc.cjs
  3. 56 0
      packages/plugins/test-run-plugin/package.json
  4. 39 0
      packages/plugins/test-run-plugin/src/create-test-run-plugin.ts
  5. 16 0
      packages/plugins/test-run-plugin/src/form-engine/contexts.ts
  6. 32 0
      packages/plugins/test-run-plugin/src/form-engine/fields/create-field.tsx
  7. 35 0
      packages/plugins/test-run-plugin/src/form-engine/fields/general-field.tsx
  8. 9 0
      packages/plugins/test-run-plugin/src/form-engine/fields/index.ts
  9. 21 0
      packages/plugins/test-run-plugin/src/form-engine/fields/object-field.tsx
  10. 57 0
      packages/plugins/test-run-plugin/src/form-engine/fields/reactive-field.tsx
  11. 31 0
      packages/plugins/test-run-plugin/src/form-engine/fields/recursion-field.tsx
  12. 32 0
      packages/plugins/test-run-plugin/src/form-engine/fields/schema-field.tsx
  13. 38 0
      packages/plugins/test-run-plugin/src/form-engine/form/form.tsx
  14. 6 0
      packages/plugins/test-run-plugin/src/form-engine/form/index.ts
  15. 8 0
      packages/plugins/test-run-plugin/src/form-engine/hooks/index.ts
  16. 69 0
      packages/plugins/test-run-plugin/src/form-engine/hooks/use-create-form.ts
  17. 13 0
      packages/plugins/test-run-plugin/src/form-engine/hooks/use-field.ts
  18. 13 0
      packages/plugins/test-run-plugin/src/form-engine/hooks/use-form.ts
  19. 19 0
      packages/plugins/test-run-plugin/src/form-engine/index.ts
  20. 89 0
      packages/plugins/test-run-plugin/src/form-engine/model/index.ts
  21. 56 0
      packages/plugins/test-run-plugin/src/form-engine/types.ts
  22. 64 0
      packages/plugins/test-run-plugin/src/form-engine/utils.ts
  23. 22 0
      packages/plugins/test-run-plugin/src/index.ts
  24. 7 0
      packages/plugins/test-run-plugin/src/reactive/hooks/index.ts
  25. 90 0
      packages/plugins/test-run-plugin/src/reactive/hooks/use-create-form.ts
  26. 10 0
      packages/plugins/test-run-plugin/src/reactive/hooks/use-test-run-service.ts
  27. 6 0
      packages/plugins/test-run-plugin/src/reactive/index.ts
  28. 42 0
      packages/plugins/test-run-plugin/src/services/config.ts
  29. 9 0
      packages/plugins/test-run-plugin/src/services/form/factory.ts
  30. 78 0
      packages/plugins/test-run-plugin/src/services/form/form.ts
  31. 8 0
      packages/plugins/test-run-plugin/src/services/form/index.ts
  32. 43 0
      packages/plugins/test-run-plugin/src/services/form/manager.ts
  33. 14 0
      packages/plugins/test-run-plugin/src/services/index.ts
  34. 9 0
      packages/plugins/test-run-plugin/src/services/pipeline/factory.ts
  35. 12 0
      packages/plugins/test-run-plugin/src/services/pipeline/index.ts
  36. 143 0
      packages/plugins/test-run-plugin/src/services/pipeline/pipeline.ts
  37. 11 0
      packages/plugins/test-run-plugin/src/services/pipeline/plugin.ts
  38. 34 0
      packages/plugins/test-run-plugin/src/services/pipeline/tap.ts
  39. 27 0
      packages/plugins/test-run-plugin/src/services/store.ts
  40. 100 0
      packages/plugins/test-run-plugin/src/services/test-run.ts
  41. 29 0
      packages/plugins/test-run-plugin/src/types.ts
  42. 12 0
      packages/plugins/test-run-plugin/tsconfig.json
  43. 5 10
      rush.json

+ 73 - 79
common/config/rush/pnpm-lock.yaml

@@ -190,85 +190,6 @@ importers:
         specifier: ^5.8.3
         version: 5.9.2
 
-  ../../apps/demo-fixed-layout-animation:
-    dependencies:
-      '@flowgram.ai/fixed-layout-editor':
-        specifier: workspace:*
-        version: link:../../packages/client/fixed-layout-editor
-      '@flowgram.ai/fixed-semi-materials':
-        specifier: workspace:*
-        version: link:../../packages/materials/fixed-semi-materials
-      '@flowgram.ai/minimap-plugin':
-        specifier: workspace:*
-        version: link:../../packages/plugins/minimap-plugin
-      classnames:
-        specifier: ^2.5.1
-        version: 2.5.1
-      lodash-es:
-        specifier: ^4.17.21
-        version: 4.17.21
-      nanoid:
-        specifier: ^5.0.9
-        version: 5.1.5
-      react:
-        specifier: ^18
-        version: 18.3.1
-      react-dom:
-        specifier: ^18
-        version: 18.3.1(react@18.3.1)
-      styled-components:
-        specifier: ^5
-        version: 5.3.11(@babel/core@7.28.4)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)
-    devDependencies:
-      '@flowgram.ai/eslint-config':
-        specifier: workspace:*
-        version: link:../../config/eslint-config
-      '@flowgram.ai/ts-config':
-        specifier: workspace:*
-        version: link:../../config/ts-config
-      '@rsbuild/core':
-        specifier: ^1.2.16
-        version: 1.5.6
-      '@rsbuild/plugin-less':
-        specifier: ^1.1.1
-        version: 1.5.0(@rsbuild/core@1.5.6)
-      '@rsbuild/plugin-react':
-        specifier: ^1.1.1
-        version: 1.4.0(@rsbuild/core@1.5.6)
-      '@types/lodash-es':
-        specifier: ^4.17.12
-        version: 4.17.12
-      '@types/node':
-        specifier: ^18
-        version: 18.19.124
-      '@types/react':
-        specifier: ^18
-        version: 18.3.24
-      '@types/react-dom':
-        specifier: ^18
-        version: 18.3.7(@types/react@18.3.24)
-      '@types/styled-components':
-        specifier: ^5
-        version: 5.1.34
-      '@typescript-eslint/parser':
-        specifier: ^6.10.0
-        version: 6.21.0(eslint@8.57.1)(typescript@5.9.2)
-      cross-env:
-        specifier: ~7.0.3
-        version: 7.0.3
-      eslint:
-        specifier: ^8.54.0
-        version: 8.57.1
-      less:
-        specifier: ^4.1.2
-        version: 4.4.1
-      less-loader:
-        specifier: ^6
-        version: 6.2.0(webpack@5.101.3)
-      typescript:
-        specifier: ^5.8.3
-        version: 5.9.2
-
   ../../apps/demo-fixed-layout-simple:
     dependencies:
       '@douyinfe/semi-icons':
@@ -4100,6 +4021,55 @@ importers:
         specifier: ^3.2.4
         version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.20)(jiti@2.5.1)(jsdom@26.1.0)(less@4.4.1)(lightningcss@1.30.1)(sass-embedded@1.93.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.19.4)(yaml@2.8.1)
 
+  ../../packages/plugins/test-run-plugin:
+    dependencies:
+      '@flowgram.ai/core':
+        specifier: workspace:*
+        version: link:../../canvas-engine/core
+      '@flowgram.ai/document':
+        specifier: workspace:*
+        version: link:../../canvas-engine/document
+      '@flowgram.ai/form':
+        specifier: workspace:*
+        version: link:../../node-engine/form
+      '@flowgram.ai/form-core':
+        specifier: workspace:*
+        version: link:../../node-engine/form-core
+      '@flowgram.ai/reactive':
+        specifier: workspace:*
+        version: link:../../common/reactive
+      '@flowgram.ai/utils':
+        specifier: workspace:*
+        version: link:../../common/utils
+      inversify:
+        specifier: ^6.0.1
+        version: 6.2.2(reflect-metadata@0.2.2)
+      nanoid:
+        specifier: ^5.0.9
+        version: 5.1.5
+      zustand:
+        specifier: ^5.0.8
+        version: 5.0.8(@types/react@18.3.24)(immer@10.1.3)(react@18.3.1)
+    devDependencies:
+      '@flowgram.ai/eslint-config':
+        specifier: workspace:*
+        version: link:../../../config/eslint-config
+      '@flowgram.ai/ts-config':
+        specifier: workspace:*
+        version: link:../../../config/ts-config
+      '@types/react':
+        specifier: ^18
+        version: 18.3.24
+      react:
+        specifier: ^18
+        version: 18.3.1
+      tsup:
+        specifier: ^8.0.1
+        version: 8.5.0(jiti@2.5.1)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.9.2)(yaml@2.8.1)
+      typescript:
+        specifier: ^5.8.3
+        version: 5.9.2
+
   ../../packages/plugins/variable-plugin:
     dependencies:
       '@flowgram.ai/core':
@@ -13261,6 +13231,24 @@ packages:
   zod@3.25.76:
     resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
 
+  zustand@5.0.8:
+    resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
+    engines: {node: '>=12.20.0'}
+    peerDependencies:
+      '@types/react': '>=18.0.0'
+      immer: '>=9.0.6'
+      react: '>=18.0.0'
+      use-sync-external-store: '>=1.2.0'
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      immer:
+        optional: true
+      react:
+        optional: true
+      use-sync-external-store:
+        optional: true
+
   zwitch@2.0.4:
     resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
 
@@ -24465,4 +24453,10 @@ snapshots:
 
   zod@3.25.76: {}
 
+  zustand@5.0.8(@types/react@18.3.24)(immer@10.1.3)(react@18.3.1):
+    optionalDependencies:
+      '@types/react': 18.3.24
+      immer: 10.1.3
+      react: 18.3.1
+
   zwitch@2.0.4: {}

+ 17 - 0
packages/plugins/test-run-plugin/.eslintrc.cjs

@@ -0,0 +1,17 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+const { defineConfig } = require('@flowgram.ai/eslint-config');
+
+module.exports = defineConfig({
+  preset: 'web',
+  packageRoot: __dirname,
+  rules: {
+    'no-console': 'off',
+    'react/no-deprecated': 'off',
+    '@flowgram.ai/e2e-data-testid': 'off',
+    'react/prop-types': 'off'
+  },
+});

+ 56 - 0
packages/plugins/test-run-plugin/package.json

@@ -0,0 +1,56 @@
+{
+  "name": "@flowgram.ai/test-run-plugin",
+  "version": "0.1.8",
+  "homepage": "https://flowgram.ai/",
+  "repository": "https://github.com/bytedance/flowgram.ai",
+  "license": "MIT",
+  "exports": {
+    "types": "./dist/index.d.ts",
+    "import": "./dist/esm/index.js",
+    "require": "./dist/index.js"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/esm/index.js",
+  "types": "./dist/index.d.ts",
+  "scripts": {
+    "build": "npm run build:fast -- --dts-resolve",
+    "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
+    "build:watch": "npm run build:fast -- --dts-resolve",
+    "clean": "rimraf dist",
+    "test": "exit 0",
+    "test:cov": "exit 0",
+    "ts-check": "tsc --noEmit",
+    "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
+  },
+  "dependencies": {
+    "inversify": "^6.0.1",
+    "nanoid": "^5.0.9",
+    "zustand": "^5.0.8",
+    "@flowgram.ai/form-core": "workspace:*",
+    "@flowgram.ai/core": "workspace:*",
+    "@flowgram.ai/utils": "workspace:*",
+    "@flowgram.ai/document": "workspace:*",
+    "@flowgram.ai/form": "workspace:*",
+    "@flowgram.ai/reactive": "workspace:*"
+  },
+  "devDependencies": {
+    "react": "^18",
+    "@types/react": "^18",
+    "@flowgram.ai/eslint-config": "workspace:*",
+    "@flowgram.ai/ts-config": "workspace:*",
+    "tsup": "^8.0.1",
+    "typescript": "^5.8.3"
+  },
+  "peerDependencies": {
+    "react": ">=16.8"
+  },
+  "peerDependenciesMeta": {
+    "react": {
+      "optional": true
+    }
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  }
+}

+ 39 - 0
packages/plugins/test-run-plugin/src/create-test-run-plugin.ts

@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { definePluginCreator } from '@flowgram.ai/core';
+
+import { TestRunFormEntity, TestRunFormFactory, TestRunFormManager } from './services/form';
+import {
+  TestRunService,
+  TestRunPipelineEntity,
+  TestRunPipelineFactory,
+  TestRunConfig,
+  defineConfig,
+} from './services';
+
+export const createTestRunPlugin = definePluginCreator<Partial<TestRunConfig>>({
+  onBind: ({ bind }, opt) => {
+    /** service */
+    bind(TestRunService).toSelf().inSingletonScope();
+    /** config */
+    bind(TestRunConfig).toConstantValue(defineConfig(opt));
+    /** form manager */
+    bind(TestRunFormManager).toSelf().inSingletonScope();
+    /** form entity */
+    bind<TestRunFormFactory>(TestRunFormFactory).toFactory<TestRunFormEntity>((context) => () => {
+      const e = context.container.resolve(TestRunFormEntity);
+      return e;
+    });
+    /** pipeline entity */
+    bind<TestRunPipelineFactory>(TestRunPipelineFactory).toFactory<TestRunPipelineEntity>(
+      (context) => () => {
+        const e = context.container.resolve(TestRunPipelineEntity);
+        e.container = context.container.createChild();
+        return e;
+      }
+    );
+  },
+});

+ 16 - 0
packages/plugins/test-run-plugin/src/form-engine/contexts.ts

@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { createContext } from 'react';
+
+import type { FormComponents } from './types';
+import type { FormSchemaModel } from './model';
+
+/** Model context for each form item */
+export const FieldModelContext = createContext<FormSchemaModel>({} as any);
+/** The form's model context */
+export const FormModelContext = createContext<FormSchemaModel>({} as any);
+/** Context of material component map */
+export const ComponentsContext = createContext<FormComponents>({});

+ 32 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/create-field.tsx

@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { PropsWithChildren } from 'react';
+
+import { SchemaField, type SchemaFieldProps } from './schema-field';
+import { FormComponents } from '../types';
+
+type InnerSchemaFieldProps = Omit<SchemaFieldProps, 'components'> &
+  Pick<Partial<SchemaFieldProps>, 'components'>;
+
+export interface CreateSchemaFieldOptions {
+  components?: FormComponents;
+}
+export const createSchemaField = (options: CreateSchemaFieldOptions) => {
+  const InnerSchemaField: React.FC<PropsWithChildren<InnerSchemaFieldProps>> = ({
+    components,
+    ...props
+  }) => (
+    <SchemaField
+      components={{
+        ...options.components,
+        ...components,
+      }}
+      {...props}
+    />
+  );
+
+  return InnerSchemaField;
+};

+ 35 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/general-field.tsx

@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { Field } from '@flowgram.ai/form';
+
+import { ReactiveField } from './reactive-field';
+import type { FormSchemaModel } from '../model';
+import { FieldModelContext } from '../contexts';
+
+export interface GeneralFieldProps {
+  model: FormSchemaModel;
+}
+
+export const GeneralField: React.FC<GeneralFieldProps> = ({ model }) => (
+  <FieldModelContext.Provider value={model}>
+    <Field
+      name={model.uniqueName}
+      defaultValue={model.defaultValue}
+      render={({ field, fieldState }) => (
+        <ReactiveField
+          componentProps={{
+            value: field.value,
+            onChange: field.onChange,
+            onFocus: field.onFocus,
+            onBlur: field.onBlur,
+            ...fieldState,
+          }}
+          decoratorProps={fieldState}
+        />
+      )}
+    />
+  </FieldModelContext.Provider>
+);

+ 9 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/index.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+/** field components */
+export { SchemaField, type SchemaFieldProps } from './schema-field';
+/** field functions */
+export { createSchemaField, type CreateSchemaFieldOptions } from './create-field';

+ 21 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/object-field.tsx

@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { FormSchemaModel } from '../model';
+import { FieldModelContext } from '../contexts';
+import { ReactiveField } from './reactive-field';
+
+export interface ObjectFieldProps {
+  model: FormSchemaModel;
+}
+
+export const ObjectField: React.FC<React.PropsWithChildren<ObjectFieldProps>> = ({
+  model,
+  children,
+}) => (
+  <FieldModelContext.Provider value={model}>
+    <ReactiveField>{children}</ReactiveField>
+  </FieldModelContext.Provider>
+);

+ 57 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/reactive-field.tsx

@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useContext } from 'react';
+import React from 'react';
+
+import { useFormState } from '../hooks/use-form';
+import { useFieldModel, useFieldState } from '../hooks/use-field';
+import { ComponentsContext } from '../contexts';
+
+interface ReactiveFieldProps {
+  componentProps?: Record<string, unknown>;
+  decoratorProps?: Record<string, unknown>;
+}
+
+export const ReactiveField: React.FC<React.PropsWithChildren<ReactiveFieldProps>> = (props) => {
+  const formState = useFormState();
+  const model = useFieldModel();
+  const modelState = useFieldState();
+  const components = useContext(ComponentsContext);
+
+  const disabled = modelState.disabled || formState.disabled;
+  const componentRender = () => {
+    if (!model.componentType || !components[model.componentType]) {
+      return props.children;
+    }
+    return React.createElement(
+      components[model.componentType],
+      {
+        disabled,
+        ...model.componentProps,
+        ...props.componentProps,
+      },
+      props.children
+    );
+  };
+
+  const decoratorRender = (children: React.ReactNode) => {
+    if (!model.decoratorType || !components[model.decoratorType]) {
+      return <>{children}</>;
+    }
+    return React.createElement(
+      components[model.decoratorType],
+      {
+        type: model.type,
+        required: model.required,
+        ...model.decoratorProps,
+        ...props.decoratorProps,
+      },
+      children
+    );
+  };
+
+  return decoratorRender(componentRender());
+};

+ 31 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/recursion-field.tsx

@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useMemo } from 'react';
+
+import { FormSchemaModel } from '../model';
+import { ObjectField } from './object-field';
+import { GeneralField } from './general-field';
+
+interface RecursionFieldProps {
+  model: FormSchemaModel;
+}
+
+export const RecursionField: React.FC<RecursionFieldProps> = ({ model }) => {
+  const properties = useMemo(() => model.getPropertyList(), [model]);
+
+  /** general field has no children */
+  if (model.type !== 'object') {
+    return <GeneralField model={model} />;
+  }
+
+  return (
+    <ObjectField model={model}>
+      {properties.map((item) => (
+        <RecursionField key={item.uniqueName} model={item} />
+      ))}
+    </ObjectField>
+  );
+};

+ 32 - 0
packages/plugins/test-run-plugin/src/form-engine/fields/schema-field.tsx

@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useState } from 'react';
+
+import type { FormComponents } from '../types';
+import { FormSchemaModel } from '../model';
+import { ComponentsContext, FormModelContext } from '../contexts';
+import { RecursionField } from './recursion-field';
+
+export interface SchemaFieldProps {
+  model: FormSchemaModel;
+  components: FormComponents;
+}
+export const SchemaField: React.FC<React.PropsWithChildren<SchemaFieldProps>> = ({
+  components,
+  model,
+  children,
+}) => {
+  /** Only initialized once, dynamic is not supported */
+  const [innerComponents] = useState(() => components);
+  return (
+    <ComponentsContext.Provider value={innerComponents}>
+      <FormModelContext.Provider value={model}>
+        <RecursionField model={model} />
+        {children}
+      </FormModelContext.Provider>
+    </ComponentsContext.Provider>
+  );
+};

+ 38 - 0
packages/plugins/test-run-plugin/src/form-engine/form/form.tsx

@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { Form } from '@flowgram.ai/form';
+
+import { FormSchema, FormComponents } from '../types';
+import { useCreateForm, type UseCreateFormOptions } from '../hooks';
+import { createSchemaField } from '../fields';
+
+const SchemaField = createSchemaField({});
+
+export type FormEngineProps = React.PropsWithChildren<
+  {
+    /** Form schema */
+    schema: FormSchema;
+    /** form material map */
+    components?: FormComponents;
+  } & UseCreateFormOptions
+>;
+
+export const FormEngine: React.FC<FormEngineProps> = ({
+  schema,
+  components,
+  children,
+  ...props
+}) => {
+  const { model, control } = useCreateForm(schema, props);
+
+  return (
+    <Form control={control}>
+      <SchemaField model={model} components={components}>
+        {children}
+      </SchemaField>
+    </Form>
+  );
+};

+ 6 - 0
packages/plugins/test-run-plugin/src/form-engine/form/index.ts

@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { FormEngine, type FormEngineProps } from './form';

+ 8 - 0
packages/plugins/test-run-plugin/src/form-engine/hooks/index.ts

@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { useCreateForm, type UseCreateFormOptions } from './use-create-form';
+export { useFieldModel, useFieldState } from './use-field';
+export { useFormModel, useFormState } from './use-form';

+ 69 - 0
packages/plugins/test-run-plugin/src/form-engine/hooks/use-create-form.ts

@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useEffect, useMemo } from 'react';
+
+import type { OnFormValuesChangePayload } from '@flowgram.ai/form-core';
+import { createForm, ValidateTrigger, type IForm } from '@flowgram.ai/form';
+
+import { createValidate } from '../utils';
+import { FormSchema, FormSchemaValidate } from '../types';
+import { FormSchemaModel } from '../model';
+
+export interface FormInstance {
+  model: FormSchemaModel;
+  form: IForm;
+}
+export interface UseCreateFormOptions {
+  defaultValues?: any;
+  validate?: Record<string, FormSchemaValidate>;
+  validateTrigger?: ValidateTrigger;
+  onMounted?: (form: FormInstance) => void;
+  onFormValuesChange?: (payload: OnFormValuesChangePayload) => void;
+  onUnmounted?: () => void;
+}
+
+export const useCreateForm = (schema: FormSchema, options: UseCreateFormOptions = {}) => {
+  const { form, control } = useMemo(
+    () =>
+      createForm({
+        validate: {
+          ...createValidate(schema),
+          ...options.validate,
+        },
+        validateTrigger: options.validateTrigger ?? ValidateTrigger.onBlur,
+      }),
+    [schema]
+  );
+
+  const model = useMemo(
+    () => new FormSchemaModel({ type: 'object', ...schema, defaultValue: options.defaultValues }),
+    [schema]
+  );
+
+  /** Lifecycle and event binding */
+  useEffect(() => {
+    if (options.onMounted) {
+      options.onMounted({ model, form });
+    }
+    const disposable = control._formModel.onFormValuesChange((payload) => {
+      if (options.onFormValuesChange) {
+        options.onFormValuesChange(payload);
+      }
+    });
+    return () => {
+      disposable.dispose();
+      if (options.onUnmounted) {
+        options.onUnmounted();
+      }
+    };
+  }, [control]);
+
+  return {
+    form,
+    control,
+    model,
+  };
+};

+ 13 - 0
packages/plugins/test-run-plugin/src/form-engine/hooks/use-field.ts

@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useContext } from 'react';
+
+import { useObserve } from '@flowgram.ai/reactive';
+
+import { FieldModelContext } from '../contexts';
+
+export const useFieldModel = () => useContext(FieldModelContext);
+export const useFieldState = () => useObserve(useFieldModel().state.value);

+ 13 - 0
packages/plugins/test-run-plugin/src/form-engine/hooks/use-form.ts

@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useContext } from 'react';
+
+import { useObserve } from '@flowgram.ai/reactive';
+
+import { FormModelContext } from '../contexts';
+
+export const useFormModel = () => useContext(FormModelContext);
+export const useFormState = () => useObserve(useFormModel().state.value);

+ 19 - 0
packages/plugins/test-run-plugin/src/form-engine/index.ts

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { FormEngine, type FormEngineProps } from './form';
+export { FormSchemaModel } from './model';
+/** utils */
+export { connect, isFormEmpty } from './utils';
+
+/** types */
+export type {
+  FormSchema,
+  FormSchemaValidate,
+  FormComponents,
+  FormComponent,
+  FormComponentProps,
+} from './types';
+export type { FormInstance } from './hooks/use-create-form';

+ 89 - 0
packages/plugins/test-run-plugin/src/form-engine/model/index.ts

@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { ReactiveState } from '@flowgram.ai/reactive';
+
+import { getUniqueFieldName, mergeFieldPath } from '../utils';
+import { FormSchema, FormSchemaType, FormSchemaModelState } from '../types';
+
+export class FormSchemaModel implements FormSchema {
+  name?: string;
+
+  type?: FormSchemaType;
+
+  defaultValue?: any;
+
+  properties?: Record<string, FormSchema>;
+
+  ['x-index']?: number;
+
+  ['x-component']?: string;
+
+  ['x-component-props']?: Record<string, unknown>;
+
+  ['x-decorator']?: string;
+
+  ['x-decorator-props']?: Record<string, unknown>;
+
+  [key: string]: any;
+
+  path: string[] = [];
+
+  state = new ReactiveState<FormSchemaModelState>({ disabled: false });
+
+  get componentType() {
+    return this['x-component'];
+  }
+
+  get componentProps() {
+    return this['x-component-props'];
+  }
+
+  get decoratorType() {
+    return this['x-decorator'];
+  }
+
+  get decoratorProps() {
+    return this['x-decorator-props'];
+  }
+
+  get uniqueName() {
+    return getUniqueFieldName(...this.path);
+  }
+
+  constructor(json: FormSchema, path: string[] = []) {
+    this.fromJSON(json);
+    this.path = path;
+  }
+
+  private fromJSON(json: FormSchema) {
+    Object.entries(json).forEach(([key, value]) => {
+      this[key] = value;
+    });
+  }
+
+  getPropertyList() {
+    const orderProperties: FormSchemaModel[] = [];
+    const unOrderProperties: FormSchemaModel[] = [];
+    Object.entries(this.properties || {}).forEach(([key, item]) => {
+      const index = item['x-index'];
+      const defaultValues = this.defaultValue;
+      /**
+       * The upper layer's default value has a higher priority than its own default value,
+       * because the upper layer's default value ultimately comes from the outside world.
+       */
+      if (typeof defaultValues === 'object' && defaultValues !== null && key in defaultValues) {
+        item.defaultValue = defaultValues[key];
+      }
+      const current = new FormSchemaModel(item, mergeFieldPath(this.path, key));
+      if (index !== undefined && !isNaN(index)) {
+        orderProperties[index] = current;
+      } else {
+        unOrderProperties.push(current);
+      }
+    });
+    return orderProperties.concat(unOrderProperties).filter((item) => !!item);
+  }
+}

+ 56 - 0
packages/plugins/test-run-plugin/src/form-engine/types.ts

@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import type { Validate, FieldState } from '@flowgram.ai/form';
+
+/** field type */
+export type FormSchemaType = 'string' | 'number' | 'boolean' | 'object' | string;
+
+export type FormSchemaValidate = Validate;
+
+export interface FormSchema {
+  /** core */
+  name?: string;
+  type?: FormSchemaType;
+  defaultValue?: any;
+
+  /** children */
+  properties?: Record<string, FormSchema>;
+
+  /** ui */
+  title?: string | React.ReactNode;
+  description?: string | React.ReactNode;
+  ['x-index']?: number;
+  ['x-visible']?: boolean;
+  ['x-hidden']?: boolean;
+  ['x-component']?: string;
+  ['x-component-props']?: Record<string, unknown>;
+  ['x-decorator']?: string;
+  ['x-decorator-props']?: Record<string, unknown>;
+
+  /** rule */
+  required?: boolean;
+  ['x-validator']?: FormSchemaValidate;
+
+  /** custom */
+  [key: string]: any;
+}
+
+export type FormComponentProps = {
+  type?: FormSchemaType;
+  disabled?: boolean;
+  [key: string]: any;
+} & FormSchema['x-component-props'] &
+  FormSchema['x-decorator-props'] &
+  Partial<FieldState>;
+export type FormComponent = React.FunctionComponent<any>;
+export type FormComponents = Record<string, FormComponent>;
+
+/** ui state */
+export interface FormSchemaModelState {
+  disabled: boolean;
+}

+ 64 - 0
packages/plugins/test-run-plugin/src/form-engine/utils.ts

@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { createElement } from 'react';
+
+import type { FormSchema, FormSchemaValidate, FormComponentProps } from './types';
+
+/** Splice form item unique name */
+export const getUniqueFieldName = (...args: (string | undefined)[]) =>
+  args.filter((path) => path).join('.');
+
+export const mergeFieldPath = (path?: string[], name?: string) =>
+  [...(path || []), name].filter((i): i is string => Boolean(i));
+
+/** Create validation rules */
+export const createValidate = (schema: FormSchema) => {
+  const rules: Record<string, FormSchemaValidate> = {};
+
+  visit(schema);
+
+  return rules;
+
+  function visit(current: FormSchema, name?: string) {
+    if (name && current['x-validator']) {
+      rules[name] = current['x-validator'];
+    }
+    if (current.type === 'object' && current.properties) {
+      Object.entries(current.properties).forEach(([key, value]) => {
+        visit(value, getUniqueFieldName(name, key));
+      });
+    }
+  }
+};
+
+export const connect = <T = any>(
+  Component: React.FunctionComponent<any>,
+  mapProps: (p: FormComponentProps) => T
+) => {
+  const Connected = (props: FormComponentProps) => {
+    const mappedProps = mapProps(props);
+    return createElement(Component, mappedProps, (mappedProps as any).children);
+  };
+
+  return Connected;
+};
+
+export const isFormEmpty = (schema: FormSchema) => {
+  /** is not general field and not has children */
+  const isEmpty = (s: FormSchema): boolean => {
+    if (!s.type || s.type === 'object' || !s.name) {
+      return Object.entries(schema.properties || {})
+        .map(([key, value]) => ({
+          name: key,
+          ...value,
+        }))
+        .every(isFormEmpty);
+    }
+    return false;
+  };
+
+  return isEmpty(schema);
+};

+ 22 - 0
packages/plugins/test-run-plugin/src/index.ts

@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { createTestRunPlugin } from './create-test-run-plugin';
+export { useCreateForm, useTestRunService } from './reactive';
+
+export {
+  FormEngine,
+  connect,
+  type FormInstance,
+  type FormEngineProps,
+  type FormSchema,
+  type FormComponentProps,
+} from './form-engine';
+
+export {
+  type TestRunPipelinePlugin,
+  TestRunPipelineEntity,
+  type TestRunPipelineEntityCtx,
+} from './services';

+ 7 - 0
packages/plugins/test-run-plugin/src/reactive/hooks/index.ts

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { useCreateForm } from './use-create-form';
+export { useTestRunService } from './use-test-run-service';

+ 90 - 0
packages/plugins/test-run-plugin/src/reactive/hooks/use-create-form.ts

@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+
+import { DisposableCollection } from '@flowgram.ai/utils';
+import type { FlowNodeEntity } from '@flowgram.ai/document';
+
+import { TestRunFormEntity } from '../../services/form/form';
+import { FormEngineProps, isFormEmpty } from '../../form-engine';
+import { useTestRunService } from './use-test-run-service';
+
+interface UseFormOptions {
+  node?: FlowNodeEntity;
+  /** form loading */
+  loadingRenderer?: React.ReactNode;
+  /** form empty */
+  emptyRenderer?: React.ReactNode;
+  defaultValues?: FormEngineProps['defaultValues'];
+  onMounted?: FormEngineProps['onMounted'];
+  onUnmounted?: FormEngineProps['onUnmounted'];
+  onFormValuesChange?: FormEngineProps['onFormValuesChange'];
+}
+
+export const useCreateForm = ({
+  node,
+  loadingRenderer,
+  emptyRenderer,
+  defaultValues,
+  onMounted,
+  onUnmounted,
+  onFormValuesChange,
+}: UseFormOptions) => {
+  const testRun = useTestRunService();
+  const [loading, setLoading] = useState(false);
+  const [form, setForm] = useState<TestRunFormEntity | null>(null);
+  const renderer = useMemo(() => {
+    if (loading || !form) {
+      return loadingRenderer;
+    }
+
+    const isEmpty = isFormEmpty(form.schema);
+
+    return form.render({
+      defaultValues,
+      onFormValuesChange,
+      children: isEmpty ? emptyRenderer : null,
+    });
+  }, [form, loading]);
+
+  const compute = async () => {
+    if (!node) {
+      return;
+    }
+    try {
+      setLoading(true);
+      const formEntity = await testRun.createForm(node);
+      setForm(formEntity);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    compute();
+  }, [node]);
+
+  useEffect(() => {
+    if (!form) {
+      return;
+    }
+    const disposable = new DisposableCollection(
+      form.onFormMounted((data) => {
+        onMounted?.(data);
+      }),
+      form.onFormUnmounted(() => {
+        onUnmounted?.();
+      })
+    );
+    return () => disposable.dispose();
+  }, [form]);
+
+  return {
+    renderer,
+    loading,
+    form,
+  };
+};

+ 10 - 0
packages/plugins/test-run-plugin/src/reactive/hooks/use-test-run-service.ts

@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useService } from '@flowgram.ai/core';
+
+import { TestRunService } from '../../services/test-run';
+
+export const useTestRunService = () => useService<TestRunService>(TestRunService);

+ 6 - 0
packages/plugins/test-run-plugin/src/reactive/index.ts

@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { useCreateForm, useTestRunService } from './hooks';

+ 42 - 0
packages/plugins/test-run-plugin/src/services/config.ts

@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { FlowNodeType, FlowNodeEntity } from '@flowgram.ai/document';
+
+import type { MaybePromise } from '../types';
+import type { FormSchema, FormComponents } from '../form-engine';
+import type { TestRunPipelinePlugin } from './pipeline';
+
+type PropertiesFunctionParams = {
+  node: FlowNodeEntity;
+};
+export type NodeMap = Record<FlowNodeType, NodeTestConfig>;
+export interface NodeTestConfig {
+  /** Enable node TestRun */
+  enabled?: boolean;
+  /** Input schema properties */
+  properties?:
+    | Record<string, FormSchema>
+    | ((params: PropertiesFunctionParams) => MaybePromise<Record<string, FormSchema>>);
+}
+
+export interface TestRunConfig {
+  components: FormComponents;
+  nodes: NodeMap;
+  plugins: (new () => TestRunPipelinePlugin)[];
+}
+
+export const TestRunConfig = Symbol('TestRunConfig');
+export const defineConfig = (config: Partial<TestRunConfig>) => {
+  const defaultConfig: TestRunConfig = {
+    components: {},
+    nodes: {},
+    plugins: [],
+  };
+  return {
+    ...defaultConfig,
+    ...config,
+  };
+};

+ 9 - 0
packages/plugins/test-run-plugin/src/services/form/factory.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { TestRunFormEntity } from './form';
+
+export const TestRunFormFactory = Symbol('TestRunFormFactory');
+export type TestRunFormFactory = () => TestRunFormEntity;

+ 78 - 0
packages/plugins/test-run-plugin/src/services/form/form.ts

@@ -0,0 +1,78 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { createElement, type ReactNode } from 'react';
+
+import { nanoid } from 'nanoid';
+import { injectable, inject } from 'inversify';
+import { Emitter } from '@flowgram.ai/utils';
+
+import { TestRunConfig } from '../config';
+import { FormSchema, FormEngine, type FormInstance, type FormEngineProps } from '../../form-engine';
+
+export type FormRenderProps = Omit<
+  FormEngineProps,
+  'schema' | 'components' | 'onMounted' | 'onUnmounted'
+>;
+
+@injectable()
+export class TestRunFormEntity {
+  @inject(TestRunConfig) private readonly config: TestRunConfig;
+
+  private _schema: FormSchema;
+
+  private initialized = false;
+
+  id = nanoid();
+
+  form: FormInstance | null = null;
+
+  onFormMountedEmitter = new Emitter<FormInstance>();
+
+  onFormMounted = this.onFormMountedEmitter.event;
+
+  onFormUnmountedEmitter = new Emitter<void>();
+
+  onFormUnmounted = this.onFormUnmountedEmitter.event;
+
+  get schema() {
+    return this._schema;
+  }
+
+  init(options: { schema: FormSchema }) {
+    if (this.initialized) return;
+
+    this._schema = options.schema;
+    this.initialized = true;
+  }
+
+  render(props?: FormRenderProps): ReactNode {
+    if (!this.initialized) {
+      return null;
+    }
+    const { children, ...restProps } = props || {};
+    return createElement(
+      FormEngine,
+      {
+        schema: this.schema,
+        components: this.config.components,
+        onMounted: (instance) => {
+          this.form = instance;
+          this.onFormMountedEmitter.fire(instance);
+        },
+        onUnmounted: this.onFormUnmountedEmitter.fire.bind(this.onFormUnmountedEmitter),
+        ...restProps,
+      },
+      children
+    );
+  }
+
+  dispose() {
+    this._schema = {};
+    this.form = null;
+    this.onFormMountedEmitter.dispose();
+    this.onFormUnmountedEmitter.dispose();
+  }
+}

+ 8 - 0
packages/plugins/test-run-plugin/src/services/form/index.ts

@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { TestRunFormEntity } from './form';
+export { TestRunFormFactory } from './factory';
+export { TestRunFormManager } from './manager';

+ 43 - 0
packages/plugins/test-run-plugin/src/services/form/manager.ts

@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { inject, injectable } from 'inversify';
+
+import type { TestRunFormEntity } from './form';
+import { TestRunFormFactory } from './factory';
+
+@injectable()
+export class TestRunFormManager {
+  @inject(TestRunFormFactory) private readonly factory: TestRunFormFactory;
+
+  private entities = new Map<string, TestRunFormEntity>();
+
+  createForm() {
+    return this.factory();
+  }
+
+  getForm(id: string) {
+    return this.entities.get(id);
+  }
+
+  getAllForm() {
+    return Array.from(this.entities);
+  }
+
+  disposeForm(id: string) {
+    const form = this.entities.get(id);
+    if (!form) {
+      return;
+    }
+    form.dispose();
+    this.entities.delete(id);
+  }
+
+  disposeAllForm() {
+    for (const id of this.entities.keys()) {
+      this.disposeForm(id);
+    }
+  }
+}

+ 14 - 0
packages/plugins/test-run-plugin/src/services/index.ts

@@ -0,0 +1,14 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { TestRunService } from './test-run';
+export { TestRunFormEntity, TestRunFormFactory } from './form';
+export {
+  TestRunPipelineEntity,
+  TestRunPipelineFactory,
+  type TestRunPipelinePlugin,
+  type TestRunPipelineEntityCtx,
+} from './pipeline';
+export { TestRunConfig, defineConfig } from './config';

+ 9 - 0
packages/plugins/test-run-plugin/src/services/pipeline/factory.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { TestRunPipelineEntity } from './pipeline';
+
+export const TestRunPipelineFactory = Symbol('TestRunPipelineFactory');
+export type TestRunPipelineFactory = () => TestRunPipelineEntity;

+ 12 - 0
packages/plugins/test-run-plugin/src/services/pipeline/index.ts

@@ -0,0 +1,12 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { TestRunPipelineFactory } from './factory';
+export {
+  TestRunPipelineEntity,
+  type TestRunPipelineEntityOptions,
+  type TestRunPipelineEntityCtx,
+} from './pipeline';
+export { TestRunPipelinePlugin } from './plugin';

+ 143 - 0
packages/plugins/test-run-plugin/src/services/pipeline/pipeline.ts

@@ -0,0 +1,143 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { StoreApi } from 'zustand';
+import { nanoid } from 'nanoid';
+import { injectable, interfaces } from 'inversify';
+import { Emitter } from '@flowgram.ai/utils';
+
+import { Tap } from './tap';
+import type { TestRunPipelinePlugin } from './plugin';
+import { StoreService } from '../store';
+export interface TestRunPipelineEntityOptions {
+  plugins: (new () => TestRunPipelinePlugin)[];
+}
+
+interface TestRunPipelineEntityState<T = any> {
+  status: 'idle' | 'preparing' | 'executing' | 'canceled' | 'finished';
+  data?: T;
+  result?: any;
+  getData: () => T;
+  setData: (next: any) => void;
+}
+
+export interface TestRunPipelineEntityCtx<T = any> {
+  id: string;
+  store: StoreApi<TestRunPipelineEntityState<T>>;
+  operate: {
+    update: (data: any) => void;
+    cancel: () => void;
+  };
+}
+
+const initialState: Omit<TestRunPipelineEntityState, 'getData' | 'setData'> = {
+  status: 'idle',
+  data: {},
+};
+
+@injectable()
+export class TestRunPipelineEntity extends StoreService<TestRunPipelineEntityState> {
+  container: interfaces.Container | undefined;
+
+  id = nanoid();
+
+  prepare = new Tap<TestRunPipelineEntityCtx>();
+
+  private execute?: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void;
+
+  private progress?: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void;
+
+  get status() {
+    return this.getState().status;
+  }
+
+  set status(next: TestRunPipelineEntityState['status']) {
+    this.setState({ status: next });
+  }
+
+  onProgressEmitter = new Emitter<any>();
+
+  onProgress = this.onProgressEmitter.event;
+
+  onFinishedEmitter = new Emitter();
+
+  onFinished = this.onFinishedEmitter.event;
+
+  constructor() {
+    super((set, get) => ({
+      ...initialState,
+      getData: () => get().data || {},
+      setData: (next: any) => set((state) => ({ ...state, data: { ...state.data, ...next } })),
+    }));
+  }
+
+  public init(options: TestRunPipelineEntityOptions) {
+    if (!this.container) {
+      return;
+    }
+    const { plugins } = options;
+    for (const PluginClass of plugins) {
+      const plugin = this.container.resolve<TestRunPipelinePlugin>(PluginClass);
+      plugin.apply(this);
+    }
+  }
+
+  public registerExecute(fn: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void) {
+    this.execute = fn;
+  }
+
+  public registerProgress(fn: (ctx: TestRunPipelineEntityCtx) => Promise<void> | void) {
+    this.progress = fn;
+  }
+
+  async start<T>(options?: { data: T }) {
+    const { data } = options || {};
+    if (this.status !== 'idle') {
+      return;
+    }
+    /** initialization data */
+    this.setState({ data });
+    const ctx: TestRunPipelineEntityCtx = {
+      id: this.id,
+      store: this.store,
+      operate: {
+        update: this.update.bind(this),
+        cancel: this.cancel.bind(this),
+      },
+    };
+
+    this.status = 'preparing';
+    await this.prepare.call(ctx);
+    if (this.status !== 'preparing') {
+      return;
+    }
+
+    this.status = 'executing';
+    if (this.execute) {
+      await this.execute(ctx);
+    }
+    if (this.progress) {
+      await this.progress(ctx);
+    }
+    if (this.status === 'executing') {
+      this.status = 'finished';
+      this.onFinishedEmitter.fire(this.getState().result);
+    }
+  }
+
+  update(result: any) {
+    this.setState({ result });
+    this.onProgressEmitter.fire(result);
+  }
+
+  cancel() {
+    if ((this.status = 'preparing')) {
+      this.prepare.freeze();
+    }
+    this.status = 'canceled';
+  }
+
+  dispose() {}
+}

+ 11 - 0
packages/plugins/test-run-plugin/src/services/pipeline/plugin.ts

@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { TestRunPipelineEntity } from './pipeline';
+
+export interface TestRunPipelinePlugin {
+  name: string;
+  apply(pipeline: TestRunPipelineEntity): void;
+}

+ 34 - 0
packages/plugins/test-run-plugin/src/services/pipeline/tap.ts

@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { MaybePromise } from '../../types';
+
+interface TapValue<T> {
+  name: string;
+  fn: (arg: T) => MaybePromise<void>;
+}
+
+export class Tap<T> {
+  private taps: TapValue<T>[] = [];
+
+  private frozen = false;
+
+  tap(name: string, fn: TapValue<T>['fn']) {
+    this.taps.push({ name, fn });
+  }
+
+  async call(ctx: T) {
+    for (const tap of this.taps) {
+      if (this.frozen) {
+        return;
+      }
+      await tap.fn(ctx);
+    }
+  }
+
+  freeze() {
+    this.frozen = true;
+  }
+}

+ 27 - 0
packages/plugins/test-run-plugin/src/services/store.ts

@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { createStore } from 'zustand/vanilla';
+import type { StoreApi, StateCreator } from 'zustand';
+import { injectable, unmanaged } from 'inversify';
+/**
+ * 包含 Store 的 Service
+ */
+@injectable()
+export class StoreService<State> {
+  store: StoreApi<State>;
+
+  get getState() {
+    return this.store.getState.bind(this.store);
+  }
+
+  get setState() {
+    return this.store.setState.bind(this.store);
+  }
+
+  constructor(@unmanaged() stateCreator: StateCreator<State>) {
+    this.store = createStore(stateCreator);
+  }
+}

+ 100 - 0
packages/plugins/test-run-plugin/src/services/test-run.ts

@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { inject, injectable } from 'inversify';
+import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
+import type { FlowNodeEntity, FlowNodeType } from '@flowgram.ai/document';
+
+import { TestRunPipelineFactory } from './pipeline/factory';
+import { TestRunFormManager } from './form';
+import { FormSchema } from '../form-engine';
+import { TestRunPipelineEntity, type TestRunPipelineEntityOptions } from './pipeline';
+import { TestRunConfig } from './config';
+
+@injectable()
+export class TestRunService {
+  @inject(TestRunConfig) private readonly config: TestRunConfig;
+
+  @inject(TestRunPipelineFactory) private readonly pipelineFactory: TestRunPipelineFactory;
+
+  @inject(TestRunFormManager) readonly formManager: TestRunFormManager;
+
+  pipelineEntities = new Map<string, TestRunPipelineEntity>();
+
+  pipelineBindings = new Map<string, Disposable>();
+
+  onPipelineProgressEmitter = new Emitter();
+
+  onPipelineProgress = this.onPipelineProgressEmitter.event;
+
+  onPipelineFinishedEmitter = new Emitter();
+
+  onPipelineFinished = this.onPipelineFinishedEmitter.event;
+
+  public isEnabled(nodeType: FlowNodeType) {
+    const config = this.config.nodes[nodeType];
+    return config && config?.enabled !== false;
+  }
+
+  async toSchema(node: FlowNodeEntity) {
+    const nodeType = node.flowNodeType;
+    const config = this.config.nodes[nodeType];
+    if (!this.isEnabled(nodeType)) {
+      return {};
+    }
+    const properties =
+      typeof config.properties === 'function'
+        ? await config.properties({ node })
+        : config.properties;
+
+    return {
+      type: 'object',
+      properties,
+    };
+  }
+
+  createFormWithSchema(schema: FormSchema) {
+    const form = this.formManager.createForm();
+    form.init({ schema });
+    return form;
+  }
+
+  async createForm(node: FlowNodeEntity) {
+    const schema = await this.toSchema(node);
+    return this.createFormWithSchema(schema);
+  }
+
+  createPipeline(options: TestRunPipelineEntityOptions) {
+    const pipeline = this.pipelineFactory();
+    this.pipelineEntities.set(pipeline.id, pipeline);
+    pipeline.init(options);
+    return pipeline;
+  }
+
+  connectPipeline(pipeline: TestRunPipelineEntity) {
+    if (this.pipelineBindings.get(pipeline.id)) {
+      return;
+    }
+    const disposable = new DisposableCollection(
+      pipeline.onProgress(this.onPipelineProgressEmitter.fire.bind(this.onPipelineProgressEmitter)),
+      pipeline.onFinished(this.onPipelineFinishedEmitter.fire.bind(this.onPipelineFinishedEmitter))
+    );
+    this.pipelineBindings.set(pipeline.id, disposable);
+  }
+
+  disconnectPipeline(id: string) {
+    if (this.pipelineBindings.has(id)) {
+      const disposable = this.pipelineBindings.get(id);
+      disposable?.dispose();
+      this.pipelineBindings.delete(id);
+    }
+  }
+
+  disconnectAllPipeline() {
+    for (const id of this.pipelineBindings.keys()) {
+      this.disconnectPipeline(id);
+    }
+  }
+}

+ 29 - 0
packages/plugins/test-run-plugin/src/types.ts

@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import type { FlowNodeType, FlowNodeEntity } from '@flowgram.ai/document';
+
+import type { FormSchema, FormComponents } from './form-engine';
+
+export type MaybePromise<T> = T | Promise<T>;
+
+type PropertiesFunctionParams = {
+  node: FlowNodeEntity;
+};
+
+export interface NodeTestConfig {
+  /** Enable node TestRun */
+  enabled?: boolean;
+  /** Input schema properties */
+  properties?:
+    | Record<string, FormSchema>
+    | ((params: PropertiesFunctionParams) => MaybePromise<Record<string, FormSchema>>);
+}
+export type NodeMap = Record<FlowNodeType, NodeTestConfig>;
+
+export interface TestRunPluginConfig {
+  components?: FormComponents;
+  nodes?: NodeMap;
+}

+ 12 - 0
packages/plugins/test-run-plugin/tsconfig.json

@@ -0,0 +1,12 @@
+{
+  "extends": "@flowgram.ai/ts-config/tsconfig.flow.base.json",
+  "compilerOptions": {
+    "jsx": "react-jsx"
+  },
+  "include": [
+    "./src"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

+ 5 - 10
rush.json

@@ -472,16 +472,6 @@
             ],
             "versionPolicyName": "appPolicy"
         },
-        {
-            "packageName": "@flowgram.ai/demo-fixed-layout-animation",
-            "projectFolder": "apps/demo-fixed-layout-animation",
-            "tags": [
-                "level-1",
-                "team-flow",
-                "demo"
-            ],
-            "versionPolicyName": "appPolicy"
-        },
         {
             "packageName": "@flowgram.ai/utils",
             "projectFolder": "packages/common/utils",
@@ -536,6 +526,11 @@
                 "team-flow"
             ]
         },
+        {
+            "packageName": "@flowgram.ai/test-run-plugin",
+            "projectFolder": "packages/plugins/test-run-plugin",
+            "versionPolicyName": "publishPolicy"
+        },
         {
             "packageName": "@flowgram.ai/fixed-layout-core",
             "projectFolder": "packages/canvas-engine/fixed-layout-core",