diff --git a/packages/toolbars/upload/package.json b/packages/toolbars/upload/package.json index 6680680b62..78ff8f7973 100644 --- a/packages/toolbars/upload/package.json +++ b/packages/toolbars/upload/package.json @@ -5,7 +5,9 @@ "access": "public" }, "scripts": { - "build": "vite build" + "build": "vite build", + "test": "vitest", + "test:unit": "vitest run" }, "type": "module", "main": "dist/index.js", @@ -35,7 +37,8 @@ "@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*", "@vitejs/plugin-vue": "^5.1.2", "@vitejs/plugin-vue-jsx": "^4.0.1", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^1.4.0" }, "peerDependencies": { "@opentiny/vue": "^3.20.0", diff --git a/packages/toolbars/upload/test/assetImport.test.ts b/packages/toolbars/upload/test/assetImport.test.ts new file mode 100644 index 0000000000..6100c1b578 --- /dev/null +++ b/packages/toolbars/upload/test/assetImport.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + buildImportedAssetCreatePayload, + buildImportedAssetResourceName, + findImportedAssetResource, + getImportedAssetResourceUrl, + getImportedAssets, + normalizeImportedAssetResourceList, + replaceImportedAssetPlaceholders, + splitImportedAssetCreatePayloads +} from '../src/assetImport' + +describe('asset import helpers', () => { + it('should keep only importable assets from an app schema', () => { + const valid = { placeholder: '__ASSET_1__', resourceData: 'data:image/png;base64,abc' } + const assets = getImportedAssets({ + assets: [valid, { placeholder: '__ASSET_2__' }, { resourceData: 'missing' }, null] + }) + + expect(assets).toEqual([valid]) + expect(getImportedAssets({ assets: [] })).toEqual([]) + expect(getImportedAssets(undefined)).toEqual([]) + }) + + it('should generate deterministic sanitized resource names and create payloads', () => { + const asset = { filePath: 'src/assets/app logo.JPG', resourceData: 'binary-data' } + const name = buildImportedAssetResourceName(asset) + + expect(name).toMatch(/^app_logo_[a-z0-9]+\.jpg$/) + expect(buildImportedAssetResourceName(asset)).toBe(name) + expect(buildImportedAssetCreatePayload(asset, 42, { appId: 'app-1', platformId: 'platform-1' })).toEqual({ + name, + description: asset.filePath, + resourceGroupId: 42, + resourceData: asset.resourceData, + resourceUrl: '', + category: 'image', + appId: 'app-1', + platformId: 'platform-1' + }) + }) + + it('should split upload payloads by batch count and serialized size', () => { + const payloads = [{ id: 1 }, { id: 2 }, { id: 3 }] + + expect(splitImportedAssetCreatePayloads(payloads, { maxBatchSize: 2 })).toEqual([ + [{ id: 1 }, { id: 2 }], + [{ id: 3 }] + ]) + expect(splitImportedAssetCreatePayloads(payloads, { maxPayloadSize: 1 })).toEqual([ + [{ id: 1 }], + [{ id: 2 }], + [{ id: 3 }] + ]) + expect(splitImportedAssetCreatePayloads([])).toEqual([]) + }) + + it('should replace asset placeholders recursively without changing non-string values', () => { + const schema = { + image: '__ASSET_1__', + nested: [{ css: 'url(__ASSET_1__)' }, { value: 1 }], + expression: { type: 'JSExpression', value: "'__ASSET_1__'" } + } + const replacements = new Map([['__ASSET_1__', 'https://cdn.test/logo.png']]) + + expect(replaceImportedAssetPlaceholders(schema, replacements)).toEqual({ + image: 'https://cdn.test/logo.png', + nested: [{ css: 'url(https://cdn.test/logo.png)' }, { value: 1 }], + expression: { type: 'JSExpression', value: "'https://cdn.test/logo.png'" } + }) + expect(replaceImportedAssetPlaceholders(schema, new Map())).toBe(schema) + }) + + it('should resolve resource URLs from supported response field names', () => { + expect(getImportedAssetResourceUrl({ resourceUrl: '/resource.png', url: '/fallback.png' })).toBe('/resource.png') + expect(getImportedAssetResourceUrl({ download_url: '/download.png' })).toBe('/download.png') + expect(getImportedAssetResourceUrl({})).toBe('') + expect(findImportedAssetResource([{ name: 'logo_hash.png' }], { name: 'logo_hash.png' })).toEqual({ + name: 'logo_hash.png' + }) + expect(findImportedAssetResource([{ description: 'src/logo.png' }], { description: 'src/logo.png' })).toEqual({ + description: 'src/logo.png' + }) + expect(findImportedAssetResource([], { name: 'missing' })).toBeNull() + }) + + it('should normalize resource list responses from common API envelopes', () => { + const list = [{ id: 1 }] + + expect(normalizeImportedAssetResourceList(list)).toBe(list) + expect(normalizeImportedAssetResourceList({ data: list })).toBe(list) + expect(normalizeImportedAssetResourceList({ data: { records: list } })).toBe(list) + expect(normalizeImportedAssetResourceList({ list })).toBe(list) + expect(normalizeImportedAssetResourceList({ data: {} })).toEqual([]) + }) +}) diff --git a/packages/toolbars/upload/test/blockImport.test.ts b/packages/toolbars/upload/test/blockImport.test.ts new file mode 100644 index 0000000000..21ab56884a --- /dev/null +++ b/packages/toolbars/upload/test/blockImport.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + buildImportedBlockEvents, + inferImportedBlockPropTypeFromValue, + normalizeImportedBlockDefaultValue, + normalizeImportedBlockPropName, + normalizeImportedBlockPropType, + resolveImportedBlockDefaultValue, + resolveImportedBlockPropType, + splitImportedBlockBindings, + toImportedBlockEventKey +} from '../src/blockImport' + +describe('block import helpers', () => { + it('should normalize kebab-case block prop names and event keys', () => { + expect(normalizeImportedBlockPropName(' model-value ')).toBe('modelValue') + expect(normalizeImportedBlockPropName('')).toBe('') + expect(toImportedBlockEventKey('save')).toBe('onSave') + expect(toImportedBlockEventKey('update:modelValue')).toBe('onUpdate:modelValue') + expect(toImportedBlockEventKey('onCancel')).toBe('onCancel') + }) + + it('should split block props and event bindings while ignoring structural keys', () => { + const result = splitImportedBlockBindings({ + 'model-value': 1, + disabled: true, + onSave: { type: 'JSExpression', value: 'save' }, + 'onUpdate:modelValue': 'update', + key: 'row-1', + ref: 'button' + }) + + expect(result).toEqual({ + props: { modelValue: 1, disabled: true }, + events: { onSave: { type: 'JSExpression', value: 'save' }, 'onUpdate:modelValue': 'update' } + }) + }) + + it('should build deduplicated event metadata from emits and bindings', () => { + const events = buildImportedBlockEvents(['save', 'update:modelValue', 'save'], { onCancel: true, ignored: true }) + + expect(Object.keys(events).sort()).toEqual(['onCancel', 'onSave', 'onUpdate:modelValue'].sort()) + expect(events.onSave).toEqual({ + name: 'onSave', + label: { zh_CN: 'onSave' }, + description: { zh_CN: 'onSave' } + }) + }) + + it('should normalize declared and inferred block property types', () => { + expect(normalizeImportedBlockPropType('Array')).toBe('array') + expect(normalizeImportedBlockPropType('string | null | undefined')).toBe('string') + expect(normalizeImportedBlockPropType('number | 1')).toBe('number') + expect(normalizeImportedBlockPropType('() => void')).toBe('function') + expect(normalizeImportedBlockPropType('Record')).toBe('object') + expect(normalizeImportedBlockPropType('unknown')).toBe('string') + expect(inferImportedBlockPropTypeFromValue([1])).toBe('array') + expect(inferImportedBlockPropTypeFromValue({})).toBe('object') + expect(inferImportedBlockPropTypeFromValue({ type: 'JSFunction', value: 'function() {}' })).toBe('function') + expect(resolveImportedBlockPropType('', false)).toBe('boolean') + expect(resolveImportedBlockPropType('Array', [])).toBe('array') + }) + + it('should resolve literal, dynamic and fallback default values by type', () => { + const dynamicNumber = { type: 'JSExpression', value: 'this.state.count' } + + expect(normalizeImportedBlockDefaultValue(null)).toBe('') + expect(normalizeImportedBlockDefaultValue(dynamicNumber)).toEqual(dynamicNumber) + expect(resolveImportedBlockDefaultValue(undefined, 'ready', 'string')).toBe('ready') + expect(resolveImportedBlockDefaultValue(undefined, dynamicNumber, 'number')).toBe(0) + expect(resolveImportedBlockDefaultValue(3, dynamicNumber, 'number')).toBe(3) + expect(resolveImportedBlockDefaultValue(undefined, undefined, 'boolean')).toBe(false) + expect(resolveImportedBlockDefaultValue(undefined, undefined, 'array')).toEqual([]) + }) +}) diff --git a/packages/toolbars/upload/test/http.test.ts b/packages/toolbars/upload/test/http.test.ts new file mode 100644 index 0000000000..c1866ff08c --- /dev/null +++ b/packages/toolbars/upload/test/http.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getMetaApi, getMergeMeta, META_SERVICE } from '@opentiny/tiny-engine-meta-register' +import { + batchCreateResource, + createBlock, + createBlockGroup, + createDataSource, + createResourceGroup, + createUtilsResource, + deployBlock, + fetchBlockByLabel, + fetchBlockGroups, + fetchDataSourceList, + fetchPageList, + fetchResourceGroups, + fetchResourceListByGroupId, + fetchUtilsResourceList, + updateAppConfig, + updateBlock, + updateDataSource, + updateUtilsResource +} from '../src/http' + +vi.mock('@opentiny/tiny-engine-meta-register', () => ({ + getMetaApi: vi.fn(), + getMergeMeta: vi.fn(), + callEntry: vi.fn((entry: any) => entry), + META_SERVICE: { GlobalService: 'GlobalService', Http: 'Http' } +})) + +describe('upload HTTP helpers', () => { + const http = { get: vi.fn(), post: vi.fn() } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getMetaApi).mockReturnValue(http as any) + vi.mocked(getMergeMeta).mockReturnValue({ platformId: 'platform-1' } as any) + vi.mocked(getMetaApi).mockImplementation((service: any) => { + if (service === META_SERVICE.GlobalService) { + return { getBaseInfo: () => ({ id: 'app-1' }) } as any + } + return http as any + }) + }) + + it('should request page and block lists with the expected query parameters', () => { + fetchPageList('app-1') + expect(http.get).toHaveBeenCalledWith('/app-center/api/pages/list/app-1') + + fetchBlockGroups({ page: 2, size: 10 }) + expect(http.get).toHaveBeenCalledWith('/material-center/api/block-groups', { + params: { page: 2, size: 10, from: 'block' } + }) + + fetchBlockByLabel('hero block') + expect(http.get).toHaveBeenCalledWith('/material-center/api/block?label=hero block') + }) + + it('should create, update and deploy blocks through the matching endpoints', () => { + const group = { name: 'Imported' } + const block = { label: 'Hero', content: {} } + const update = { content: { componentName: 'Block' } } + + createBlockGroup(group) + createBlock(block) + updateBlock('block-1', update, 'app-1') + deployBlock({ id: 'block-1' }) + + expect(http.post).toHaveBeenNthCalledWith(1, '/material-center/api/block-groups/create', group) + expect(http.post).toHaveBeenNthCalledWith(2, '/material-center/api/block/create', block) + expect(http.post).toHaveBeenNthCalledWith(3, '/material-center/api/block/update/block-1', update, { + params: { appId: 'app-1' } + }) + expect(http.post).toHaveBeenNthCalledWith(4, '/material-center/api/block/deploy', { id: 'block-1' }) + }) + + it('should expose utils and data source endpoints with app identifiers', () => { + fetchUtilsResourceList('app-1') + createUtilsResource({ name: 'format' }) + updateUtilsResource({ id: 'util-1', name: 'format' }) + fetchDataSourceList(7) + createDataSource({ name: 'users' }) + updateDataSource(9, { name: 'users' }) + updateAppConfig('app-1', { description: 'Imported' }) + + expect(http.get).toHaveBeenCalledWith('/app-center/api/apps/extension/list?app=app-1&category=utils') + expect(http.get).toHaveBeenCalledWith('/app-center/api/sources/list/7') + expect(http.post).toHaveBeenCalledWith('/app-center/api/apps/extension/create', { name: 'format' }) + expect(http.post).toHaveBeenCalledWith('/app-center/api/apps/extension/update', { id: 'util-1', name: 'format' }) + expect(http.post).toHaveBeenCalledWith('/app-center/api/sources/create', { name: 'users' }) + expect(http.post).toHaveBeenCalledWith('/app-center/api/sources/update/9', { name: 'users' }) + expect(http.post).toHaveBeenCalledWith('/app-center/api/apps/update/app-1', { description: 'Imported' }) + }) + + it('should include app and platform metadata for resource group and batch uploads', () => { + const group = { name: 'Imported assets', description: 'Images' } + const resources = [{ name: 'logo.png', resourceData: 'data:image/png;base64,abc' }] + + fetchResourceGroups() + createResourceGroup(group) + fetchResourceListByGroupId('group-1') + batchCreateResource(resources) + + expect(http.get).toHaveBeenCalledWith('/material-center/api/resource-group/app-1') + expect(http.get).toHaveBeenCalledWith('/material-center/api/resource/find/group-1') + expect(http.post).toHaveBeenCalledWith('/material-center/api/resource-group/create', { + ...group, + appId: 'app-1', + platformId: 'platform-1' + }) + expect(http.post).toHaveBeenCalledWith('/material-center/api/resource/create/batch', [ + { + ...resources[0], + appId: 'app-1', + platformId: 'platform-1' + } + ]) + }) +}) diff --git a/packages/toolbars/upload/test/schemaImport.test.ts b/packages/toolbars/upload/test/schemaImport.test.ts new file mode 100644 index 0000000000..1899d83cd7 --- /dev/null +++ b/packages/toolbars/upload/test/schemaImport.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { hydrateImportedAppSchemaState, normalizeImportedAppSchema, normalizeImportedSchema } from '../src/schemaImport' + +describe('schema import normalization', () => { + it('should remove block metadata from router components and normalize runtime helpers', () => { + const schema = { + children: [{ componentName: 'RouterLink', componentType: 'Block', props: {} }], + methods: { + navigate: { + type: 'JSFunction', + value: 'function navigate() { this.$router.push(this.$route.path); await nextTick() }' + } + } + } + + normalizeImportedSchema(schema) + + expect(schema.children[0].componentType).toBeUndefined() + expect(schema.methods.navigate.value).toContain('this.router.push(this.route.path)') + expect(schema.methods.navigate.value).toContain('await Promise.resolve()') + }) + + it('should infer computed defaults and rebuild computed getters', () => { + const schema = { + state: { label: { accessor: { getter: { type: 'JSFunction', value: 'function getter() {}' } } } }, + computed: { label: { type: 'JSFunction', value: 'function label() { return "ready" }' } } + } + + normalizeImportedSchema(schema) + + expect(schema.state.label.defaultValue).toBe('ready') + expect(schema.state.label.accessor.getter.value).toBe('function getter() { this.state.label = "ready" }') + }) + + it('should replace template refs and remove their temporary state entries', () => { + const schema = { + state: { button: { defaultValue: null } }, + methods: { focus: { type: 'JSFunction', value: 'function focus() { this.state.button.focus() }' } }, + children: [{ componentName: 'Button', props: { ref: 'button' }, children: [] }] + } + + normalizeImportedSchema(schema) + + expect(schema.methods.focus.value).toContain("this.$('button').focus()") + expect(schema.state.button).toBeUndefined() + }) + + it('should convert icon state references and remove unreferenced icon state', () => { + const schema = { + state: { TinyIconPanelMini: { type: 'JSExpression', value: 'iconPanelMini()' } }, + children: [ + { + componentName: 'div', + props: { icon: { type: 'JSExpression', value: 'this.state.TinyIconPanelMini' } }, + children: [] + } + ] + } + + normalizeImportedSchema(schema) + + expect(schema.children[0].props.icon).toEqual({ componentName: 'Icon', props: { name: 'IconPanelMini' } }) + expect(schema.state.TinyIconPanelMini).toBeUndefined() + }) + + it('should wrap multiple roots in a slot with a container node', () => { + const schema = { + children: [ + { + type: 'JSSlot', + value: [ + { componentName: 'span', props: {} }, + { componentName: 'span', props: {} } + ] + } + ] + } + + normalizeImportedSchema(schema) + + expect(schema.children[0].value).toHaveLength(1) + expect(schema.children[0].value[0]).toMatchObject({ componentName: 'div', children: expect.any(Array) }) + expect(schema.children[0].value[0].children).toHaveLength(2) + }) + + it('should normalize pages and blocks in an app schema in place', () => { + const page = { state: {}, children: [{ componentName: 'RouterView', componentType: 'Block' }] } + const block = { state: {}, children: [{ componentName: 'RouterLink', componentType: 'Block' }] } + const appSchema = { pageSchema: [page], blockSchemas: [block] } + + expect(normalizeImportedAppSchema(appSchema)).toBe(appSchema) + expect(page.children[0].componentType).toBeUndefined() + expect(block.children[0].componentType).toBeUndefined() + }) +}) + +describe('imported schema state hydration', () => { + it('should execute a page mounted hook and write the resulting state back', async () => { + const appSchema = { + pageSchema: [ + { + state: { count: 0 }, + lifeCycles: { onMounted: { type: 'JSFunction', value: 'function onMounted() { this.state.count = 3 }' } } + } + ] + } + + await hydrateImportedAppSchemaState(appSchema) + + expect(appSchema.pageSchema[0].state.count).toBe(3) + }) + + it('should ignore missing app schemas and pages', async () => { + expect(await hydrateImportedAppSchemaState(undefined)).toBeUndefined() + expect(await hydrateImportedAppSchemaState({ pageSchema: [] })).toEqual({ pageSchema: [] }) + }) +}) diff --git a/packages/vue-to-dsl/test/converter/converter-edge.test.js b/packages/vue-to-dsl/test/converter/converter-edge.test.js new file mode 100644 index 0000000000..28a5272425 --- /dev/null +++ b/packages/vue-to-dsl/test/converter/converter-edge.test.js @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest' +import JSZip from 'jszip' +import path from 'node:path' +import { VueToDslConverter } from '../../src/converter' + +describe('VueToDslConverter edge cases', () => { + it('should return a conversion error when the source has no template or script', async () => { + const converter = new VueToDslConverter() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await converter.convertFromString('') + warn.mockRestore() + + expect(result.schema).toBeNull() + expect(result.dependencies).toEqual([]) + expect(result.errors[0]).toContain('Invalid Vue SFC') + }) + + it('should deduplicate script dependencies and remove the Vue extension from the file name', async () => { + const converter = new VueToDslConverter() + const result = await converter.convertFromString( + ` + + + `, + 'Greeting.vue' + ) + + expect(result.errors).toHaveLength(0) + expect(result.dependencies).toEqual(['vue', '@opentiny/vue']) + expect(result.schema).toMatchObject({ componentName: 'Page', fileName: 'Greeting', meta: { name: 'Greeting' } }) + expect(result.schema.state.message).toBe('hello') + }) + + it('should use custom parsers and preserve their output in the generated schema', async () => { + const calls = [] + const converter = new VueToDslConverter({ + customParsers: { + template: { + parse: (source) => { + calls.push(['template', source.trim()]) + return [{ componentName: 'Custom', props: {}, children: [] }] + } + }, + script: { + parse: (source) => { + calls.push(['script', source.trim()]) + return { + imports: [], + state: { value: { type: 'ref', value: 'ref(7)' } }, + methods: {}, + computed: {}, + lifeCycles: {} + } + } + }, + style: { + parse: (source) => { + calls.push(['style', source.trim()]) + return { css: source.trim() } + } + } + }, + computed_flag: true + }) + const result = await converter.convertFromString( + '', + 'Custom.vue' + ) + + expect(result.errors).toHaveLength(0) + expect(calls.map(([name]) => name)).toEqual(['script', 'template', 'style']) + expect(result.schema).toMatchObject({ + fileName: 'Custom', + state: { value: 7 }, + css: '.custom { color: red; }', + children: [{ componentName: 'Custom' }] + }) + }) + + it('should return parser errors in non-strict mode and a null schema in strict mode', async () => { + const source = '' + const parserOptions = { + customParsers: { + script: { + parse: () => { + throw new Error('script failed') + } + } + } + } + + const nonStrict = await new VueToDslConverter(parserOptions).convertFromString(source) + expect(nonStrict.errors).toEqual([expect.stringContaining('Script parsing error: script failed')]) + expect(nonStrict.schema).toBeDefined() + + const strict = await new VueToDslConverter({ ...parserOptions, strictMode: true }).convertFromString(source) + expect(strict.schema).toBeNull() + expect(strict.errors[0]).toContain('Script parsing error: script failed') + }) + + it('should report file read failures and keep result ordering for multiple files', async () => { + const converter = new VueToDslConverter() + const missing = path.join(process.cwd(), 'test', 'missing-component.vue') + const existing = path.join(process.cwd(), 'test', 'testcases', '001_simple', 'input', 'component.vue') + const result = await converter.convertFromFile(missing) + const multiple = await converter.convertMultipleFiles([missing, existing]) + + expect(result.schema).toBeNull() + expect(result.errors[0]).toContain('File reading error') + expect(multiple).toHaveLength(2) + expect(multiple[0].schema).toBeNull() + expect(multiple[1].schema).toBeDefined() + }) + + it('should reject malformed ZIP buffers instead of returning a partial schema', async () => { + const converter = new VueToDslConverter() + + await expect(converter.convertAppFromZip(new Uint8Array([0, 1, 2, 3]))).rejects.toThrow() + }) + + it('should convert a browser ZIP buffer without accessing the file system', async () => { + const zip = new JSZip() + zip.file('demo/src/views/Home.vue', '') + zip.file( + 'demo/src/router/index.js', + "export default [{ name: 'Home', path: '/home', component: () => import('../views/Home.vue') }]" + ) + const buffer = await zip.generateAsync({ type: 'uint8array' }) + const originalWindow = globalThis.window + globalThis.window = { document: {} } + + try { + const schema = await new VueToDslConverter().convertAppFromZip(buffer) + + expect(schema.pageSchema).toHaveLength(1) + expect(schema.pageSchema[0]).toMatchObject({ fileName: 'Home', meta: { router: 'home' } }) + } finally { + globalThis.window = originalWindow + } + }) + + it('should merge updated options while retaining normalized defaults', () => { + const converter = new VueToDslConverter({ computed_flag: false }) + + expect(converter.getOptions()).toMatchObject({ computed_flag: false, strictMode: false }) + expect(converter.getOptions().componentMap).toBeDefined() + + converter.setOptions({ computed_flag: true, fileName: 'demo' }) + + expect(converter.getOptions()).toMatchObject({ computed_flag: true, fileName: 'demo', strictMode: false }) + expect(converter.getOptions().componentMap).toBeDefined() + }) + + it('should convert a browser-style file list and honor its gitignore entries', async () => { + const originalFileReader = globalThis.FileReader + class TestFileReader { + readAsText(file) { + this.result = file.content + this.onload?.() + } + } + + globalThis.FileReader = TestFileReader + const file = (relativePath, content) => ({ + webkitRelativePath: `demo/${relativePath}`, + content + }) + const files = [ + file('.gitignore', 'ignored.vue'), + file('ignored.vue', ''), + file('src/views/Home.vue', ''), + file( + 'src/router/index.js', + "export default [{ name: 'Home', path: '/home', component: () => import('../views/Home.vue') }]" + ) + ] + + try { + const schema = await new VueToDslConverter().convertAppFromDirectory(files) + + expect(schema.pageSchema).toHaveLength(1) + expect(schema.pageSchema[0]).toMatchObject({ fileName: 'Home', meta: { router: 'home' } }) + } finally { + globalThis.FileReader = originalFileReader + } + }) +}) diff --git a/packages/vue-to-dsl/test/converter/converter-integration.test.js b/packages/vue-to-dsl/test/converter/converter-integration.test.js new file mode 100644 index 0000000000..a8e7cf287b --- /dev/null +++ b/packages/vue-to-dsl/test/converter/converter-integration.test.js @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { VueToDslConverter } from '../../src/converter' + +let appRoot + +async function createAppFixture() { + appRoot = await mkdtemp(path.join(os.tmpdir(), 'tiny-engine-vue-to-dsl-')) + + const files = { + 'src/views/Home.vue': ` + + + + `, + 'src/components/LocalCard.vue': ` + + + `, + 'src/utils.js': `export function formatName(value) { return value.trim() }`, + 'src/router/index.js': ` + export default [{ + name: 'Home', + path: '/home', + component: () => import('../views/Home.vue') + }] + `, + 'src/i18n/en_US.json': JSON.stringify({ home: { title: 'Home' }, items: ['one', 'two'] }), + 'src/i18n/zh_CN.json': JSON.stringify({ home: { title: '首页' } }), + 'src/lowcodeConfig/dataSource.json': JSON.stringify({ list: [{ name: 'users', type: 'array' }] }), + 'src/stores/user.js': ` + import { defineStore } from 'pinia' + export const useUserStore = defineStore('user', { + state: () => ({ token: 'abc', count: 0 }) + }) + ` + } + + await Promise.all( + Object.entries(files).map(async ([relativePath, content]) => { + const target = path.join(appRoot, ...relativePath.split('/')) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, content, 'utf8') + }) + ) + + const assetPath = path.join(appRoot, 'src', 'assets', 'logo.png') + await mkdir(path.dirname(assetPath), { recursive: true }) + await writeFile(assetPath, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + + return appRoot +} + +afterEach(async () => { + if (appRoot) { + await rm(appRoot, { recursive: true, force: true }) + appRoot = undefined + } +}) + +describe('VueToDslConverter app integration', () => { + it('should merge routes, i18n, data sources, stores, local blocks, utils and assets', async () => { + const converter = new VueToDslConverter({ computed_flag: true }) + const schema = await converter.convertAppDirectory(await createAppFixture()) + + expect(schema.pageSchema).toHaveLength(1) + expect(schema.pageSchema[0].meta).toMatchObject({ router: 'home', isPage: true, isHome: false }) + expect(schema.pageSchema[0].children[0].children[0]).toMatchObject({ + componentName: 'LocalCard', + componentType: 'Block' + }) + + expect(schema.blockSchemas).toHaveLength(1) + expect(schema.blockSchemas[0]).toMatchObject({ componentName: 'Block', fileName: 'LocalCard' }) + expect(schema.i18n).toEqual({ + en_US: { 'home.title': 'Home', 'items.0': 'one', 'items.1': 'two' }, + zh_CN: { 'home.title': '首页' } + }) + expect(schema.dataSource).toEqual({ list: [{ name: 'users', type: 'array' }] }) + expect(schema.globalState).toEqual([{ id: 'user', state: { token: 'abc', count: 0 }, getters: {}, actions: {} }]) + expect(schema.utils.some((item) => item.name === 'formatName')).toBe(true) + expect(schema.assets).toHaveLength(1) + expect(schema.assets[0]).toMatchObject({ + filePath: 'src/assets/logo.png', + name: 'logo.png', + resourceData: 'data:image/png;base64,iVBORw0KGgo=' + }) + expect(schema.pageSchema[0].css).toContain('__TE_IMPORTED_ASSET_1__') + }) +}) diff --git a/packages/vue-to-dsl/test/generator/generator.test.js b/packages/vue-to-dsl/test/generator/generator.test.js new file mode 100644 index 0000000000..21667c254a --- /dev/null +++ b/packages/vue-to-dsl/test/generator/generator.test.js @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { generateAppSchema, generateSchema } from '../../src/generator/index' + +describe('schema generator', () => { + it('should generate a page schema with transformed state, computed values and stable node ids', async () => { + const schema = await generateSchema( + [ + { + componentName: 'div', + props: { className: 'page' }, + children: [{ componentName: 'Text', props: { text: 'Hello' }, children: [] }] + } + ], + { + state: { + count: { type: 'ref', value: 'ref(2)' }, + settings: { type: 'reactive', value: { enabled: true } } + }, + computed: { + doubled: { type: 'computed', value: 'function doubled() { return this.state.count * 2 }' } + }, + methods: { save: { type: 'function', value: 'function save() {}' } }, + lifeCycles: { onMounted: { type: 'lifecycle', value: 'function onMounted() {}' } }, + props: [{ name: 'title', type: 'string', required: true }], + emits: ['save'] + }, + { css: '.page { color: red; }' }, + { fileName: 'home', computed_flag: true } + ) + + expect(schema).toMatchObject({ + componentName: 'Page', + fileName: 'home', + meta: { name: 'Home' }, + state: { + count: 2, + settings: { enabled: true }, + doubled: { accessor: { getter: { type: 'JSFunction' } } } + }, + methods: { save: { type: 'JSFunction', value: 'function save() {}' } }, + lifeCycles: { onMounted: { type: 'JSFunction', value: 'function onMounted() {}' } }, + props: [{ name: 'title', type: 'string', required: true }], + emits: ['save'], + css: '.page { color: red; }' + }) + expect(schema.computed.doubled).toEqual({ + type: 'JSFunction', + value: expect.stringContaining('function doubled()') + }) + expect(schema.id).toMatch(/^[a-z0-9]{8}$/) + expect(schema.children[0].id).toMatch(/^[a-z0-9]{8}$/) + expect(schema.children[0].children[0].id).toMatch(/^[a-z0-9]{8}$/) + }) + + it('should omit the computed section while retaining computed state when the flag is disabled', async () => { + const schema = await generateSchema( + [], + { computed: { label: { value: 'function label() { return "ready" }' } } }, + {}, + { fileName: 'status' } + ) + + expect(schema.state.label).toMatchObject({ defaultValue: 'ready' }) + expect(schema.computed).toBeUndefined() + }) + + it('should generate block schemas, preserve existing ids and fill fallback entries', async () => { + const schema = await generateSchema( + [ + { + componentName: 'section', + id: 'fixed-id', + props: {}, + children: [{ componentName: 'span', id: 'child-id', props: {}, children: [] }] + } + ], + { + state: { + title: { type: 'normal', value: 'Card' }, + enabled: { type: 'ref', value: 'ref(false)' } + }, + methods: { submit: {} }, + lifeCycles: { mounted: {} }, + props: ['title'] + }, + null, + { isBlock: true, fileName: 'card' } + ) + + expect(schema).toMatchObject({ + componentName: 'Block', + fileName: 'card', + state: { title: 'Card', enabled: false }, + methods: { submit: { type: 'JSFunction', value: 'function() { /* method implementation */ }' } }, + lifeCycles: { mounted: { type: 'JSFunction', value: 'function() { /* lifecycle hook */ }' } }, + props: [{ name: 'title', type: 'any', default: undefined }] + }) + expect(schema.id).toMatch(/^[a-z0-9]{8}$/) + expect(schema.children[0].id).toBe('fixed-id') + expect(schema.children[0].children[0].id).toBe('child-id') + }) + + it('should provide fallback output for incomplete computed, lifecycle and prop entries', async () => { + const schema = await generateSchema( + [], + { + computed: { broken: {} }, + lifeCycles: { onMounted: 'function onMounted() {}' }, + props: [42] + }, + {}, + { fileName: 'fallback', computed_flag: true } + ) + + expect(schema.state.broken).toMatchObject({ defaultValue: undefined, accessor: { getter: { type: 'JSFunction' } } }) + expect(schema.computed.broken).toMatchObject({ type: 'JSFunction' }) + expect(schema.lifeCycles.onMounted).toEqual({ type: 'JSFunction', value: 'function onMounted() {}' }) + expect(schema.props).toEqual([42]) + }) +}) + +describe('app schema generator', () => { + it('should apply default app fields and normalize leading router slashes', () => { + const pages = [{ fileName: 'Home', meta: { router: '/home' } }] + const schema = generateAppSchema(pages) + + expect(pages[0].meta.router).toBe('home') + expect(schema).toMatchObject({ + meta: { name: 'Generated App', description: 'App generated from Vue SFC files' }, + i18n: { en_US: {}, zh_CN: {} }, + utils: [], + assets: [], + dataSource: { list: [] }, + globalState: [], + pageSchema: pages, + blockSchemas: [] + }) + expect(schema.componentsMap.length).toBeGreaterThan(0) + }) + + it('should preserve explicitly supplied app collections and metadata', () => { + const pages = [{ fileName: 'Home', meta: { router: '/home' } }] + const blocks = [{ componentName: 'Block', fileName: 'Card' }] + const schema = generateAppSchema(pages, { + name: 'Demo', + description: 'Demo app', + i18n: { en_US: { hello: 'Hello' }, zh_CN: {} }, + utils: [{ name: 'format' }], + assets: [{ name: 'logo.png' }], + dataSource: { list: [{ name: 'users' }] }, + globalState: [{ id: 'user' }], + blockSchemas: blocks, + componentsMap: [{ componentName: 'CustomCard' }] + }) + + expect(schema).toEqual({ + meta: { name: 'Demo', description: 'Demo app' }, + i18n: { en_US: { hello: 'Hello' }, zh_CN: {} }, + utils: [{ name: 'format' }], + assets: [{ name: 'logo.png' }], + dataSource: { list: [{ name: 'users' }] }, + globalState: [{ id: 'user' }], + pageSchema: pages, + blockSchemas: blocks, + componentsMap: [{ componentName: 'CustomCard' }] + }) + }) +}) diff --git a/packages/vue-to-dsl/test/parsers/parser.test.js b/packages/vue-to-dsl/test/parsers/parser.test.js new file mode 100644 index 0000000000..00773d4e5c --- /dev/null +++ b/packages/vue-to-dsl/test/parsers/parser.test.js @@ -0,0 +1,418 @@ +import { describe, expect, it, vi } from 'vitest' +import { getSFCMeta, parseSFC, parseVueFile, validateSFC } from '../../src/parser/index' +import { + parseCSSRules, + extractCSSVariables, + extractMediaQueries, + hasMediaQueries, + parseStyle +} from '../../src/parsers/styleParser' +import { parseScript } from '../../src/parsers/scriptParser' +import { parseTemplate } from '../../src/parsers/templateParser' + +describe('SFC parser helpers', () => { + it('should collect template, script, style and custom block metadata from an SFC', () => { + const result = parseSFC(` + + + + {"name":"home"} + `) + + expect(validateSFC(result)).toBe(true) + expect(result.template).toContain('
Hello
') + expect(result.templateLang).toBe('html') + expect(result.scriptSetupLang).toBe('ts') + expect(result.styleBlocks).toEqual([{ content: 'main { color: red; }', lang: 'scss', scoped: true, module: false }]) + expect(result.customBlocks).toEqual([{ type: 'route', content: '{"name":"home"}', attrs: { lang: 'json' } }]) + + expect(getSFCMeta(result)).toMatchObject({ + hasTemplate: true, + hasScriptSetup: true, + hasScript: false, + hasStyle: true, + templateLang: 'html', + scriptLang: 'ts' + }) + }) + + it('should reject an SFC that only contains style or custom blocks', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(validateSFC(parseSFC(''))).toBe(false) + expect(validateSFC({})).toBe(false) + } finally { + warn.mockRestore() + } + }) +}) + +describe('CSS parser helpers', () => { + it('should parse declarations, variables and media rules', () => { + const css = ` + :root { --brand-color: #123456; --spacing: 8px; } + .button { color: var(--brand-color); padding: var(--spacing); } + @media (min-width: 600px) { .button { padding: 12px; } } + ` + + expect(parseStyle(' .button { color: red; } ', { scoped: true, lang: 'scss' })).toEqual({ + css: '.button { color: red; }', + scoped: true, + lang: 'scss' + }) + expect(parseStyle(' ')).toEqual({ css: '', scoped: false, lang: 'css' }) + expect(parseCSSRules('.button { color: red; padding: 0; }')).toEqual([ + { selector: '.button', declarations: { color: 'red', padding: '0' } } + ]) + expect(extractCSSVariables(css)).toEqual({ '--brand-color': '#123456', '--spacing': '8px' }) + expect(hasMediaQueries(css)).toBe(true) + expect(hasMediaQueries('.button { color: red; }')).toBe(false) + expect(extractMediaQueries(css)).toEqual([ + { + condition: '(min-width: 600px)', + content: '.button { padding: 12px; }', + rules: [{ selector: '.button', declarations: { padding: '12px' } }] + } + ]) + }) + + it('should return empty values for missing CSS input', () => { + expect(parseCSSRules('')).toEqual([]) + expect(extractCSSVariables('')).toEqual({}) + expect(extractMediaQueries('')).toEqual([]) + }) + + it('should read a Vue file from disk before parsing its SFC metadata', async () => { + const filePath = `${__dirname}/../testcases/001_simple/input/component.vue` + const result = await parseVueFile(filePath) + + expect(result.template).toContain(' { + const options = { + state: { visible: {}, items: {}, form: {} }, + methods: { select: {} }, + componentMap: { 'custom-card': 'CustomCard' } + } + + it('should map directives, loops and interpolations to DSL nodes', () => { + const nodes = parseTemplate( + ` +
+

