Skip to content

Commit 72df9bc

Browse files
authored
test: add test case to cover all codebase (#41)
* test: setup vitest with coverage * test(utils): add unit testing for isElementOfType and getChildren * test(vitest): add ui script * test(adapter): properly test adapters * test: add test case to cover all codebase * fix(builder): missing manifest key and remove unused try-catch
1 parent dfd1960 commit 72df9bc

31 files changed

Lines changed: 2090 additions & 127 deletions

.changeset/frank-books-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@devsantara/head': patch
3+
---
4+
5+
test: add test case to cover all codebase
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@devsantara/head': minor
3+
---
4+
5+
fix(builder): missing manifest key and remove unused try-catch

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ node_modules/
44
# Build
55
dist/
66

7+
# Testing
8+
coverage/
9+
710
# Misc
811
.DS_Store

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"build": "tsdown",
3838
"dev": "tsdown --watch",
3939
"test": "vitest",
40+
"test:ui": "vitest --ui",
41+
"test:coverage": "vitest run --coverage",
4042
"lint": "oxlint --type-aware",
4143
"lint:fix": "oxlint --type-aware --fix",
4244
"lint:ts": "tsc --noEmit",
@@ -49,6 +51,8 @@
4951
},
5052
"devDependencies": {
5153
"@changesets/cli": "^2.29.8",
54+
"@vitest/coverage-v8": "4.0.18",
55+
"@vitest/ui": "4.0.18",
5256
"oxfmt": "^0.27.0",
5357
"oxlint": "^1.42.0",
5458
"oxlint-tsgolint": "^0.11.4",

pnpm-lock.yaml

Lines changed: 311 additions & 110 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as React from 'react';
2+
import { describe, it, expect } from 'vitest';
3+
import { HeadReactAdapter } from '../react-adapter';
4+
import type { HeadElement } from '../../types';
5+
6+
describe('HeadReactAdapter', () => {
7+
const adapter = new HeadReactAdapter();
8+
9+
describe('transform', () => {
10+
it('should returns empty array for empty input', () => {
11+
expect(adapter.transform([])).toEqual([]);
12+
});
13+
14+
it('should converts elements to React components with key pattern "head-{type}-{index}"', () => {
15+
const elements: HeadElement[] = [
16+
{ type: 'title', attributes: { children: 'My Page' } },
17+
{
18+
type: 'meta',
19+
attributes: { name: 'description', content: 'A description' },
20+
},
21+
{ type: 'link', attributes: { rel: 'icon', href: '/favicon.ico' } },
22+
{ type: 'script', attributes: { src: '/script.js', async: true } },
23+
{ type: 'style', attributes: { children: 'body { margin: 0; }' } },
24+
];
25+
26+
const result = adapter.transform(elements);
27+
28+
expect(result).toHaveLength(elements.length);
29+
result.forEach((node, index) => {
30+
expect(React.isValidElement(node)).toBe(true);
31+
expect(node).toEqual(
32+
React.createElement(elements[index].type, {
33+
key: `head-${elements[index].type}-${index}`,
34+
...elements[index].attributes,
35+
}),
36+
);
37+
});
38+
});
39+
});
40+
});
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { HeadTanstackRouterAdapter } from '../tanstack-router-adapter';
3+
import type { HeadElement } from '../../types';
4+
5+
describe('HeadTanstackRouterAdapter', () => {
6+
const adapter = new HeadTanstackRouterAdapter();
7+
8+
describe('transform', () => {
9+
it('should return empty arrays when given empty elements', () => {
10+
const result = adapter.transform([]);
11+
expect(result).toEqual({
12+
meta: [],
13+
links: [],
14+
scripts: [],
15+
styles: [],
16+
});
17+
});
18+
19+
it('should transform meta element', () => {
20+
const elements: HeadElement[] = [
21+
{
22+
type: 'meta',
23+
attributes: { name: 'viewport', content: 'width=device-width' },
24+
},
25+
];
26+
27+
const result = adapter.transform(elements);
28+
29+
expect(result.meta).toHaveLength(1);
30+
expect(result.meta?.[0]).toEqual({
31+
name: 'viewport',
32+
content: 'width=device-width',
33+
});
34+
expect(result.links).toHaveLength(0);
35+
expect(result.scripts).toHaveLength(0);
36+
expect(result.styles).toHaveLength(0);
37+
});
38+
39+
it('should transform link element', () => {
40+
const elements: HeadElement[] = [
41+
{
42+
type: 'link',
43+
attributes: { rel: 'icon', href: '/favicon.ico' },
44+
},
45+
];
46+
47+
const result = adapter.transform(elements);
48+
49+
expect(result.links).toHaveLength(1);
50+
expect(result.links?.[0]).toEqual({ rel: 'icon', href: '/favicon.ico' });
51+
expect(result.meta).toHaveLength(0);
52+
expect(result.scripts).toHaveLength(0);
53+
expect(result.styles).toHaveLength(0);
54+
});
55+
56+
it('should transform script element', () => {
57+
const elements: HeadElement[] = [
58+
{
59+
type: 'script',
60+
attributes: { src: '/script.js', async: true },
61+
},
62+
];
63+
64+
const result = adapter.transform(elements);
65+
66+
expect(result.scripts).toHaveLength(1);
67+
expect(result.scripts?.[0]).toEqual({ src: '/script.js', async: true });
68+
expect(result.meta).toHaveLength(0);
69+
expect(result.links).toHaveLength(0);
70+
expect(result.styles).toHaveLength(0);
71+
});
72+
73+
it('should transform style element', () => {
74+
const elements: HeadElement[] = [
75+
{
76+
type: 'style',
77+
attributes: { children: 'body { margin: 0; }' },
78+
},
79+
];
80+
81+
const result = adapter.transform(elements);
82+
83+
expect(result.styles).toHaveLength(1);
84+
expect(result.styles?.[0]).toEqual({ children: 'body { margin: 0; }' });
85+
expect(result.meta).toHaveLength(0);
86+
expect(result.links).toHaveLength(0);
87+
expect(result.scripts).toHaveLength(0);
88+
});
89+
90+
it('should transform title element into meta with title property', () => {
91+
const elements: HeadElement[] = [
92+
{
93+
type: 'title',
94+
attributes: { children: 'My Page' },
95+
},
96+
];
97+
98+
const result = adapter.transform(elements);
99+
100+
expect(result.meta).toHaveLength(1);
101+
expect(result.meta?.[0]).toEqual({ title: 'My Page' });
102+
expect(result.links).toHaveLength(0);
103+
expect(result.scripts).toHaveLength(0);
104+
expect(result.styles).toHaveLength(0);
105+
});
106+
107+
it('should transform multiple elements into categorized configuration', () => {
108+
const elements: HeadElement[] = [
109+
{
110+
type: 'title',
111+
attributes: { children: 'My Page' },
112+
},
113+
{
114+
type: 'meta',
115+
attributes: { name: 'description', content: 'A description' },
116+
},
117+
{
118+
type: 'meta',
119+
attributes: { name: 'viewport', content: 'width=device-width' },
120+
},
121+
{
122+
type: 'link',
123+
attributes: { rel: 'icon', href: '/favicon.ico' },
124+
},
125+
{
126+
type: 'link',
127+
attributes: { rel: 'stylesheet', href: '/styles.css' },
128+
},
129+
{
130+
type: 'script',
131+
attributes: { src: '/script.js', async: true },
132+
},
133+
{
134+
type: 'script',
135+
attributes: { children: 'console.log("Hello World!");', async: true },
136+
},
137+
{
138+
type: 'style',
139+
attributes: { children: 'body { margin: 0; }' },
140+
},
141+
];
142+
143+
const result = adapter.transform(elements);
144+
145+
expect(result.meta).toHaveLength(3);
146+
expect(result.meta?.[0]).toEqual({ title: 'My Page' });
147+
expect(result.meta?.[1]).toEqual({
148+
name: 'description',
149+
content: 'A description',
150+
});
151+
expect(result.meta?.[2]).toEqual({
152+
name: 'viewport',
153+
content: 'width=device-width',
154+
});
155+
156+
expect(result.links).toHaveLength(2);
157+
expect(result.links?.[0]).toEqual({ rel: 'icon', href: '/favicon.ico' });
158+
expect(result.links?.[1]).toEqual({
159+
rel: 'stylesheet',
160+
href: '/styles.css',
161+
});
162+
163+
expect(result.scripts).toHaveLength(2);
164+
expect(result.scripts?.[0]).toEqual({ src: '/script.js', async: true });
165+
expect(result.scripts?.[1]).toEqual({
166+
children: 'console.log("Hello World!");',
167+
async: true,
168+
});
169+
170+
expect(result.styles).toHaveLength(1);
171+
expect(result.styles?.[0]).toEqual({ children: 'body { margin: 0; }' });
172+
});
173+
174+
it('should ignore unknown element types', () => {
175+
const elements: HeadElement[] = [
176+
{
177+
type: 'meta',
178+
attributes: { name: 'description', content: 'A description' },
179+
},
180+
{
181+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
182+
type: 'base' as any,
183+
attributes: { href: 'https://devsantara.com' },
184+
},
185+
{
186+
type: 'meta',
187+
attributes: { name: 'viewport', content: 'width=device-width' },
188+
},
189+
];
190+
191+
const result = adapter.transform(elements);
192+
193+
// Only the meta element should be included
194+
expect(result.meta).toHaveLength(2);
195+
expect(result.meta?.[0]).toEqual({
196+
name: 'description',
197+
content: 'A description',
198+
});
199+
expect(result.meta?.[1]).toEqual({
200+
name: 'viewport',
201+
content: 'width=device-width',
202+
});
203+
expect(result.links).toHaveLength(0);
204+
expect(result.scripts).toHaveLength(0);
205+
expect(result.styles).toHaveLength(0);
206+
});
207+
});
208+
});

src/builder.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,8 @@ export class HeadBuilder<TOutput = HeadElement[]> {
111111
}
112112

113113
// Resolve relative URL against metadataBase
114-
try {
115-
const resolved = new URL(url, this.metadataBase);
116-
return resolved.href;
117-
} catch {
118-
// If URL construction fails, return raw url
119-
return url;
120-
}
114+
const resolved = new URL(url, this.metadataBase);
115+
return resolved.href;
121116
}
122117

