metaowl 0.1.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,40 @@
1
+ import { mount } from '@odoo/owl'
2
+ import { mergeTemplates } from './templates-manager.js'
3
+
4
+ const _defaults = {
5
+ warnIfNoStaticProps: true,
6
+ willStartTimeout: 10000,
7
+ translatableAttributes: ['title', 'placeholder', 'label', 'alt']
8
+ }
9
+
10
+ let _config = { ..._defaults }
11
+
12
+ /**
13
+ * Override or extend the default OWL mount configuration.
14
+ * Call before boot() in your project's metaowl.js.
15
+ *
16
+ * @param {object} config - Partial OWL config merged over the defaults.
17
+ */
18
+ export function configureOwl(config) {
19
+ _config = { ..._defaults, ...config }
20
+ }
21
+
22
+ /**
23
+ * Mount the resolved route's OWL component into `#app`.
24
+ *
25
+ * Loads and merges all XML templates (collected at build time by the
26
+ * metaowl Vite plugin via the `COMPONENTS` define), then mounts the component
27
+ * using the active OWL config.
28
+ *
29
+ * @param {object[]} route - Single-element array returned by `processRoutes()`.
30
+ * @returns {Promise<void>}
31
+ */
32
+ export async function mountApp(route) {
33
+ // COMPONENTS is a string[] injected at build time by the metaowl Vite plugin
34
+ const components = typeof COMPONENTS !== 'undefined' ? COMPONENTS : []
35
+ const templates = await mergeTemplates(components)
36
+ const mountElement = document.getElementById('metaowl')
37
+ mountElement.innerHTML = ''
38
+
39
+ await mount(route[0].component, mountElement, { ..._config, templates })
40
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Async-style localStorage wrapper.
3
+ *
4
+ * Values are automatically JSON-serialised on write and deserialised on read.
5
+ * All methods return Promises so they are interchangeable with IndexedDB-based
6
+ * alternatives without changing call-sites.
7
+ */
8
+ export default class Cache {
9
+ /**
10
+ * Retrieve a value by key.
11
+ *
12
+ * @param {string} key
13
+ * @returns {Promise<any>} Parsed value, or `null` if the key does not exist.
14
+ */
15
+ static async get(key) {
16
+ return JSON.parse(localStorage.getItem(key))
17
+ }
18
+
19
+ /**
20
+ * Store a value under the given key.
21
+ *
22
+ * @param {string} key
23
+ * @param {any} value - Must be JSON-serialisable.
24
+ * @returns {Promise<void>}
25
+ */
26
+ static async set(key, value) {
27
+ localStorage.setItem(key, JSON.stringify(value))
28
+ }
29
+
30
+ /**
31
+ * Remove a single entry.
32
+ *
33
+ * @param {string} key
34
+ * @returns {Promise<void>}
35
+ */
36
+ static async remove(key) {
37
+ localStorage.removeItem(key)
38
+ }
39
+
40
+ /**
41
+ * Remove **all** entries from localStorage.
42
+ *
43
+ * @returns {Promise<void>}
44
+ */
45
+ static async clear() {
46
+ localStorage.clear()
47
+ }
48
+
49
+ /**
50
+ * Return all keys currently stored in localStorage.
51
+ *
52
+ * @returns {Promise<string[]>}
53
+ */
54
+ static async keys() {
55
+ return Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
56
+ }
57
+ }
@@ -0,0 +1,44 @@
1
+ export default class Fetch {
2
+ static _baseUrl = ''
3
+ static _onError = null
4
+
5
+ /**
6
+ * Configure the Fetch helper. Call once in your metaowl.js before boot().
7
+ *
8
+ * @param {object} options
9
+ * @param {string} [options.baseUrl=''] - Base URL prepended to every internal request.
10
+ * @param {function} [options.onError] - Callback invoked on network errors.
11
+ */
12
+ static configure({ baseUrl = '', onError = null } = {}) {
13
+ Fetch._baseUrl = baseUrl
14
+ Fetch._onError = onError
15
+ }
16
+
17
+ /**
18
+ * Perform a fetch request.
19
+ *
20
+ * @param {string} url - Path or full URL.
21
+ * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} [method='GET']
22
+ * @param {object|null} [data=null] - Request body (JSON-serialised).
23
+ * @param {boolean} [internal=true] - Prepend baseUrl when true.
24
+ * @param {boolean} [triggerErrorHandler=true] - Call onError callback on failure.
25
+ * @returns {Promise<any|null>}
26
+ */
27
+ static async url(url, method = 'GET', data = null, internal = true, triggerErrorHandler = true) {
28
+ const fullUrl = `${internal ? Fetch._baseUrl : ''}${url}`
29
+
30
+ const response = await fetch(fullUrl, {
31
+ method,
32
+ body: data ? JSON.stringify(data) : null
33
+ }).catch(error => {
34
+ console.warn('[metaowl] Fetch error:', error)
35
+ if (triggerErrorHandler && Fetch._onError) {
36
+ Fetch._onError(error)
37
+ }
38
+ })
39
+
40
+ if (!response) return null
41
+
42
+ return response.json()
43
+ }
44
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Derives a URL path from an import.meta.glob key.
3
+ *
4
+ * Convention (mirrors Nuxt/Next.js file-based routing):
5
+ * './pages/index/Index.js' → '/'
6
+ * './pages/about/About.js' → '/about'
7
+ * './pages/about/bla/Bla.js' → '/about/bla'
8
+ *
9
+ * Rule: the *directory* path relative to pages/ becomes the URL.
10
+ * A top-level directory named 'index' maps to '/'.
11
+ *
12
+ * @param {string} key - import.meta.glob key, e.g. './pages/about/About.js'
13
+ * @returns {string} URL path
14
+ */
15
+ function pathFromKey(key) {
16
+ // Strip leading './' and 'pages/' prefix
17
+ const rel = key.replace(/^\.\/pages\//, '')
18
+ // Drop the filename, keep directory segments
19
+ const dirParts = rel.split('/').slice(0, -1)
20
+ if (dirParts.length === 1 && dirParts[0] === 'index') return '/'
21
+ return '/' + dirParts.join('/')
22
+ }
23
+
24
+ /**
25
+ * Extracts the page component from an eagerly-imported module.
26
+ * Prefers default export, falls back to the first function export.
27
+ *
28
+ * @param {object} mod - Eagerly imported module
29
+ * @param {string} key - Glob key (for error messages)
30
+ * @returns {Function}
31
+ */
32
+ function componentFromModule(mod, key) {
33
+ if (typeof mod.default === 'function') return mod.default
34
+ const named = Object.values(mod).find(v => typeof v === 'function')
35
+ if (!named) throw new Error(`[metaowl] No component export found in "${key}"`)
36
+ return named
37
+ }
38
+
39
+ /**
40
+ * Builds a metaowl route table from an import.meta.glob result.
41
+ *
42
+ * Usage in src/metaowl.js:
43
+ *
44
+ * import { boot } from 'metaowl'
45
+ * boot(import.meta.glob('./pages/**\/*.js', { eager: true }))
46
+ *
47
+ * @param {Record<string, object>} modules - Result of import.meta.glob({ eager: true })
48
+ * @returns {object[]} Route table for processRoutes()
49
+ */
50
+ export function buildRoutes(modules) {
51
+ return Object.entries(modules).map(([key, mod]) => {
52
+ const path = pathFromKey(key)
53
+ const name = path === '/' ? 'index' : path.slice(1).replace(/\//g, '-')
54
+ return {
55
+ name,
56
+ path: [path],
57
+ component: componentFromModule(mod, key)
58
+ }
59
+ })
60
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @module Meta
3
+ *
4
+ * Programmatic helpers for managing document meta tags at runtime.
5
+ *
6
+ * Each function is idempotent: the relevant `<meta>` or `<link>` element is
7
+ * created on first call if it does not already exist, then its content is
8
+ * updated on every subsequent call as well.
9
+ *
10
+ * Import the entire namespace via:
11
+ * import { Meta } from 'metaowl'
12
+ * Meta.title('My Page')
13
+ */
14
+
15
+ function _nameMeta(name, value) {
16
+ if (!value) return
17
+ let el = document.querySelector(`meta[name="${name}"]`)
18
+ if (!el) {
19
+ el = document.createElement('meta')
20
+ el.name = name
21
+ document.head.appendChild(el)
22
+ }
23
+ el.content = value
24
+ }
25
+
26
+ function _propMeta(property, value) {
27
+ if (!value) return
28
+ let el = document.querySelector(`meta[property="${property}"]`)
29
+ if (!el) {
30
+ el = document.createElement('meta')
31
+ el.setAttribute('property', property)
32
+ document.head.appendChild(el)
33
+ }
34
+ el.content = value
35
+ }
36
+
37
+ /** Set the document `<title>`. @param {string} title */
38
+ export function title(title) {
39
+ if (!title) return
40
+ document.title = title
41
+ }
42
+
43
+ /** @param {string} description */
44
+ export function description(description) { _nameMeta('description', description) }
45
+
46
+ /** @param {string} keywords */
47
+ export function keywords(keywords) { _nameMeta('keywords', keywords) }
48
+
49
+ /** @param {string} author */
50
+ export function author(author) { _nameMeta('author', author) }
51
+
52
+ /** @param {string} url */
53
+ export function canonical(url) {
54
+ if (!url) return
55
+ let el = document.querySelector('link[rel="canonical"]')
56
+ if (!el) {
57
+ el = document.createElement('link')
58
+ el.rel = 'canonical'
59
+ document.head.appendChild(el)
60
+ }
61
+ el.href = url
62
+ }
63
+
64
+ /** @param {string} title */
65
+ export function ogTitle(title) { _propMeta('og:title', title) }
66
+
67
+ /** @param {string} description */
68
+ export function ogDescription(description) { _propMeta('og:description', description) }
69
+
70
+ /** @param {string} image */
71
+ export function ogImage(image) { _propMeta('og:image', image) }
72
+
73
+ /** @param {string} url */
74
+ export function ogUrl(url) { _propMeta('og:url', url) }
75
+
76
+ /** @param {string} type */
77
+ export function ogType(type) { _propMeta('og:type', type) }
78
+
79
+ /** @param {string} siteName */
80
+ export function ogSiteName(siteName) { _propMeta('og:site_name', siteName) }
81
+
82
+ /** @param {string} locale */
83
+ export function ogLocale(locale) { _propMeta('og:locale', locale) }
84
+
85
+ /** @param {string|number} width */
86
+ export function ogImageWidth(width) { _propMeta('og:image:width', width) }
87
+
88
+ /** @param {string|number} height */
89
+ export function ogImageHeight(height) { _propMeta('og:image:height', height) }
90
+
91
+ /** @param {string} card */
92
+ export function twitterCard(card) { _nameMeta('twitter:card', card) }
93
+
94
+ /** @param {string} site */
95
+ export function twitterSite(site) { _nameMeta('twitter:site', site) }
96
+
97
+ /** @param {string} creator */
98
+ export function twitterCreator(creator) { _nameMeta('twitter:creator', creator) }
99
+
100
+ /** @param {string} title */
101
+ export function twitterTitle(title) { _nameMeta('twitter:title', title) }
102
+
103
+ /** @param {string} description */
104
+ export function twitterDescription(description) { _nameMeta('twitter:description', description) }
105
+
106
+ /** @param {string} image */
107
+ export function twitterImage(image) { _nameMeta('twitter:image', image) }
108
+
109
+ /** @param {string} alt */
110
+ export function twitterImageAlt(alt) { _nameMeta('twitter:image:alt', alt) }
111
+
112
+ /** @param {string} url */
113
+ export function twitterUrl(url) { _nameMeta('twitter:url', url) }
114
+
115
+ /** @param {string} siteId */
116
+ export function twitterSiteId(siteId) { _nameMeta('twitter:site:id', siteId) }
117
+
118
+ /** @param {string} creatorId */
119
+ export function twitterCreatorId(creatorId) { _nameMeta('twitter:creator:id', creatorId) }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Resolves the current URL against a route table.
3
+ * Used internally by processRoutes().
4
+ */
5
+ class Router {
6
+ constructor(routes) {
7
+ this.routes = routes
8
+ }
9
+
10
+ resolve() {
11
+ const match = this.routes.filter(route =>
12
+ (typeof route.path === 'string' && document.location.pathname === route.path) ||
13
+ (Array.isArray(route.path) && route.path.includes(document.location.pathname))
14
+ )
15
+
16
+ if (match.length !== 1) {
17
+ throw new Error(`No route found for "${document.location.pathname}".`)
18
+ }
19
+
20
+ return match
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Injects SSG-compatible path variants for a route:
26
+ * trailing slash, .html suffix, /index.html suffix.
27
+ *
28
+ * @param {object} route
29
+ * @param {string} path
30
+ * @returns {object}
31
+ */
32
+ function injectSystemRoutes(route, path) {
33
+ if (path === '/') {
34
+ if (!route.path.includes('/index.html')) route.path.push('/index.html')
35
+ } else {
36
+ if (!route.path.includes(`${path}.html`)) route.path.push(`${path}.html`)
37
+ if (!route.path.includes(`${path}/`)) route.path.push(`${path}/`)
38
+ if (!route.path.includes(`${path}/index.html`)) route.path.push(`${path}/index.html`)
39
+ }
40
+
41
+ return route
42
+ }
43
+
44
+ /**
45
+ * Expands all routes with SSG path variants, then resolves the current URL.
46
+ *
47
+ * @param {object[]} routes
48
+ * @returns {Promise<object[]>}
49
+ */
50
+ export async function processRoutes(routes) {
51
+ for (const route of routes) {
52
+ const originalPaths = [...route.path]
53
+ for (const path of originalPaths) {
54
+ if (typeof path === 'string') {
55
+ injectSystemRoutes(route, path)
56
+ }
57
+ }
58
+ }
59
+
60
+ return new Router(routes).resolve()
61
+ }
62
+
@@ -0,0 +1,20 @@
1
+ import { loadFile } from '@odoo/owl'
2
+
3
+ /**
4
+ * Loads and concatenates a list of OWL XML template files into a single
5
+ * `<templates>` string ready to be passed to OWL's mount() options.
6
+ *
7
+ * @param {string[]} files - Array of URL-style XML paths, e.g. ['/owl/components/Header/Header.xml']
8
+ * @returns {Promise<string>}
9
+ */
10
+ export async function mergeTemplates(files) {
11
+ let templates = '<templates>'
12
+ for (const file of files) {
13
+ try {
14
+ templates += await loadFile(file)
15
+ } catch (e) {
16
+ console.error(`[metaowl] Failed to load template: ${file}`, e)
17
+ }
18
+ }
19
+ return templates + '</templates>'
20
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "metaowl",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./vite": "./vite/plugin.js",
10
+ "./eslint": "./eslint.js",
11
+ "./postcss": "./postcss.cjs"
12
+ },
13
+ "bin": {
14
+ "metaowl-create": "./bin/metaowl-create.js",
15
+ "metaowl-dev": "./bin/metaowl-dev.js",
16
+ "metaowl-build": "./bin/metaowl-build.js",
17
+ "metaowl-generate": "./bin/metaowl-generate.js",
18
+ "metaowl-lint": "./bin/metaowl-lint.js"
19
+ },
20
+ "keywords": [
21
+ "owl",
22
+ "odoo",
23
+ "framework",
24
+ "vite",
25
+ "meta-framework"
26
+ ],
27
+ "author": "Dennis Schott",
28
+ "license": "LGPL-3.0-only",
29
+ "sideEffects": false,
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/dennisschott/metaowl"
33
+ },
34
+ "homepage": "https://github.com/dennisschott/metaowl#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/dennisschott/metaowl/issues"
37
+ },
38
+ "dependencies": {
39
+ "@eslint/js": "^9.20.1",
40
+ "@fullhuman/postcss-purgecss": "^6.0.0",
41
+ "@odoo/owl": "^2.8.2",
42
+ "@typescript-eslint/eslint-plugin": "^8.24.1",
43
+ "@typescript-eslint/parser": "^8.24.1",
44
+ "dotenv": "^16.4.7",
45
+ "eslint": "^9.20.1",
46
+ "eslint-plugin-import": "^2.31.0",
47
+ "eslint-plugin-n": "^17.15.1",
48
+ "eslint-plugin-promise": "^7.2.1",
49
+ "glob": "^13.0.6",
50
+ "globals": "^13.24.0",
51
+ "prettier": "3.5.1",
52
+ "vite": "^7.3.1",
53
+ "vite-plugin-handlebars": "^2.0.0",
54
+ "vite-plugin-restart": "^2.0.0",
55
+ "vite-tsconfig-paths": "^6.1.1"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ },
60
+ "scripts": {
61
+ "test": "vitest run",
62
+ "test:watch": "vitest"
63
+ },
64
+ "devDependencies": {
65
+ "jsdom": "^28.1.0",
66
+ "vitest": "^4.0.18"
67
+ }
68
+ }
package/postcss.cjs ADDED
@@ -0,0 +1,43 @@
1
+ // CommonJS PostCSS config factory for metaowl projects.
2
+ // Usage in postcss.config.cjs:
3
+ //
4
+ // const { createPostcssConfig } = require('metaowl/postcss')
5
+ // module.exports = createPostcssConfig()
6
+ //
7
+ // Override safelist or add content globs:
8
+ //
9
+ // module.exports = createPostcssConfig({
10
+ // safelist: [/^my-custom-class/],
11
+ // content: ['./templates/**/*.html']
12
+ // })
13
+
14
+ const defaultSafelist = []
15
+
16
+ function createPostcssConfig(options = {}) {
17
+ const {
18
+ safelist = [],
19
+ content = [],
20
+ additionalPlugins = []
21
+ } = options
22
+
23
+ return {
24
+ plugins: [
25
+ ...process.env.NODE_ENV === 'production'
26
+ ? [
27
+ require('@fullhuman/postcss-purgecss')({
28
+ content: [
29
+ './**/*.xml',
30
+ './**/*.html',
31
+ './src/**/*.js',
32
+ ...content
33
+ ],
34
+ safelist: [...defaultSafelist, ...safelist]
35
+ })
36
+ ]
37
+ : [],
38
+ ...additionalPlugins
39
+ ]
40
+ }
41
+ }
42
+
43
+ module.exports = { createPostcssConfig }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import Cache from '../modules/cache.js'
3
+
4
+ // Minimal localStorage stub
5
+ const store = {}
6
+ const localStorageMock = {
7
+ getItem: (k) => store[k] ?? null,
8
+ setItem: (k, v) => { store[k] = v },
9
+ removeItem: (k) => { delete store[k] },
10
+ clear: () => { Object.keys(store).forEach(k => delete store[k]) },
11
+ get length() { return Object.keys(store).length },
12
+ key: (i) => Object.keys(store)[i] ?? null
13
+ }
14
+ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
15
+
16
+ beforeEach(() => localStorageMock.clear())
17
+
18
+ describe('Cache', () => {
19
+ it('set and get a string value', async () => {
20
+ await Cache.set('name', 'metaowl')
21
+ expect(await Cache.get('name')).toBe('metaowl')
22
+ })
23
+
24
+ it('set and get an object', async () => {
25
+ await Cache.set('obj', { a: 1, b: [2, 3] })
26
+ expect(await Cache.get('obj')).toEqual({ a: 1, b: [2, 3] })
27
+ })
28
+
29
+ it('get returns null for missing key', async () => {
30
+ expect(await Cache.get('missing')).toBeNull()
31
+ })
32
+
33
+ it('remove deletes the key', async () => {
34
+ await Cache.set('tmp', 'x')
35
+ await Cache.remove('tmp')
36
+ expect(await Cache.get('tmp')).toBeNull()
37
+ })
38
+
39
+ it('clear removes all keys', async () => {
40
+ await Cache.set('a', 1)
41
+ await Cache.set('b', 2)
42
+ await Cache.clear()
43
+ expect(await Cache.get('a')).toBeNull()
44
+ expect(await Cache.get('b')).toBeNull()
45
+ })
46
+
47
+ it('keys returns all stored keys', async () => {
48
+ await Cache.set('x', 1)
49
+ await Cache.set('y', 2)
50
+ const keys = await Cache.keys()
51
+ expect(keys).toContain('x')
52
+ expect(keys).toContain('y')
53
+ expect(keys).toHaveLength(2)
54
+ })
55
+ })
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import Fetch from '../modules/fetch.js'
3
+
4
+ beforeEach(() => {
5
+ Fetch.configure({ baseUrl: '', onError: null })
6
+ vi.restoreAllMocks()
7
+ })
8
+
9
+ describe('Fetch.configure', () => {
10
+ it('sets baseUrl', () => {
11
+ Fetch.configure({ baseUrl: 'https://api.example.com' })
12
+ expect(Fetch._baseUrl).toBe('https://api.example.com')
13
+ })
14
+
15
+ it('sets onError callback', () => {
16
+ const handler = vi.fn()
17
+ Fetch.configure({ onError: handler })
18
+ expect(Fetch._onError).toBe(handler)
19
+ })
20
+
21
+ it('defaults baseUrl to empty string', () => {
22
+ Fetch.configure({})
23
+ expect(Fetch._baseUrl).toBe('')
24
+ })
25
+ })
26
+
27
+ describe('Fetch.url', () => {
28
+ it('prepends baseUrl for internal requests', async () => {
29
+ Fetch.configure({ baseUrl: 'https://api.example.com' })
30
+ const mockFetch = vi.fn().mockResolvedValue({
31
+ json: () => Promise.resolve({ ok: true })
32
+ })
33
+ vi.stubGlobal('fetch', mockFetch)
34
+
35
+ await Fetch.url('/items')
36
+ expect(mockFetch).toHaveBeenCalledWith(
37
+ 'https://api.example.com/items',
38
+ expect.objectContaining({ method: 'GET' })
39
+ )
40
+ })
41
+
42
+ it('does not prepend baseUrl when internal=false', async () => {
43
+ Fetch.configure({ baseUrl: 'https://api.example.com' })
44
+ const mockFetch = vi.fn().mockResolvedValue({
45
+ json: () => Promise.resolve({})
46
+ })
47
+ vi.stubGlobal('fetch', mockFetch)
48
+
49
+ await Fetch.url('https://other.com/data', 'GET', null, false)
50
+ expect(mockFetch).toHaveBeenCalledWith(
51
+ 'https://other.com/data',
52
+ expect.anything()
53
+ )
54
+ })
55
+
56
+ it('sends JSON body for POST requests', async () => {
57
+ const mockFetch = vi.fn().mockResolvedValue({
58
+ json: () => Promise.resolve({})
59
+ })
60
+ vi.stubGlobal('fetch', mockFetch)
61
+
62
+ await Fetch.url('/items', 'POST', { name: 'test' })
63
+ expect(mockFetch).toHaveBeenCalledWith(
64
+ '/items',
65
+ expect.objectContaining({
66
+ method: 'POST',
67
+ body: JSON.stringify({ name: 'test' })
68
+ })
69
+ )
70
+ })
71
+
72
+ it('returns null and calls onError on network failure', async () => {
73
+ const onError = vi.fn()
74
+ Fetch.configure({ onError })
75
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
76
+
77
+ const result = await Fetch.url('/fail')
78
+ expect(result).toBeNull()
79
+ expect(onError).toHaveBeenCalledOnce()
80
+ })
81
+
82
+ it('returns null and skips onError when triggerErrorHandler=false', async () => {
83
+ const onError = vi.fn()
84
+ Fetch.configure({ onError })
85
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
86
+
87
+ const result = await Fetch.url('/fail', 'GET', null, true, false)
88
+ expect(result).toBeNull()
89
+ expect(onError).not.toHaveBeenCalled()
90
+ })
91
+
92
+ it('returns parsed JSON on success', async () => {
93
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
94
+ json: () => Promise.resolve({ id: 42 })
95
+ }))
96
+
97
+ const result = await Fetch.url('/item/42')
98
+ expect(result).toEqual({ id: 42 })
99
+ })
100
+ })