diff --git a/package.json b/package.json index 93fb9c28..dd6c2e65 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "@compodium/examples": "workspace:^", "@compodium/meta": "workspace:^", "@compodium/nuxt": "workspace:^", - "@compodium/vue": "workspace:^" + "@compodium/vue": "workspace:^", + "@compodium/testing": "workspace:^" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/core/build.config.ts b/packages/core/build.config.ts index 5e4c37ff..f9e289ff 100644 --- a/packages/core/build.config.ts +++ b/packages/core/build.config.ts @@ -8,7 +8,6 @@ export default defineBuildConfig({ { builder: 'copy', input: './src/runtime', outDir: './dist/runtime' } ], replace: { - 'process.env.COMPODIUM_TEST': 'false', 'process.env.COMPODIUM_DEVTOOLS_URL': undefined } }) diff --git a/packages/core/package.json b/packages/core/package.json index f98d9dbb..e5f94ac5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,11 +33,13 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { + "@compodium/testing": "workspace:^", "vite": ">=6", + "vitest": ">=3.2.4", "vue": ">=3" }, "peerDependenciesMeta": { - "vite": { + "@compodium/testing": { "optional": true } }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 97623298..6d1ff5e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +import type { Plugin } from 'vite' import { collectionsPlugin } from './plugins/collections' import { extendMetaPlugin, metaPlugin } from './plugins/meta' import { examplePlugin } from './plugins/examples' @@ -8,8 +9,8 @@ import type { PluginOptions } from './types' export * from './types' -export const compodium = /* #__PURE__ */ (options: PluginOptions) => { - return [ +export const compodium = /* #__PURE__ */ async (options: PluginOptions) => { + const plugins: Plugin[] = [ collectionsPlugin(options), metaPlugin(options), extendMetaPlugin(options), @@ -18,4 +19,17 @@ export const compodium = /* #__PURE__ */ (options: PluginOptions) => { iconifyPlugin(options), colorsPlugin(options) ] + + if (options.testing?.enabled) { + try { + const { compodiumTesting } = await import('@compodium/testing') + compodiumTesting(options).forEach(p => plugins.push(p)) + } catch { + throw new Error( + 'The `@compodium/testing` package is required when tests are enabled.' + ) + } + } + + return plugins } diff --git a/packages/core/src/plugins/collections.ts b/packages/core/src/plugins/collections.ts index c15eaf42..ba8054ed 100644 --- a/packages/core/src/plugins/collections.ts +++ b/packages/core/src/plugins/collections.ts @@ -7,6 +7,9 @@ import { resolve } from 'pathe' import { joinURL } from 'ufo' import type { ResolvedConfig } from 'vite' +const VIRTUAL_MODULE_ID = 'virtual:compodium/collections' +const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID + export function resolveCollections(options: PluginOptions, viteConfig: ResolvedConfig): Collection[] { const rootDir = options.rootDir ?? viteConfig.root @@ -54,52 +57,76 @@ export function resolveCollections(options: PluginOptions, viteConfig: ResolvedC ] } +async function generateCollectionsData(collections: Collection[]) { + const result = await Promise.all(collections.map(async (col) => { + const components = await scanComponents(col.dirs) + const examples = await scanComponents([col.exampleDir]) + + const collectionComponents = components.flatMap((c) => { + const componentExamples = examples?.filter(e => e.pascalName.startsWith(`${c.pascalName}Example`)).map(e => ({ + ...e, + isExample: true, + componentPath: c.filePath, + componentName: c.pascalName, + collectionName: col.name + })) + + const mainExample = componentExamples.find(e => e.pascalName === `${c.pascalName}Example`) + const component = mainExample ?? c + + if (col.name !== 'Components' && !mainExample) return [] + + return [{ + ...component, + wrapperComponent: col.wrapperComponent, + docUrl: col.getDocUrl?.(c.pascalName), + examples: componentExamples.filter(e => e.pascalName !== mainExample?.pascalName), + collectionName: col.name + }] + }) + + return { + ...col, + components: collectionComponents + } + })) + + return result +} + export function collectionsPlugin(options: PluginOptions): VitePlugin { let collections: Collection[] + let server: any return { name: 'compodium:collections', apply: 'serve', - enforce: 'post', + enforce: 'pre', configResolved(viteConfig) { collections = resolveCollections(options, viteConfig) }, - configureServer(server) { - server.middlewares.use('/__compodium__/api/collections', async (_, res) => { - try { - const result = await Promise.all(collections.map(async (col) => { - const components = await scanComponents(col.dirs) - const examples = await scanComponents([col.exampleDir]) - - const collectionComponents = components.flatMap((c) => { - const componentExamples = examples?.filter(e => e.pascalName.startsWith(`${c.pascalName}Example`)).map(e => ({ - ...e, - isExample: true, - componentPath: c.filePath - })) - - const mainExample = componentExamples.find(e => e.pascalName === `${c.pascalName}Example`) - const component = mainExample ?? c - - // Hides third party library components if no example can be found. - if (col.name !== 'Components' && !mainExample) return [] - - return [{ - ...component, - wrapperComponent: col.wrapperComponent, - docUrl: col.getDocUrl?.(c.pascalName), - examples: componentExamples.filter(e => e.pascalName !== mainExample?.pascalName) - }] - }) - - return { - ...col, - components: collectionComponents - } - })) + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID + } + }, + async load(id) { + if (id === RESOLVED_VIRTUAL_MODULE_ID) { + const data = await generateCollectionsData(collections) + return `export default ${JSON.stringify(data, null, 2)}` + } + }, + + configureServer(_server) { + server = _server + + // Existing middleware endpoint + server.middlewares.use('/__compodium__/api/collections', async (_: any, res: any) => { + try { + const result = await generateCollectionsData(collections) res.setHeader('Content-Type', 'application/json') res.write(JSON.stringify(result)) res.end() @@ -116,7 +143,6 @@ export function collectionsPlugin(options: PluginOptions): VitePlugin { componentCollection.exampleDir ].map(d => d.path) - // Watch for changes in example directory const watcher = watch(watchedPaths, { persistent: true, awaitWriteFinish: { @@ -125,34 +151,33 @@ export function collectionsPlugin(options: PluginOptions): VitePlugin { } }) - watcher.on('add', async (filePath: string) => { + const handleFileChange = async (filePath: string, event: string) => { if (watchedPaths.find(p => filePath.startsWith(p))) { + // Existing WebSocket HMR server.ws.send({ type: 'custom', event: 'compodium:hmr', - data: { path: filePath, event: 'component:added' } + data: { path: filePath, event } }) + + // Virtual module invalidation + const module = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID) + if (module) { + server.reloadModule(module) + } } + } + + watcher.on('add', async (filePath: string) => { + await handleFileChange(filePath, 'component:added') }) watcher.on('addDir', async (filePath: string) => { - if (watchedPaths.find(p => filePath.startsWith(p))) { - server.ws.send({ - type: 'custom', - event: 'compodium:hmr', - data: { path: filePath, event: 'component:added' } - }) - } + await handleFileChange(filePath, 'component:added') }) watcher.on('unlink', async (filePath: string) => { - if (watchedPaths.find(p => filePath.startsWith(p))) { - server.ws.send({ - type: 'custom', - event: 'compodium:hmr', - data: { path: filePath, event: 'component:removed' } - }) - } + await handleFileChange(filePath, 'component:removed') }) } } diff --git a/packages/core/src/plugins/colors.ts b/packages/core/src/plugins/colors.ts index 85e9888b..e684bb27 100644 --- a/packages/core/src/plugins/colors.ts +++ b/packages/core/src/plugins/colors.ts @@ -9,6 +9,7 @@ export function colorsPlugin(options: PluginOptions): VitePlugin { return { name: 'compodium:colors', apply: 'serve', + enforce: 'pre', configureServer(server) { server.middlewares.use('/__compodium__/api/colors', async (req, res) => { try { diff --git a/packages/core/src/plugins/devtools.ts b/packages/core/src/plugins/devtools.ts index a600b486..46700b35 100644 --- a/packages/core/src/plugins/devtools.ts +++ b/packages/core/src/plugins/devtools.ts @@ -12,6 +12,7 @@ export function devtoolsPlugin(options: PluginOptions): VitePlugin { return { name: 'compodium:devtools', + enforce: 'pre', apply: 'serve', configResolved(viteConfig) { @@ -32,7 +33,7 @@ export function devtoolsPlugin(options: PluginOptions): VitePlugin { }, configureServer(server) { - if (process.env.COMPODIUM_DEVTOOLS_URL || process.env.COMPODIUM_TEST) return + if (process.env.COMPODIUM_DEVTOOLS_URL || process.env.VITEST) return server.middlewares.use('/__compodium__/devtools', sirv(resolve(dirname(fileURLToPath(import.meta.url)), './client/devtools'), { single: true, setHeaders: res => res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400') } diff --git a/packages/core/src/plugins/examples.ts b/packages/core/src/plugins/examples.ts index 4eac1e1a..05898634 100644 --- a/packages/core/src/plugins/examples.ts +++ b/packages/core/src/plugins/examples.ts @@ -8,6 +8,7 @@ export function examplePlugin(options: PluginOptions): VitePlugin { return { name: 'compodium:examples', + enforce: 'pre', apply: 'serve', configResolved(viteConfig) { diff --git a/packages/core/src/plugins/iconify.ts b/packages/core/src/plugins/iconify.ts index add34f71..0af0c069 100644 --- a/packages/core/src/plugins/iconify.ts +++ b/packages/core/src/plugins/iconify.ts @@ -5,7 +5,9 @@ import { joinURL } from 'ufo' export function iconifyPlugin(_options: PluginOptions): VitePlugin { return { name: 'compodium:iconify', + enforce: 'pre', apply: 'serve', + configureServer(server) { server.middlewares.use('/__compodium__/api/iconify', async (req, res) => { try { diff --git a/packages/core/src/plugins/meta/index.ts b/packages/core/src/plugins/meta/index.ts index 37737e59..01c57955 100644 --- a/packages/core/src/plugins/meta/index.ts +++ b/packages/core/src/plugins/meta/index.ts @@ -2,12 +2,15 @@ import { readFile } from 'node:fs/promises' import type { Collection, PluginOptions } from '../../types' import { createChecker } from './checker' import { watch } from 'chokidar' -import type { VitePlugin } from 'unplugin' +import type { Plugin } from 'vite' import AST from 'unplugin-ast/vite' import { resolveCollections } from '../collections' -export function extendMetaPlugin(_options: PluginOptions): VitePlugin { +const VIRTUAL_MODULE_ID = 'virtual:compodium/meta' +const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID + +export function extendMetaPlugin(_options: PluginOptions): Plugin { return AST({ include: [/\.[jt]sx?$/, /\.vue$/], enforce: 'post', @@ -24,8 +27,10 @@ export function extendMetaPlugin(_options: PluginOptions): VitePlugin { }) } -export function metaPlugin(options: PluginOptions): VitePlugin { +export function metaPlugin(options: PluginOptions): Plugin { let collections: Collection[] + let checker: any + let server: any return { name: 'compodium:meta', @@ -36,14 +41,41 @@ export function metaPlugin(options: PluginOptions): VitePlugin { collections = resolveCollections(options, viteConfig) }, - configureServer(server) { + resolveId(id) { + if (id.startsWith(VIRTUAL_MODULE_ID)) { + return RESOLVED_VIRTUAL_MODULE_ID + id.slice(VIRTUAL_MODULE_ID.length) + } + }, + + async load(id) { + if (id.startsWith(RESOLVED_VIRTUAL_MODULE_ID)) { + const url = new URL(id.slice(1), 'http://localhost') // Remove the \0 prefix + const componentPath = url.searchParams.get('component') + + if (!componentPath) { + return 'export default null' + } + + if (!checker) { + return 'export default null' + } + + const meta = checker.getComponentMeta(componentPath) + return `export default ${JSON.stringify(meta, null, 2)}` + } + }, + + configureServer(_server) { + server = _server + const checkerDirs = collections.flatMap(c => [ ...c.dirs, c.exampleDir ]) - const checker = createChecker(checkerDirs) + checker = createChecker(checkerDirs) - server.middlewares.use('/__compodium__/api/meta', async (req, res) => { + // Existing middleware endpoint + server.middlewares.use('/__compodium__/api/meta', async (req: any, res: any) => { try { const url = new URL(req.url!, `http://${req.headers.host}`) const componentPath = url.searchParams.get('component') @@ -77,7 +109,23 @@ export function metaPlugin(options: PluginOptions): VitePlugin { componentCollection.exampleDir ].map(d => d.path) - // Watch for changes in example directory + const invalidateVirtualModuleForFile = (filePath: string) => { + const virtualModuleId = `${RESOLVED_VIRTUAL_MODULE_ID}?component=${encodeURIComponent(filePath)}` + const module = server.moduleGraph.getModuleById(virtualModuleId) + if (module) { + server.reloadModule(module) + } + } + + const invalidateAllVirtualModules = () => { + // Only for add events where we need to reload all modules + for (const [id, module] of server.moduleGraph.idToModuleMap) { + if (id.startsWith(RESOLVED_VIRTUAL_MODULE_ID)) { + server.reloadModule(module) + } + } + } + const watcher = watch(watchedPaths, { persistent: true, awaitWriteFinish: { @@ -88,18 +136,19 @@ export function metaPlugin(options: PluginOptions): VitePlugin { watcher.on('add', async () => { checker.reload() + invalidateAllVirtualModules() }) watcher.on('change', async (filePath: string) => { if (watchedPaths.find(p => filePath.startsWith(p))) { const code = await readFile(filePath, 'utf-8') checker.updateFile(filePath, code) - server.ws.send({ type: 'custom', event: 'compodium:hmr', data: { path: filePath, event: 'component:changed' } }) + invalidateVirtualModuleForFile(filePath) } }) } diff --git a/packages/core/src/runtime/preview.vue b/packages/core/src/runtime/preview.vue index 36630aa0..7aeb33f7 100644 --- a/packages/core/src/runtime/preview.vue +++ b/packages/core/src/runtime/preview.vue @@ -20,10 +20,5 @@ body { #compodium-default-preview { display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - min-width: fit-content; - padding: 64px; } diff --git a/packages/core/src/runtime/root.vue b/packages/core/src/runtime/root.vue index f908a9db..855bfd7f 100644 --- a/packages/core/src/runtime/root.vue +++ b/packages/core/src/runtime/root.vue @@ -1,8 +1,12 @@ + + + + diff --git a/packages/devtools/app/components/ComponentCodeTab.vue b/packages/devtools/app/components/ComponentCodeTab.vue new file mode 100644 index 00000000..2603c5cf --- /dev/null +++ b/packages/devtools/app/components/ComponentCodeTab.vue @@ -0,0 +1,63 @@ + + + diff --git a/packages/devtools/app/components/ComponentCollectionMenu.vue b/packages/devtools/app/components/ComponentCollectionMenu.vue index 9f8803d1..0358981b 100644 --- a/packages/devtools/app/components/ComponentCollectionMenu.vue +++ b/packages/devtools/app/components/ComponentCollectionMenu.vue @@ -4,21 +4,26 @@ import type { Component, ComponentCollection, ComponentExample } from '@compodiu const props = defineProps<{ collections: ComponentCollection[] }>() const modelValue = defineModel() +const { suites } = useVitest() + const treeItems = computed(() => { if (!props.collections) return return props.collections?.map(col => ({ label: col.name, + pascalName: col.name, icon: col.icon, defaultExpanded: true, children: col.components?.map(comp => ({ label: comp?.isExample ? comp.pascalName.replace(/Example$/, '') : comp.pascalName, + pascalName: comp.pascalName, active: modelValue.value?.pascalName === comp.pascalName, onSelect() { modelValue.value = comp }, children: comp.examples?.map(ex => ({ label: ex.pascalName.replace(comp.pascalName, ''), + pascalName: ex.pascalName, active: modelValue.value?.pascalName === ex.pascalName, onSelect() { modelValue.value = ex @@ -32,7 +37,7 @@ const treeItems = computed(() => { - + new Set(fuseResults.value?.map(result => res class="w-full ml-1" /> - +import type { PropInputType, PropSchema } from '@compodium/core' +import { createReusableTemplate } from '@vueuse/core' + +import BooleanInput from './BooleanInput.vue' +import StringInput from './StringInput.vue' +import NumberInput from './NumberInput.vue' +import StringEnumInput from './StringEnumInput.vue' +import ObjectInput from './ObjectInput.vue' +import ArrayInput from './ArrayInput.vue' +import PrimitiveArrayInput from './PrimitiveArrayInput.vue' +import DateInput from './DateInput.vue' +import IconInput from './IconInput.vue' + +const inputTypes: Record = { + icon: IconInput, + array: ArrayInput, + object: ObjectInput, + boolean: BooleanInput, + string: StringInput, + number: NumberInput, + primitiveArray: PrimitiveArrayInput, + date: DateInput, + stringEnum: StringEnumInput +} + + + + + diff --git a/packages/devtools/app/components/testing/TestMenu.vue b/packages/devtools/app/components/testing/TestMenu.vue new file mode 100644 index 00000000..6a9b67c0 --- /dev/null +++ b/packages/devtools/app/components/testing/TestMenu.vue @@ -0,0 +1,136 @@ + + + diff --git a/packages/devtools/app/components/testing/TestResult.vue b/packages/devtools/app/components/testing/TestResult.vue new file mode 100644 index 00000000..a23f47cf --- /dev/null +++ b/packages/devtools/app/components/testing/TestResult.vue @@ -0,0 +1,89 @@ + + + diff --git a/packages/devtools/app/components/testing/TestStatusIcon.vue b/packages/devtools/app/components/testing/TestStatusIcon.vue new file mode 100644 index 00000000..e4aed7aa --- /dev/null +++ b/packages/devtools/app/components/testing/TestStatusIcon.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/devtools/app/composables/useViteClient.ts b/packages/devtools/app/composables/useViteClient.ts new file mode 100644 index 00000000..0708a852 --- /dev/null +++ b/packages/devtools/app/composables/useViteClient.ts @@ -0,0 +1,33 @@ +import { createSharedComposable } from '@vueuse/core' +import type { ViteHotContext } from 'vite/types/hot.js' + +function _useViteClient() { + const { hooks } = useCompodiumClient() + let hot: ViteHotContext | undefined = undefined + + hooks.hook('renderer:mounted', (_hot?: ViteHotContext) => { + hot = _hot + + for (const [e, fns] of Object.entries(viteHandlers.value)) { + fns?.forEach(fn => hot?.on(e, fn)) + } + + viteHandlers.value = {} + }) + + const viteHandlers = shallowRef void)[]>>({}) + + function onEvent(e: string, fn: (payload: any) => void) { + if (hot) hot.on(e, fn) + else { + viteHandlers.value[e] ??= [] + viteHandlers.value[e].push(fn) + } + } + + return { + onEvent + } +} + +export const useViteClient = createSharedComposable(_useViteClient) diff --git a/packages/devtools/app/composables/useVitest.ts b/packages/devtools/app/composables/useVitest.ts new file mode 100644 index 00000000..38c0f95a --- /dev/null +++ b/packages/devtools/app/composables/useVitest.ts @@ -0,0 +1,144 @@ +import { createSharedComposable } from '@vueuse/core' +import { createClient, type VitestClient } from '@vitest/ws-client' +import { reactive, readonly } from 'vue' +import type { RunnerTask, RunnerTaskResult, TestError } from 'vitest' + +let client: VitestClient + +export interface TestState { + id: string + name?: string + state: RunnerTaskResult['state'] + duration?: number + errors?: TestError[] +} + +export interface SuiteState { + id: string + name: string + state: RunnerTaskResult['state'] + duration?: number + errors?: TestError[] + file?: string + tests?: Map +} + +export function _useVitest() { + const suites = reactive(new Map()) + + async function getVitest() { + if (!client) { + const { port, token } = await $fetch<{ port: number, token: string }>('/api/test/start', { baseURL: '/__compodium__' }) + + client = createClient(`ws://${window.location.hostname}:${port}/__vitest_api__?token=${token}`, { + handlers: { + onTaskUpdate: (packs) => { + packs.forEach(([id, result, meta]) => { + updateTaskState({ id, result, meta }) + }) + }, + onCollected: (files) => { + function processTask(task: RunnerTask) { + updateTaskState(task) + if ('tasks' in task) { + task.tasks.forEach(t => processTask(t)) + } + } + files?.forEach(f => processTask(f)) + }, + onFinished: (files) => { + console.log(`Test run finished (${files.length} files)`) + const hasErrors = files.some(f => f.result?.state === 'fail') + testStatus.value = hasErrors ? 'fail' : 'pass' + } + } + }) + + const connectedPromise = new Promise((resolve) => { + client.ws.addEventListener('open', () => resolve()) + }) + await connectedPromise + } + return client + } + + function updateTaskState(task: Partial & { id: string }) { + if (task.meta?.compodium?.suite) { + const name = task.meta.compodium?.component ?? task.meta.compodium?.collection + if (!name) return + const suite = suites.get(name) + suites.set(name, { + ...suite, + id: task.id, + name, + state: task.result?.state ?? 'queued', + file: task.file?.filepath, + duration: task.result?.duration, + errors: task.result?.errors as TestError[] + }) + } else if (task.meta?.compodium) { + const componentName = task.meta?.compodium.component + if (!componentName) return + + const suite = suites.get(componentName) + if (!suite) return + + suite.tests ??= new Map() + const test = suite.tests.get(task.id) + + suite.tests.set(task.id, { + ...test, + id: task.id, + name: task.meta.compodium.name, + state: task.result?.state ?? 'queued', + duration: task.result?.duration, + errors: task.result?.errors as TestError[] + }) + } + + if (task.result?.errors) testErrors.value?.push(...task.result.errors as TestError[]) + } + + const testStatus = ref(null) + + async function runTests() { + testStatus.value = 'run' + testErrors.value = [] + const vitest = await getVitest() + const files = await vitest.rpc.getTestFiles().then(specs => specs.map(([_project, file, _config]) => file)) + + for (const suite of suites.values()) { + suite.state = 'run' + suite.tests?.forEach(t => t.state = 'run') + } + + await vitest.rpc.rerun(files, true) + } + + async function runComponentTests(component: string) { + const vitest = await getVitest() + const suite = suites.get(component) + if (!suite || !suite.file) return + + testStatus.value = 'run' + testErrors.value = [] + + suite.state = 'run' + suite.tests?.forEach(t => t.state = 'run') + + await vitest.rpc.rerun([suite.file], true) + } + + const testErrors = shallowRef([]) + + return { + testStatus: readonly(testStatus), + testErrors: testErrors, + getVitest, + runTests, + runComponentTests, + suites: readonly(suites) + } +} + +export const useVitest = createSharedComposable(_useVitest) diff --git a/packages/devtools/app/pages/components.vue b/packages/devtools/app/pages/components.vue index bd3d15be..10146b46 100644 --- a/packages/devtools/app/pages/components.vue +++ b/packages/devtools/app/pages/components.vue @@ -1,7 +1,7 @@