123118
/**
@@ -146,6 +141,9 @@ export class HeadBuilder<TOutput = HeadElement[]> {
146141
if (attributes.rel === 'canonical') {
147142
return 'link:canonical';
148143
}
144+
if (attributes.rel === 'manifest') {
145+
return 'link:manifest';
146+
}
149147
if (attributes.rel === 'alternate' && 'hrefLang' in attributes) {
150148
return `link:alternate:${attributes.hrefLang}`;
151149
}
@@ -216,7 +214,7 @@ export class HeadBuilder<TOutput = HeadElement[]> {
216214
* @example
217215
* new HeadBuilder()
218216
* .addScript('/script.js')
219-
* .addScript(new URL('https://example.com/script.js'), { async: true })
217+
* .addScript(new URL('https://devsantara.com/script.js'), { async: true })
220218
* .addScript({ code: 'console.log("Hello, World!")' })
221219
* .build();
222220
*/
@@ -656,7 +654,7 @@ export class HeadBuilder<TOutput = HeadElement[]> {
656654
* @returns The builder instance for method chaining
657655
*
658656
* @example
659-
* new HeadBuilder({ metadataBase: new URL('https://example.com') })
657+
* new HeadBuilder({ metadataBase: new URL('https://devsantara.com') })
660658
* .addAlternateLocale((helper) => ({
661659
* 'en-US': helper.resolveUrl('/en'),
662660
* 'fr-FR': helper.resolveUrl('/fr'),
@@ -715,6 +713,7 @@ export class HeadBuilder<TOutput = HeadElement[]> {
715713
addStylesheet(href: string | URL, options?: StylesheetOptions): this {
716714
this.addElement('link', {
717715
rel: 'stylesheet',
716+
type: 'text/css',
718717
href: href.toString(),
719718
...options,
720719
});

0 commit comments

Comments
 (0)