metaowl 0.4.0 → 0.5.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -15
  3. package/build/runtime/bin/metaowl-build.js +10 -0
  4. package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
  5. package/build/runtime/bin/metaowl-dev.js +10 -0
  6. package/build/runtime/bin/metaowl-generate.js +231 -0
  7. package/build/runtime/bin/metaowl-lint.js +58 -0
  8. package/build/runtime/bin/utils.js +68 -0
  9. package/build/runtime/index.js +141 -0
  10. package/build/runtime/modules/app-mounter.js +65 -0
  11. package/build/runtime/modules/auto-import.js +140 -0
  12. package/build/runtime/modules/cache.js +49 -0
  13. package/build/runtime/modules/composables.js +353 -0
  14. package/build/runtime/modules/error-boundary.js +116 -0
  15. package/build/runtime/modules/fetch.js +31 -0
  16. package/build/runtime/modules/file-router.js +205 -0
  17. package/build/runtime/modules/forms.js +193 -0
  18. package/build/runtime/modules/i18n.js +167 -0
  19. package/build/runtime/modules/layouts.js +163 -0
  20. package/build/runtime/modules/link.js +141 -0
  21. package/build/runtime/modules/meta.js +117 -0
  22. package/build/runtime/modules/odoo-rpc.js +264 -0
  23. package/build/runtime/modules/pwa.js +262 -0
  24. package/build/runtime/modules/router.js +389 -0
  25. package/build/runtime/modules/seo.js +186 -0
  26. package/build/runtime/modules/store.js +196 -0
  27. package/build/runtime/modules/templates-manager.js +52 -0
  28. package/build/runtime/modules/test-utils.js +238 -0
  29. package/build/runtime/vite/plugin.js +183 -0
  30. package/eslint.js +29 -0
  31. package/package.json +29 -11
  32. package/CONTRIBUTING.md +0 -49
  33. package/bin/metaowl-build.js +0 -12
  34. package/bin/metaowl-dev.js +0 -12
  35. package/bin/metaowl-generate.js +0 -339
  36. package/bin/metaowl-lint.js +0 -71
  37. package/bin/utils.js +0 -82
  38. package/index.js +0 -328
  39. package/modules/app-mounter.js +0 -104
  40. package/modules/auto-import.js +0 -225
  41. package/modules/cache.js +0 -59
  42. package/modules/composables.js +0 -600
  43. package/modules/error-boundary.js +0 -228
  44. package/modules/fetch.js +0 -51
  45. package/modules/file-router.js +0 -478
  46. package/modules/forms.js +0 -353
  47. package/modules/i18n.js +0 -333
  48. package/modules/layouts.js +0 -431
  49. package/modules/link.js +0 -255
  50. package/modules/meta.js +0 -119
  51. package/modules/odoo-rpc.js +0 -511
  52. package/modules/pwa.js +0 -515
  53. package/modules/router.js +0 -769
  54. package/modules/seo.js +0 -501
  55. package/modules/store.js +0 -409
  56. package/modules/templates-manager.js +0 -89
  57. package/modules/test-utils.js +0 -532
  58. package/test/auto-import.test.js +0 -110
  59. package/test/cache.test.js +0 -55
  60. package/test/composables.test.js +0 -103
  61. package/test/dynamic-routes.test.js +0 -469
  62. package/test/error-boundary.test.js +0 -126
  63. package/test/fetch.test.js +0 -100
  64. package/test/file-router.test.js +0 -55
  65. package/test/forms.test.js +0 -203
  66. package/test/i18n.test.js +0 -188
  67. package/test/layouts.test.js +0 -395
  68. package/test/link.test.js +0 -189
  69. package/test/meta.test.js +0 -146
  70. package/test/odoo-rpc.test.js +0 -547
  71. package/test/pwa.test.js +0 -154
  72. package/test/router-guards.test.js +0 -229
  73. package/test/router.test.js +0 -77
  74. package/test/seo.test.js +0 -353
  75. package/test/store.test.js +0 -476
  76. package/test/templates-manager.test.js +0 -83
  77. package/test/test-utils.test.js +0 -314
  78. package/vite/plugin.js +0 -277
  79. package/vitest.config.js +0 -8
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @module SEO
3
+ *
4
+ * SEO utilities for MetaOwl applications.
5
+ */
6
+ const VALID_CHANGE_FREQUENCIES = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
7
+ export function generateSitemap(entries, options = {}) {
8
+ const { baseUrl } = options;
9
+ if (!baseUrl) {
10
+ throw new Error('[SEO] baseUrl is required for sitemap generation');
11
+ }
12
+ const normalizedBase = baseUrl.replace(/\/$/, '');
13
+ const urls = entries.map((entry) => {
14
+ const routeUrl = entry.url ?? '';
15
+ const location = routeUrl.startsWith('http')
16
+ ? routeUrl
17
+ : `${normalizedBase}${routeUrl.startsWith('/') ? routeUrl : '/' + routeUrl}`;
18
+ let urlXml = ` <url>\n <loc>${escapeXml(location)}</loc>\n`;
19
+ if (entry.lastmod) {
20
+ urlXml += ` <lastmod>${entry.lastmod}</lastmod>\n`;
21
+ }
22
+ if (entry.changefreq && VALID_CHANGE_FREQUENCIES.includes(entry.changefreq)) {
23
+ urlXml += ` <changefreq>${entry.changefreq}</changefreq>\n`;
24
+ }
25
+ if (entry.priority !== undefined) {
26
+ const priority = Math.max(0, Math.min(1, entry.priority)).toFixed(1);
27
+ urlXml += ` <priority>${priority}</priority>\n`;
28
+ }
29
+ if (entry.image) {
30
+ urlXml += ' <image:image xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n';
31
+ urlXml += ` <image:loc>${escapeXml(entry.image)}</image:loc>\n`;
32
+ urlXml += ' </image:image>\n';
33
+ }
34
+ urlXml += ' </url>';
35
+ return urlXml;
36
+ });
37
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`;
38
+ }
39
+ export function generateRobotsTxt(config = {}) {
40
+ const configs = Array.isArray(config) ? config : [config];
41
+ const sections = configs.map((cfg) => {
42
+ const { userAgent = '*', allow = [], disallow = [], crawlDelay } = cfg;
43
+ let section = `User-agent: ${userAgent}\n`;
44
+ for (const path of allow) {
45
+ section += `Allow: ${path}\n`;
46
+ }
47
+ for (const path of disallow) {
48
+ section += `Disallow: ${path}\n`;
49
+ }
50
+ if (crawlDelay !== undefined && crawlDelay > 0) {
51
+ section += `Crawl-delay: ${crawlDelay}\n`;
52
+ }
53
+ return section.trim();
54
+ });
55
+ const globalConfig = configs.find((cfg) => cfg.sitemap || cfg.host);
56
+ if (globalConfig?.sitemap) {
57
+ sections.push(`Sitemap: ${globalConfig.sitemap}`);
58
+ }
59
+ if (globalConfig?.host) {
60
+ sections.push(`Host: ${globalConfig.host}`);
61
+ }
62
+ return sections.join('\n\n');
63
+ }
64
+ export function jsonLd(schema) {
65
+ const fullSchema = {
66
+ '@context': 'https://schema.org',
67
+ ...schema
68
+ };
69
+ return JSON.stringify(fullSchema, null, 2);
70
+ }
71
+ export function createCanonicalUrl(baseUrl, path, options = {}) {
72
+ const { removeQueryParams = false, allowedParams = [] } = options;
73
+ const normalizedBase = baseUrl.replace(/\/$/, '');
74
+ const [pathname, queryString] = path.split('?');
75
+ const normalizedPath = pathname.startsWith('/') ? pathname : '/' + pathname;
76
+ if (!queryString || removeQueryParams) {
77
+ return `${normalizedBase}${normalizedPath}`;
78
+ }
79
+ if (allowedParams.length > 0) {
80
+ const params = new URLSearchParams(queryString);
81
+ const filtered = new URLSearchParams();
82
+ for (const key of allowedParams) {
83
+ const value = params.get(key);
84
+ if (value !== null) {
85
+ filtered.set(key, value);
86
+ }
87
+ }
88
+ const filteredQuery = filtered.toString();
89
+ return filteredQuery
90
+ ? `${normalizedBase}${normalizedPath}?${filteredQuery}`
91
+ : `${normalizedBase}${normalizedPath}`;
92
+ }
93
+ return `${normalizedBase}${normalizedPath}?${queryString}`;
94
+ }
95
+ export function generateOpenGraph(data) {
96
+ const { title, description, type = 'website', url, image, siteName } = data;
97
+ const tags = {
98
+ 'og:title': title,
99
+ 'og:type': type
100
+ };
101
+ if (description)
102
+ tags['og:description'] = description;
103
+ if (url)
104
+ tags['og:url'] = url;
105
+ if (image)
106
+ tags['og:image'] = image;
107
+ if (siteName)
108
+ tags['og:site_name'] = siteName;
109
+ return tags;
110
+ }
111
+ export function generateTwitterCard(data) {
112
+ const { title, description, card = 'summary_large_image', image, site } = data;
113
+ const tags = {
114
+ 'twitter:card': card,
115
+ 'twitter:title': title
116
+ };
117
+ if (description)
118
+ tags['twitter:description'] = description;
119
+ if (image)
120
+ tags['twitter:image'] = image;
121
+ if (site)
122
+ tags['twitter:site'] = site;
123
+ return tags;
124
+ }
125
+ export function validateSitemap(entries) {
126
+ const errors = [];
127
+ for (let index = 0; index < entries.length; index++) {
128
+ const entry = entries[index];
129
+ if (!entry.url) {
130
+ errors.push(`Entry ${index}: Missing required 'url'`);
131
+ }
132
+ if (entry.priority !== undefined && (entry.priority < 0 || entry.priority > 1)) {
133
+ errors.push(`Entry ${index}: Priority must be between 0 and 1`);
134
+ }
135
+ if (entry.changefreq && !VALID_CHANGE_FREQUENCIES.includes(entry.changefreq)) {
136
+ errors.push(`Entry ${index}: Invalid changefreq '${entry.changefreq}'`);
137
+ }
138
+ if (entry.lastmod) {
139
+ const date = new Date(entry.lastmod);
140
+ if (Number.isNaN(date.getTime())) {
141
+ errors.push(`Entry ${index}: Invalid lastmod date '${entry.lastmod}'`);
142
+ }
143
+ }
144
+ }
145
+ return {
146
+ valid: errors.length === 0,
147
+ errors
148
+ };
149
+ }
150
+ export function getPriorityByDepth(url, options = {}) {
151
+ const { maxDepth = 3 } = options;
152
+ const depth = url.split('/').filter(Boolean).length;
153
+ const priority = Math.max(0.1, 1 - (depth / maxDepth) * 0.3);
154
+ return Math.round(priority * 10) / 10;
155
+ }
156
+ export function generateSitemapIndex(sitemaps) {
157
+ const entries = sitemaps.map((sitemap) => {
158
+ let entry = ` <sitemap>\n <loc>${escapeXml(sitemap.loc)}</loc>\n`;
159
+ if (sitemap.lastmod) {
160
+ entry += ` <lastmod>${sitemap.lastmod}</lastmod>\n`;
161
+ }
162
+ entry += ' </sitemap>';
163
+ return entry;
164
+ });
165
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join('\n')}\n</sitemapindex>`;
166
+ }
167
+ function escapeXml(str) {
168
+ return str
169
+ .replace(/&/g, '&')
170
+ .replace(/</g, '<')
171
+ .replace(/>/g, '>')
172
+ .replace(/"/g, '"')
173
+ .replace(/'/g, '&#x27;');
174
+ }
175
+ export const SEO = {
176
+ generateSitemap,
177
+ generateRobotsTxt,
178
+ jsonLd,
179
+ createCanonicalUrl,
180
+ generateOpenGraph,
181
+ generateTwitterCard,
182
+ validateSitemap,
183
+ getPriorityByDepth,
184
+ generateSitemapIndex
185
+ };
186
+ export default SEO;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @module Store
3
+ *
4
+ * Lightweight state management for OWL applications, inspired by Pinia/Vuex.
5
+ */
6
+ import { reactive } from '@odoo/owl';
7
+ const stores = new Map();
8
+ const plugins = [];
9
+ export class Store {
10
+ _id;
11
+ _config;
12
+ _state;
13
+ _getters;
14
+ _mutations;
15
+ _actions;
16
+ _subscribers;
17
+ _actionSubscribers;
18
+ constructor(id, config) {
19
+ this._id = id;
20
+ this._config = config;
21
+ this._state = reactive(config.state ? config.state() : {});
22
+ this._getters = {};
23
+ this._mutations = config.mutations || {};
24
+ this._actions = config.actions || {};
25
+ this._subscribers = [];
26
+ this._actionSubscribers = [];
27
+ if (config.getters) {
28
+ for (const [name, fn] of Object.entries(config.getters)) {
29
+ Object.defineProperty(this._getters, name, {
30
+ get: () => fn(this._state, this._getters),
31
+ enumerable: true,
32
+ configurable: true
33
+ });
34
+ }
35
+ }
36
+ for (const plugin of plugins) {
37
+ plugin(this);
38
+ }
39
+ }
40
+ get id() {
41
+ return this._id;
42
+ }
43
+ get state() {
44
+ return this._state;
45
+ }
46
+ get getters() {
47
+ return this._getters;
48
+ }
49
+ commit(type, payload) {
50
+ const mutation = this._mutations[type];
51
+ if (!mutation) {
52
+ throw new Error(`[metaowl] Mutation "${type}" not found in store "${this._id}"`);
53
+ }
54
+ const prevState = JSON.parse(JSON.stringify(this._state));
55
+ const result = mutation(this._state, payload);
56
+ for (const subscriber of this._subscribers) {
57
+ subscriber({ type, payload }, this._state, prevState);
58
+ }
59
+ return result;
60
+ }
61
+ async dispatch(type, payload) {
62
+ const action = this._actions[type];
63
+ if (!action) {
64
+ throw new Error(`[metaowl] Action "${type}" not found in store "${this._id}"`);
65
+ }
66
+ const context = {
67
+ state: this._state,
68
+ getters: this._getters,
69
+ commit: this.commit.bind(this),
70
+ dispatch: this.dispatch.bind(this)
71
+ };
72
+ for (const subscriber of this._actionSubscribers) {
73
+ subscriber({ type, payload }, 'before');
74
+ }
75
+ try {
76
+ const result = await action(context, payload);
77
+ for (const subscriber of this._actionSubscribers) {
78
+ subscriber({ type, payload }, 'after', result);
79
+ }
80
+ return result;
81
+ }
82
+ catch (error) {
83
+ for (const subscriber of this._actionSubscribers) {
84
+ subscriber({ type, payload }, 'error', error);
85
+ }
86
+ throw error;
87
+ }
88
+ }
89
+ subscribe(callback) {
90
+ this._subscribers.push(callback);
91
+ return () => {
92
+ const index = this._subscribers.indexOf(callback);
93
+ if (index > -1)
94
+ this._subscribers.splice(index, 1);
95
+ };
96
+ }
97
+ subscribeAction(callback) {
98
+ this._actionSubscribers.push(callback);
99
+ return () => {
100
+ const index = this._actionSubscribers.indexOf(callback);
101
+ if (index > -1)
102
+ this._actionSubscribers.splice(index, 1);
103
+ };
104
+ }
105
+ reset() {
106
+ if (this._config.state) {
107
+ const initialState = this._config.state();
108
+ Object.keys(this._state).forEach((key) => {
109
+ delete this._state[key];
110
+ });
111
+ Object.assign(this._state, initialState);
112
+ }
113
+ }
114
+ static define(id, config) {
115
+ return function useStore() {
116
+ if (!stores.has(id)) {
117
+ stores.set(id, new Store(id, config));
118
+ }
119
+ return stores.get(id);
120
+ };
121
+ }
122
+ static get(id) {
123
+ return stores.get(id);
124
+ }
125
+ static has(id) {
126
+ return stores.has(id);
127
+ }
128
+ static remove(id) {
129
+ const store = stores.get(id);
130
+ if (store) {
131
+ store.reset();
132
+ return stores.delete(id);
133
+ }
134
+ return false;
135
+ }
136
+ static clear() {
137
+ stores.clear();
138
+ }
139
+ static storeIds() {
140
+ return Array.from(stores.keys());
141
+ }
142
+ static use(plugin) {
143
+ plugins.push(plugin);
144
+ }
145
+ }
146
+ export function createPersistencePlugin(options = {}) {
147
+ const { storage = localStorage, key, paths } = options;
148
+ return function persistencePlugin(store) {
149
+ const storageKey = key || `metaowl:store:${store.id}`;
150
+ try {
151
+ const saved = storage.getItem(storageKey);
152
+ if (saved) {
153
+ const persisted = JSON.parse(saved);
154
+ if (paths) {
155
+ for (const path of paths) {
156
+ if (path in persisted && path in store.state) {
157
+ store.state[path] = persisted[path];
158
+ }
159
+ }
160
+ }
161
+ else {
162
+ Object.assign(store.state, persisted);
163
+ }
164
+ }
165
+ }
166
+ catch (error) {
167
+ console.warn('[metaowl] Failed to restore store from storage:', error);
168
+ }
169
+ store.subscribe((_mutation, state) => {
170
+ try {
171
+ const toPersist = paths
172
+ ? Object.fromEntries(paths.map((path) => [path, state[path]]))
173
+ : state;
174
+ storage.setItem(storageKey, JSON.stringify(toPersist));
175
+ }
176
+ catch (error) {
177
+ console.warn('[metaowl] Failed to persist store:', error);
178
+ }
179
+ });
180
+ };
181
+ }
182
+ export function createStore(initialState = {}) {
183
+ const state = reactive({ ...initialState });
184
+ state.$patch = (partialState) => {
185
+ Object.assign(state, partialState);
186
+ };
187
+ state.$reset = () => {
188
+ Object.keys(state).forEach((key) => {
189
+ if (!key.startsWith('$')) {
190
+ delete state[key];
191
+ }
192
+ });
193
+ Object.assign(state, initialState);
194
+ };
195
+ return state;
196
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @module TemplatesManager
3
+ *
4
+ * Template loading and merging utilities for OWL applications.
5
+ */
6
+ import { loadFile } from '@odoo/owl';
7
+ const LINK_COMPONENT_TEMPLATE = /* xml */ `
8
+ <t t-name="Link">
9
+ <a
10
+ t-att="forwardedAttrs"
11
+ t-att-href="props.to"
12
+ t-att-class="linkClasses"
13
+ t-att-target="props.target"
14
+ t-att-rel="linkRel"
15
+ t-att-title="props.title"
16
+ t-att-download="props.download"
17
+ t-on-click="onClick"
18
+ >
19
+ <t t-slot="default"/>
20
+ </a>
21
+ </t>
22
+ `;
23
+ const INTERNAL_TEMPLATES = [LINK_COMPONENT_TEMPLATE];
24
+ export async function mergeTemplates(files) {
25
+ const fileArray = Array.isArray(files) ? files : [files];
26
+ if (fileArray.length === 1) {
27
+ try {
28
+ const content = await loadFile(fileArray[0]);
29
+ if (content.trim().startsWith('<templates>')) {
30
+ return content.replace('</templates>', INTERNAL_TEMPLATES.join('') + '</templates>');
31
+ }
32
+ return '<templates>' + content + INTERNAL_TEMPLATES.join('') + '</templates>';
33
+ }
34
+ catch (error) {
35
+ console.error(`[metaowl] Failed to load template: ${fileArray[0]}`, error);
36
+ return '<templates>' + INTERNAL_TEMPLATES.join('') + '</templates>';
37
+ }
38
+ }
39
+ const results = await Promise.all(fileArray.map(async (file) => {
40
+ try {
41
+ return await loadFile(file);
42
+ }
43
+ catch (error) {
44
+ console.error(`[metaowl] Failed to load template: ${file}`, error);
45
+ return '';
46
+ }
47
+ }));
48
+ return '<templates>' + results.join('') + INTERNAL_TEMPLATES.join('') + '</templates>';
49
+ }
50
+ export function getInternalTemplates() {
51
+ return [...INTERNAL_TEMPLATES];
52
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @module TestUtils
3
+ *
4
+ * Testing utilities for MetaOwl OWL applications.
5
+ */
6
+ import { mount, reactive } from '@odoo/owl';
7
+ export function createMockStore(config = {}) {
8
+ const { state: initialState = {}, getters: getterDefs = {}, mutations: mutationDefs = {}, actions: actionDefs = {} } = config;
9
+ const state = reactive({ ...initialState });
10
+ const getters = {};
11
+ for (const [name, fn] of Object.entries(getterDefs)) {
12
+ Object.defineProperty(getters, name, {
13
+ get: () => fn(state),
14
+ enumerable: true
15
+ });
16
+ }
17
+ const mutations = {};
18
+ for (const [name, fn] of Object.entries(mutationDefs)) {
19
+ mutations[name] = (payload) => {
20
+ fn(state, payload);
21
+ };
22
+ }
23
+ const actions = {};
24
+ for (const [name, fn] of Object.entries(actionDefs)) {
25
+ actions[name] = async (payload) => {
26
+ const context = {
27
+ state,
28
+ getters,
29
+ commit: (mutation, nextPayload) => mutations[mutation]?.(nextPayload),
30
+ dispatch: (action, nextPayload) => actions[action]?.(nextPayload)
31
+ };
32
+ return await fn(context, payload);
33
+ };
34
+ }
35
+ return {
36
+ state,
37
+ getters,
38
+ mutations,
39
+ actions,
40
+ commit(name, payload) {
41
+ if (mutations[name]) {
42
+ mutations[name](payload);
43
+ }
44
+ else {
45
+ console.warn(`[TestUtils] Mutation '${name}' not found`);
46
+ }
47
+ },
48
+ async dispatch(name, payload) {
49
+ if (actions[name]) {
50
+ return await actions[name](payload);
51
+ }
52
+ console.warn(`[TestUtils] Action '${name}' not found`);
53
+ return undefined;
54
+ },
55
+ reset() {
56
+ Object.keys(state).forEach((key) => delete state[key]);
57
+ Object.assign(state, initialState);
58
+ },
59
+ setState(newState) {
60
+ Object.assign(state, newState);
61
+ }
62
+ };
63
+ }
64
+ export function mockRouter(config = {}) {
65
+ const { initialRoute = '/', routes = [] } = config;
66
+ const currentRoute = reactive({
67
+ path: initialRoute,
68
+ name: null,
69
+ params: {},
70
+ query: {},
71
+ hash: ''
72
+ });
73
+ const beforeEachGuards = [];
74
+ const afterEachHooks = [];
75
+ function parseUrl(url) {
76
+ const [pathAndQuery, hash = ''] = url.split('#');
77
+ const [path = '', queryString = ''] = pathAndQuery.split('?');
78
+ const query = {};
79
+ if (queryString) {
80
+ queryString.split('&').forEach((param) => {
81
+ const [key, value] = param.split('=');
82
+ query[decodeURIComponent(key)] = decodeURIComponent(value || '');
83
+ });
84
+ }
85
+ let matchedRoute = null;
86
+ let params = {};
87
+ for (const route of routes) {
88
+ const match = matchPath(path, route.path);
89
+ if (match) {
90
+ matchedRoute = route;
91
+ params = match.params;
92
+ break;
93
+ }
94
+ }
95
+ return {
96
+ path,
97
+ name: matchedRoute?.name || null,
98
+ params,
99
+ query,
100
+ hash
101
+ };
102
+ }
103
+ function matchPath(path, pattern) {
104
+ const paramNames = [];
105
+ const regexPattern = pattern
106
+ .replace(/\*/g, '.*')
107
+ .replace(/:([^/]+)/g, (_match, name) => {
108
+ paramNames.push(name);
109
+ return '([^/]+)';
110
+ });
111
+ const regex = new RegExp(`^${regexPattern}$`);
112
+ const match = path.match(regex);
113
+ if (!match)
114
+ return null;
115
+ const params = {};
116
+ paramNames.forEach((name, index) => {
117
+ params[name] = match[index + 1];
118
+ });
119
+ return { params };
120
+ }
121
+ Object.assign(currentRoute, parseUrl(initialRoute));
122
+ return {
123
+ currentRoute,
124
+ async push(path) {
125
+ const to = parseUrl(path);
126
+ const from = { ...currentRoute };
127
+ for (const guard of beforeEachGuards) {
128
+ const result = await guard(to, from, () => { });
129
+ if (result === false)
130
+ return;
131
+ }
132
+ Object.assign(currentRoute, to);
133
+ for (const hook of afterEachHooks) {
134
+ await hook(to, from);
135
+ }
136
+ },
137
+ async replace(path) {
138
+ await this.push(path);
139
+ },
140
+ back() {
141
+ // Mock - does nothing in test environment
142
+ },
143
+ beforeEach(guard) {
144
+ beforeEachGuards.push(guard);
145
+ return () => {
146
+ const index = beforeEachGuards.indexOf(guard);
147
+ if (index > -1)
148
+ beforeEachGuards.splice(index, 1);
149
+ };
150
+ },
151
+ afterEach(hook) {
152
+ afterEachHooks.push(hook);
153
+ return () => {
154
+ const index = afterEachHooks.indexOf(hook);
155
+ if (index > -1)
156
+ afterEachHooks.splice(index, 1);
157
+ };
158
+ },
159
+ resolve(name, params = {}) {
160
+ const route = routes.find((candidate) => candidate.name === name);
161
+ if (!route)
162
+ return '/';
163
+ let path = route.path;
164
+ for (const [key, value] of Object.entries(params)) {
165
+ path = path.replace(`:${key}`, String(value));
166
+ }
167
+ return path;
168
+ }
169
+ };
170
+ }
171
+ export async function mountComponent(ComponentClass, options = {}) {
172
+ const { props = {}, store = null, router = null, target = document.createElement('div') } = options;
173
+ const env = {};
174
+ if (store)
175
+ env.store = store;
176
+ if (router)
177
+ env.router = router;
178
+ return await mount(ComponentClass, target, {
179
+ props,
180
+ env
181
+ });
182
+ }
183
+ export function wait(ms) {
184
+ return new Promise((resolve) => setTimeout(resolve, ms));
185
+ }
186
+ export function nextTick() {
187
+ return new Promise((resolve) => {
188
+ requestAnimationFrame(() => resolve());
189
+ });
190
+ }
191
+ export async function flushPromises() {
192
+ await new Promise((resolve) => setTimeout(resolve, 0));
193
+ }
194
+ export const userEvent = {
195
+ async click(element) {
196
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
197
+ await flushPromises();
198
+ },
199
+ async type(input, text) {
200
+ input.value = text;
201
+ input.dispatchEvent(new Event('input', { bubbles: true }));
202
+ await flushPromises();
203
+ },
204
+ async submit(form) {
205
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
206
+ await flushPromises();
207
+ },
208
+ async select(select, value) {
209
+ select.value = value;
210
+ select.dispatchEvent(new Event('change', { bubbles: true }));
211
+ await flushPromises();
212
+ }
213
+ };
214
+ export const dom = {
215
+ query(selector, container = document) {
216
+ return container.querySelector(selector);
217
+ },
218
+ queryAll(selector, container = document) {
219
+ return container.querySelectorAll(selector);
220
+ },
221
+ hasClass(element, className) {
222
+ return element?.classList?.contains(className) || false;
223
+ },
224
+ text(element) {
225
+ return element?.textContent?.trim() || '';
226
+ }
227
+ };
228
+ export const TestUtils = {
229
+ createMockStore,
230
+ mockRouter,
231
+ mountComponent,
232
+ wait,
233
+ nextTick,
234
+ flushPromises,
235
+ userEvent,
236
+ dom
237
+ };
238
+ export default TestUtils;