chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
'react/no-deprecated': 'off',
'@flowgram.ai/e2e-data-testid': 'off',
'react/prop-types': 'off'
},
});
@@ -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": "^4.5.5",
"@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/"
}
}
@@ -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;
}
);
},
});
@@ -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>({});
@@ -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;
};
@@ -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>
);
@@ -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';
@@ -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>
);
@@ -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());
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { FormEngine, type FormEngineProps } from './form';
@@ -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';
@@ -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,
};
};
@@ -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);
@@ -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);
@@ -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';
@@ -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);
}
}
@@ -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;
}
@@ -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);
};
@@ -0,0 +1,23 @@
/**
* 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,
type TestRunConfig,
} from './services';
@@ -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';
@@ -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,
};
};
@@ -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);
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { useCreateForm, useTestRunService } from './hooks';
@@ -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,
};
};
@@ -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;
@@ -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();
}
}
@@ -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';
@@ -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);
}
}
}
@@ -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';
@@ -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;
@@ -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';
@@ -0,0 +1,155 @@
/**
* 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' | 'disposed';
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();
plugins: TestRunPipelinePlugin[] = [];
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);
this.plugins.push(plugin);
}
}
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() {
this.status = 'disposed';
this.plugins.forEach((p) => {
if (p.dispose) {
p.dispose();
}
});
this.onProgressEmitter.dispose();
this.onFinishedEmitter.dispose();
}
}
@@ -0,0 +1,13 @@
/**
* 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;
dispose?: () => void;
}
@@ -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;
}
}
@@ -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);
}
}
@@ -0,0 +1,108 @@
/**
* 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;
}
disposePipeline(id: string) {
const pipeline = this.pipelineEntities.get(id);
if (pipeline) {
this.pipelineEntities.delete(id);
pipeline.dispose();
}
}
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);
}
}
}
@@ -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;
}
@@ -0,0 +1,12 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.base.json",
"compilerOptions": {
"jsx": "react-jsx"
},
"include": [
"./src"
],
"exclude": [
"node_modules"
]
}