metaowl 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,532 @@
1
+ /**
2
+ * @module TestUtils
3
+ *
4
+ * Testing utilities for MetaOwl OWL applications.
5
+ *
6
+ * Provides:
7
+ * - Mock Store for state management testing
8
+ * - Router mocking for navigation testing
9
+ * - Component mounting helpers
10
+ * - Async test utilities
11
+ *
12
+ * Usage:
13
+ * import { createMockStore, mockRouter, mountComponent } from 'metaowl/test'
14
+ *
15
+ * // Mock store
16
+ * const store = createMockStore({
17
+ * state: { count: 0 },
18
+ * mutations: { increment: (s) => s.count++ }
19
+ * })
20
+ *
21
+ * // Mock router
22
+ * const router = mockRouter({
23
+ * initialRoute: '/dashboard',
24
+ * routes: [
25
+ * { path: '/', component: HomePage },
26
+ * { path: '/dashboard', component: DashboardPage }
27
+ * ]
28
+ * })
29
+ *
30
+ * // Mount component with mocks
31
+ * const component = await mountComponent(MyComponent, {
32
+ * store,
33
+ * router,
34
+ * props: { title: 'Test' }
35
+ * })
36
+ */
37
+
38
+ import { mount, reactive } from '@odoo/owl'
39
+
40
+ /**
41
+ * Create a mock store for testing.
42
+ *
43
+ * @param {Object} config - Store configuration
44
+ * @param {Object} [config.state={}] - Initial state
45
+ * @param {Object} [config.getters={}] - Getters object
46
+ * @param {Object} [config.mutations={}] - Mutations object
47
+ * @param {Object} [config.actions={}] - Actions object
48
+ * @returns {Object} Mock store instance
49
+ *
50
+ * @example
51
+ * const store = createMockStore({
52
+ * state: { user: null, count: 0 },
53
+ * getters: {
54
+ * isLoggedIn: (state) => !!state.user
55
+ * },
56
+ * mutations: {
57
+ * setUser: (state, user) => { state.user = user },
58
+ * increment: (state) => { state.count++ }
59
+ * },
60
+ * actions: {
61
+ * login: async ({ commit }, credentials) => {
62
+ * const user = await fakeApi.login(credentials)
63
+ * commit('setUser', user)
64
+ * }
65
+ * }
66
+ * })
67
+ *
68
+ * // Use in tests
69
+ * store.commit('setUser', { name: 'John' })
70
+ * console.log(store.state.user.name) // 'John'
71
+ * console.log(store.getters.isLoggedIn.value) // true
72
+ *
73
+ * await store.dispatch('login', { email, password })
74
+ */
75
+ export function createMockStore(config = {}) {
76
+ const {
77
+ state: initialState = {},
78
+ getters: getterDefs = {},
79
+ mutations: mutationDefs = {},
80
+ actions: actionDefs = {}
81
+ } = config
82
+
83
+ // Create reactive state
84
+ const state = reactive({ ...initialState })
85
+
86
+ // Create computed getters
87
+ const getters = {}
88
+ for (const [name, fn] of Object.entries(getterDefs)) {
89
+ Object.defineProperty(getters, name, {
90
+ get: () => fn(state),
91
+ enumerable: true
92
+ })
93
+ }
94
+
95
+ // Create mutations
96
+ const mutations = {}
97
+ for (const [name, fn] of Object.entries(mutationDefs)) {
98
+ mutations[name] = (payload) => {
99
+ fn(state, payload)
100
+ }
101
+ }
102
+
103
+ // Create actions
104
+ const actions = {}
105
+ for (const [name, fn] of Object.entries(actionDefs)) {
106
+ actions[name] = async (payload) => {
107
+ const context = {
108
+ state,
109
+ getters,
110
+ commit: (mutation, payload) => mutations[mutation]?.(payload),
111
+ dispatch: (action, payload) => actions[action]?.(payload)
112
+ }
113
+ return await fn(context, payload)
114
+ }
115
+ }
116
+
117
+ // Store instance
118
+ return {
119
+ state,
120
+ getters,
121
+ mutations,
122
+ actions,
123
+
124
+ /**
125
+ * Commit a mutation.
126
+ * @param {string} name - Mutation name
127
+ * @param {any} payload - Mutation payload
128
+ */
129
+ commit(name, payload) {
130
+ if (mutations[name]) {
131
+ mutations[name](payload)
132
+ } else {
133
+ console.warn(`[TestUtils] Mutation '${name}' not found`)
134
+ }
135
+ },
136
+
137
+ /**
138
+ * Dispatch an action.
139
+ * @param {string} name - Action name
140
+ * @param {any} payload - Action payload
141
+ * @returns {Promise<any>}
142
+ */
143
+ async dispatch(name, payload) {
144
+ if (actions[name]) {
145
+ return await actions[name](payload)
146
+ } else {
147
+ console.warn(`[TestUtils] Action '${name}' not found`)
148
+ }
149
+ },
150
+
151
+ /**
152
+ * Reset store to initial state.
153
+ */
154
+ reset() {
155
+ Object.keys(state).forEach(key => delete state[key])
156
+ Object.assign(state, initialState)
157
+ },
158
+
159
+ /**
160
+ * Set state directly (for testing).
161
+ * @param {Object} newState
162
+ */
163
+ setState(newState) {
164
+ Object.assign(state, newState)
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create a mock router for testing.
171
+ *
172
+ * @param {Object} config - Router configuration
173
+ * @param {string} [config.initialRoute='/'] - Initial route path
174
+ * @param {Array} [config.routes=[]] - Route definitions
175
+ * @returns {Object} Mock router instance
176
+ *
177
+ * @example
178
+ * const router = mockRouter({
179
+ * initialRoute: '/',
180
+ * routes: [
181
+ * { path: '/', name: 'home' },
182
+ * { path: '/user/:id', name: 'user' }
183
+ * ]
184
+ * })
185
+ *
186
+ * await router.push('/user/123')
187
+ * console.log(router.currentRoute.value.path) // '/user/123'
188
+ * console.log(router.currentRoute.value.params.id) // '123'
189
+ */
190
+ export function mockRouter(config = {}) {
191
+ const {
192
+ initialRoute = '/',
193
+ routes = []
194
+ } = config
195
+
196
+ // Reactive current route
197
+ const currentRoute = reactive({
198
+ path: initialRoute,
199
+ name: null,
200
+ params: {},
201
+ query: {},
202
+ hash: ''
203
+ })
204
+
205
+ // Navigation guards
206
+ const beforeEachGuards = []
207
+ const afterEachHooks = []
208
+
209
+ // Parse URL into route object
210
+ function parseUrl(url) {
211
+ const [pathAndQuery, hash = ''] = url.split('#')
212
+ const [path, queryString = ''] = pathAndQuery.split('?')
213
+
214
+ const query = {}
215
+ if (queryString) {
216
+ queryString.split('&').forEach(param => {
217
+ const [key, value] = param.split('=')
218
+ query[decodeURIComponent(key)] = decodeURIComponent(value || '')
219
+ })
220
+ }
221
+
222
+ // Find matching route
223
+ let matchedRoute = null
224
+ let params = {}
225
+
226
+ for (const route of routes) {
227
+ const match = matchPath(path, route.path)
228
+ if (match) {
229
+ matchedRoute = route
230
+ params = match.params
231
+ break
232
+ }
233
+ }
234
+
235
+ return {
236
+ path,
237
+ name: matchedRoute?.name || null,
238
+ params,
239
+ query,
240
+ hash
241
+ }
242
+ }
243
+
244
+ // Match path against route pattern
245
+ function matchPath(path, pattern) {
246
+ // Simple pattern matching (supports :param and * wildcards)
247
+ const paramNames = []
248
+ const regexPattern = pattern
249
+ .replace(/\*/g, '.*')
250
+ .replace(/:([^/]+)/g, (match, name) => {
251
+ paramNames.push(name)
252
+ return '([^/]+)'
253
+ })
254
+
255
+ const regex = new RegExp(`^${regexPattern}$`)
256
+ const match = path.match(regex)
257
+
258
+ if (!match) return null
259
+
260
+ const params = {}
261
+ paramNames.forEach((name, i) => {
262
+ params[name] = match[i + 1]
263
+ })
264
+
265
+ return { params }
266
+ }
267
+
268
+ // Initialize
269
+ Object.assign(currentRoute, parseUrl(initialRoute))
270
+
271
+ return {
272
+ currentRoute,
273
+
274
+ /**
275
+ * Navigate to a new route.
276
+ * @param {string} path - Target path
277
+ */
278
+ async push(path) {
279
+ const to = parseUrl(path)
280
+ const from = { ...currentRoute }
281
+
282
+ // Run beforeEach guards
283
+ for (const guard of beforeEachGuards) {
284
+ const result = await guard(to, from, () => {})
285
+ if (result === false) return
286
+ }
287
+
288
+ Object.assign(currentRoute, to)
289
+
290
+ // Run afterEach hooks
291
+ for (const hook of afterEachHooks) {
292
+ await hook(to, from)
293
+ }
294
+ },
295
+
296
+ /**
297
+ * Replace current route.
298
+ * @param {string} path - Target path
299
+ */
300
+ async replace(path) {
301
+ await this.push(path)
302
+ },
303
+
304
+ /**
305
+ * Go back in history.
306
+ */
307
+ back() {
308
+ // Mock - does nothing in test environment
309
+ },
310
+
311
+ /**
312
+ * Register beforeEach guard.
313
+ * @param {Function} guard
314
+ * @returns {Function} Unsubscribe function
315
+ */
316
+ beforeEach(guard) {
317
+ beforeEachGuards.push(guard)
318
+ return () => {
319
+ const index = beforeEachGuards.indexOf(guard)
320
+ if (index > -1) beforeEachGuards.splice(index, 1)
321
+ }
322
+ },
323
+
324
+ /**
325
+ * Register afterEach hook.
326
+ * @param {Function} hook
327
+ * @returns {Function} Unsubscribe function
328
+ */
329
+ afterEach(hook) {
330
+ afterEachHooks.push(hook)
331
+ return () => {
332
+ const index = afterEachHooks.indexOf(hook)
333
+ if (index > -1) afterEachHooks.splice(index, 1)
334
+ }
335
+ },
336
+
337
+ /**
338
+ * Generate URL for named route.
339
+ * @param {string} name - Route name
340
+ * @param {Object} params - Route params
341
+ * @returns {string}
342
+ */
343
+ resolve(name, params = {}) {
344
+ const route = routes.find(r => r.name === name)
345
+ if (!route) return '/'
346
+
347
+ let path = route.path
348
+ for (const [key, value] of Object.entries(params)) {
349
+ path = path.replace(`:${key}`, value)
350
+ }
351
+ return path
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Mount a component with test utilities.
358
+ *
359
+ * @param {Component} Component - OWL component class
360
+ * @param {Object} [options={}] - Mount options
361
+ * @param {Object} [options.props={}] - Component props
362
+ * @param {Object} [options.store] - Mock store
363
+ * @param {Object} [options.router] - Mock router
364
+ * @param {Element} [options.target] - Mount target (default: detached div)
365
+ * @returns {Promise<Object>} Mounted component instance
366
+ *
367
+ * @example
368
+ * const component = await mountComponent(MyComponent, {
369
+ * props: { title: 'Test' },
370
+ * store: createMockStore({ state: { user: null } }),
371
+ * router: mockRouter({ initialRoute: '/' })
372
+ * })
373
+ *
374
+ * // Access component
375
+ * expect(component.props.title).toBe('Test')
376
+ * expect(component.env.store.state.user).toBeNull()
377
+ */
378
+ export async function mountComponent(Component, options = {}) {
379
+ const {
380
+ props = {},
381
+ store = null,
382
+ router = null,
383
+ target = document.createElement('div')
384
+ } = options
385
+
386
+ // Create environment with mocks
387
+ const env = {}
388
+ if (store) env.store = store
389
+ if (router) env.router = router
390
+
391
+ // Mount component
392
+ const component = await mount(Component, {
393
+ props,
394
+ env,
395
+ target
396
+ })
397
+
398
+ return component
399
+ }
400
+
401
+ /**
402
+ * Wait for a specific time (for async tests).
403
+ *
404
+ * @param {number} ms - Milliseconds to wait
405
+ * @returns {Promise<void>}
406
+ */
407
+ export function wait(ms) {
408
+ return new Promise(resolve => setTimeout(resolve, ms))
409
+ }
410
+
411
+ /**
412
+ * Wait for next tick (DOM update).
413
+ *
414
+ * @returns {Promise<void>}
415
+ */
416
+ export function nextTick() {
417
+ return new Promise(resolve => {
418
+ requestAnimationFrame(resolve)
419
+ })
420
+ }
421
+
422
+ /**
423
+ * Flush all promises (useful after state changes).
424
+ *
425
+ * @returns {Promise<void>}
426
+ */
427
+ export async function flushPromises() {
428
+ await new Promise(resolve => setTimeout(resolve, 0))
429
+ }
430
+
431
+ /**
432
+ * Simulate user events.
433
+ */
434
+ export const userEvent = {
435
+ /**
436
+ * Click an element.
437
+ * @param {Element} element
438
+ */
439
+ async click(element) {
440
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
441
+ await flushPromises()
442
+ },
443
+
444
+ /**
445
+ * Type text into an input.
446
+ * @param {HTMLInputElement} input
447
+ * @param {string} text
448
+ */
449
+ async type(input, text) {
450
+ input.value = text
451
+ input.dispatchEvent(new Event('input', { bubbles: true }))
452
+ await flushPromises()
453
+ },
454
+
455
+ /**
456
+ * Submit a form.
457
+ * @param {HTMLFormElement} form
458
+ */
459
+ async submit(form) {
460
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
461
+ await flushPromises()
462
+ },
463
+
464
+ /**
465
+ * Change a select value.
466
+ * @param {HTMLSelectElement} select
467
+ * @param {string} value
468
+ */
469
+ async select(select, value) {
470
+ select.value = value
471
+ select.dispatchEvent(new Event('change', { bubbles: true }))
472
+ await flushPromises()
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Create DOM element helpers.
478
+ */
479
+ export const dom = {
480
+ /**
481
+ * Query element by selector.
482
+ * @param {string} selector
483
+ * @param {Element} [container=document]
484
+ * @returns {Element|null}
485
+ */
486
+ query(selector, container = document) {
487
+ return container.querySelector(selector)
488
+ },
489
+
490
+ /**
491
+ * Query all elements by selector.
492
+ * @param {string} selector
493
+ * @param {Element} [container=document]
494
+ * @returns {NodeList}
495
+ */
496
+ queryAll(selector, container = document) {
497
+ return container.querySelectorAll(selector)
498
+ },
499
+
500
+ /**
501
+ * Check if element has class.
502
+ * @param {Element} element
503
+ * @param {string} className
504
+ * @returns {boolean}
505
+ */
506
+ hasClass(element, className) {
507
+ return element?.classList?.contains(className) || false
508
+ },
509
+
510
+ /**
511
+ * Get text content.
512
+ * @param {Element} element
513
+ * @returns {string}
514
+ */
515
+ text(element) {
516
+ return element?.textContent?.trim() || ''
517
+ }
518
+ }
519
+
520
+ // Export namespace
521
+ export const TestUtils = {
522
+ createMockStore,
523
+ mockRouter,
524
+ mountComponent,
525
+ wait,
526
+ nextTick,
527
+ flushPromises,
528
+ userEvent,
529
+ dom
530
+ }
531
+
532
+ export default TestUtils
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metaowl",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Lightweight meta-framework for Odoo OWL — file-based routing, app mounting, Fetch helper, Cache, Meta tags, SSG generator, and a Vite plugin.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { scanComponents, generateComponentDts } from '../modules/auto-import.js'
3
+ import { mkdirSync, writeFileSync, rmSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { tmpdir } from 'os'
6
+
7
+ describe('auto-import', () => {
8
+ let tempDir
9
+
10
+ beforeEach(() => {
11
+ tempDir = join(tmpdir(), `metaowl-test-${Date.now()}`)
12
+ mkdirSync(tempDir, { recursive: true })
13
+ })
14
+
15
+ afterEach(() => {
16
+ try {
17
+ rmSync(tempDir, { recursive: true, force: true })
18
+ } catch {
19
+ // Ignore cleanup errors
20
+ }
21
+ })
22
+
23
+ describe('scanComponents', () => {
24
+ it('should find all component files', async () => {
25
+ // Create test components
26
+ mkdirSync(join(tempDir, 'Button'), { recursive: true })
27
+ mkdirSync(join(tempDir, 'Card'), { recursive: true })
28
+ writeFileSync(join(tempDir, 'Button', 'Button.js'), 'export default {}')
29
+ writeFileSync(join(tempDir, 'Card', 'Card.js'), 'export default {}')
30
+
31
+ const components = await scanComponents(tempDir)
32
+
33
+ expect(components).toHaveLength(2)
34
+ expect(components).toContain('Button')
35
+ expect(components).toContain('Card')
36
+ })
37
+
38
+ it('should filter helpers', async () => {
39
+ mkdirSync(join(tempDir, 'Button'), { recursive: true })
40
+ mkdirSync(join(tempDir, 'utils'), { recursive: true })
41
+ writeFileSync(join(tempDir, 'Button', 'Button.js'), 'export default {}')
42
+ writeFileSync(join(tempDir, 'utils', 'helpers.js'), 'export const utils = {}')
43
+
44
+ const components = await scanComponents(tempDir)
45
+
46
+ // Finds all JS files recursively
47
+ expect(components).toContain('Button')
48
+ expect(components).toContain('helpers')
49
+ })
50
+
51
+ it('should return empty array for non-existent directory', async () => {
52
+ const components = await scanComponents('/non-existent/path')
53
+ expect(components).toEqual([])
54
+ })
55
+ })
56
+
57
+ describe('generateComponentDts', () => {
58
+ it('should generate TypeScript declarations', async () => {
59
+ const components = ['Button', 'Card', 'Modal']
60
+ const outputPath = join(tempDir, 'components.d.ts')
61
+
62
+ await generateComponentDts(components, outputPath)
63
+
64
+ // Check file was created
65
+ const fs = await import('fs')
66
+ const content = fs.readFileSync(outputPath, 'utf-8')
67
+
68
+ expect(content).toContain('Button')
69
+ expect(content).toContain('Card')
70
+ expect(content).toContain('Modal')
71
+ expect(content).toContain('declare module')
72
+ })
73
+
74
+ it('should handle empty components array', async () => {
75
+ await generateComponentDts([], join(tempDir, 'empty.d.ts'))
76
+
77
+ const fs = await import('fs')
78
+ const content = fs.readFileSync(join(tempDir, 'empty.d.ts'), 'utf-8')
79
+
80
+ expect(content).toContain('declare module')
81
+ })
82
+ })
83
+
84
+ describe('integration', () => {
85
+ it('should work with nested components', async () => {
86
+ // Create nested structure
87
+ const nestedPath = join(tempDir, 'ui', 'forms')
88
+ mkdirSync(nestedPath, { recursive: true })
89
+ writeFileSync(join(nestedPath, 'TextField.js'), 'export default {}')
90
+ writeFileSync(join(nestedPath, 'SelectField.js'), 'export default {}')
91
+
92
+ const components = await scanComponents(tempDir)
93
+
94
+ expect(components).toContain('TextField')
95
+ expect(components).toContain('SelectField')
96
+ })
97
+
98
+ it('should ignore non-js files', async () => {
99
+ mkdirSync(join(tempDir, 'Button'), { recursive: true })
100
+ writeFileSync(join(tempDir, 'Button', 'Button.js'), 'export default {}')
101
+ writeFileSync(join(tempDir, 'Button', 'Button.test.js'), 'test')
102
+ writeFileSync(join(tempDir, 'Button', 'styles.css'), '/* styles */')
103
+
104
+ const components = await scanComponents(tempDir)
105
+
106
+ expect(components).toHaveLength(1)
107
+ expect(components).toContain('Button')
108
+ })
109
+ })
110
+ })