{{ message }}

+

Empty

+
  • {{ index }}: {{ item.name }}
+ +
+ `, + options + ) + + const root = nodes[0] + expect(root.componentName).toBe('div') + expect(root.props.className).toMatchObject({ type: 'JSExpression' }) + expect(root.props.className.value).toContain('this.state.visible') + expect(root.children[0]).toMatchObject({ + componentName: 'p', + condition: { type: 'JSExpression', value: 'this.state.visible' } + }) + expect(root.children[1]).toMatchObject({ + componentName: 'p', + condition: { type: 'JSExpression', value: '!(this.state.visible)' } + }) + + const listItem = root.children.find((item) => item.componentName === 'ul').children[0] + expect(listItem.loopArgs).toEqual(['item', 'index']) + expect(listItem.loop).toEqual({ type: 'JSExpression', value: 'this.state.items' }) + expect(listItem.props.onClick.value).toBe('this.select(item)') + expect(listItem.children[0].props.text.value).toContain('index') + + const input = root.children.find((item) => item.componentName === 'input') + expect(input.props.modelValue).toEqual({ + type: 'JSExpression', + value: 'this.state.form.name', + model: true + }) + }) + + it('should normalize custom component names and literal bindings', () => { + const [node] = parseTemplate('', options) + + expect(node.componentName).toBe('CustomCard') + expect(node.props).toMatchObject({ count: 2, disabled: true }) + }) + + it('should mark imported components as blocks and preserve slot metadata', () => { + const [node] = parseTemplate( + '{{ row.name }}', + { + ...options, + imports: [{ specifiers: [{ local: 'CustomCard' }] }] + } + ) + + expect(node).toMatchObject({ componentName: 'CustomCard', componentType: 'Block' }) + expect(node.props.className).toEqual({ + type: 'JSExpression', + value: "['base', ['active', { selected: this.state.visible }]]" + }) + expect(node.props['v-show']).toEqual({ type: 'JSExpression', value: 'this.state.visible' }) + expect(node.props.slot).toEqual({ name: 'item', params: ['row'] }) + expect(node.children[0].props.text.value).toBe('row.name') + }) + + it('should normalize TinyGrid columns into named JSSlots', () => { + const [grid] = parseTemplate( + ` + + + + + + `, + options + ) + + expect(grid.componentName).toBe('TinyGrid') + expect(grid.children).toHaveLength(0) + expect(grid.props.columns).toEqual([ + { + field: 'name', + slots: { + default: { + type: 'JSSlot', + params: ['row'], + value: [ + { + componentName: 'span', + props: {}, + children: [{ componentName: 'Text', props: { text: { type: 'JSExpression', value: 'row.name' } } }] + } + ] + } + } + } + ]) + }) + + it('should combine conditions for if, else-if and else branches', () => { + const nodes = parseTemplate( + '
Visible
Loading
Empty
', + options + ) + + expect(nodes).toHaveLength(3) + expect(nodes[0].condition).toEqual({ type: 'JSExpression', value: 'this.state.visible' }) + expect(nodes[1].condition.value).toContain('this.state.loading') + expect(nodes[1].condition.value).toContain('this.state.visible') + expect(nodes[2].condition.value).toContain('this.state.visible') + expect(nodes[2].condition.value).toContain('this.state.loading') + }) + + it('should merge normalized grid columns with an existing literal columns prop', () => { + const [grid] = parseTemplate( + `Name`, + options + ) + + expect(grid.props.columns).toEqual([ + { field: 'id' }, + { + field: 'name', + slots: { + default: { + type: 'JSSlot', + params: [], + value: [{ componentName: 'Text', props: { text: 'Name' } }] + } + } + } + ]) + expect(grid.children).toEqual([]) + }) + + it('should return no nodes for comments and empty templates', () => { + expect(parseTemplate('', options)).toEqual([]) + expect(parseTemplate(' ', options)).toEqual([]) + }) +}) + +describe('script parser', () => { + it('should extract setup state, props, emits, methods, computed values and runtime aliases', () => { + const result = parseScript( + ` + import { ref, reactive, computed, onMounted } from 'vue' + import { useRouter } from 'vue-router' + const count = ref(1) + const state = reactive({ name: 'Alice' }) + const doubled = computed(() => count.value * 2) + const router = useRouter() + const props = defineProps({ title: { type: String, required: true }, size: Number }) + const emit = defineEmits(['save']) + function save(value: string) { emit('save', value) } + onMounted(() => { console.log(count.value) }) + `, + { isSetup: true } + ) + + expect(result.imports).toEqual([ + { + source: 'vue', + specifiers: [ + { local: 'ref', imported: 'ref', kind: 'named' }, + { local: 'reactive', imported: 'reactive', kind: 'named' }, + { local: 'computed', imported: 'computed', kind: 'named' }, + { local: 'onMounted', imported: 'onMounted', kind: 'named' } + ] + }, + { source: 'vue-router', specifiers: [{ local: 'useRouter', imported: 'useRouter', kind: 'named' }] } + ]) + expect(result.state.count).toEqual({ type: 'ref', value: 1 }) + expect(result.state.name).toEqual({ type: 'reactive', value: 'Alice' }) + expect(result.computed.doubled.value).toContain('function doubled()') + expect(result.methods.save.value).toContain('function save(value)') + expect(result.methods.save.value).not.toContain(': string') + expect(result.lifeCycles.onMounted.value).toContain('function onMounted()') + expect(result.runtimeAliases.router).toEqual(['router']) + expect(result.props).toEqual([ + { name: 'title', type: 'string', required: true }, + { name: 'size', type: 'number' } + ]) + expect(result.emits).toEqual(['save']) + }) + + it('should report syntax errors without throwing', () => { + const result = parseScript('const = invalid', { isSetup: true }) + + expect(result.error).toBeTruthy() + expect(result.state).toEqual({}) + expect(result.methods).toEqual({}) + }) + + it('should parse TypeScript props, withDefaults and typed emits', () => { + const result = parseScript( + ` + type FormProps = { + title?: string + count: number + enabled?: boolean + } + const props = withDefaults(defineProps(), { + title: 'Untitled', + enabled: true + }) + const emit = defineEmits<{ + (event: 'save', id: number): void + cancel: [] + }>() + `, + { isSetup: true } + ) + + expect(result.props).toEqual([ + { name: 'title', type: 'string', required: false, default: 'Untitled' }, + { name: 'count', type: 'number', required: true }, + { name: 'enabled', type: 'boolean', required: false, default: true } + ]) + expect(result.emits).toEqual(['save', 'cancel']) + }) + + it('should rewrite router aliases, nextTick and imported utility references', () => { + const result = parseScript( + ` + import { ref, nextTick as tick } from 'vue' + import { useRouter as useAppRouter } from 'vue-router' + import { formatName } from './utils' + const router = useAppRouter() + const count = ref(0) + async function save() { + await tick() + router.push(formatName(String(count.value))) + } + `, + { isSetup: true } + ) + + expect(result.runtimeAliases.router).toEqual(['router']) + expect(result.runtimeAliases.nextTick).toEqual([]) + expect(result.methods.save.value).toContain('await Promise.resolve()') + expect(result.methods.save.value).toContain('this.router.push(this.utils.formatName(String(this.state.count)))') + expect(result.usedUtilsImports).toEqual([ + { source: './utils', imported: 'formatName', local: 'formatName', kind: 'named' } + ]) + }) + + it('should parse Options API props, data, methods, computed values and lifecycle hooks', () => { + const result = parseScript(` + export default { + props: { + title: { type: String, default: 'Untitled' }, + count: Number + }, + data() { + return { count: 1, user: { name: 'Ada' } } + }, + methods: { + save: (value) => value.trim() + }, + computed: { + label() { return this.user.name } + }, + mounted() { this.save(this.title) } + } + `) + + expect(result.props).toEqual([ + { name: 'title', type: 'string', default: 'Untitled' }, + { name: 'count', type: 'number' } + ]) + expect(result.state).toEqual({ + count: { type: 'reactive', value: 1 }, + user: { type: 'reactive', value: { name: 'Ada' } } + }) + expect(result.methods.save.value).toContain('function save(value)') + expect(result.computed.label.value).toContain('function label()') + expect(result.lifeCycles.mounted.value).toContain('function mounted()') + }) + + it('should convert h-rendered slot values and namespace utility calls', () => { + const result = parseScript( + ` + import { h } from 'vue' + import * as utils from './utils' + const slots = { + default: ({ row }) => h('span', { class: 'cell' }, row.name) + } + const save = () => utils.format('ready') + `, + { isSetup: true } + ) + + expect(result.state.slots.value.default).toMatchObject({ + type: 'JSSlot', + params: ['row'], + value: [ + { componentName: 'span', props: { className: 'cell' }, children: { type: 'JSExpression', value: 'row.name' } } + ] + }) + expect(result.methods.save.value).toContain('this.utils.format') + expect(result.usedUtilsImports).toEqual([{ source: './utils', imported: 'format', local: 'format', kind: 'named' }]) + }) + + it('should parse standalone TypeScript defineProps and defineEmits calls', () => { + const result = parseScript( + ` + type Props = { title: string } + defineProps() + defineEmits<{ (event: 'submit', id: number): void }>() + `, + { isSetup: true } + ) + + expect(result.props).toEqual([{ name: 'title', type: 'string', required: true }]) + expect(result.emits).toEqual(['submit']) + }) +})