toiljs 0.0.11 → 0.0.14

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 (120) hide show
  1. package/README.md +3 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +10 -4
  4. package/build/cli/create.js +58 -30
  5. package/build/cli/diagnostics.d.ts +55 -0
  6. package/build/cli/diagnostics.js +333 -0
  7. package/build/cli/doctor.d.ts +6 -0
  8. package/build/cli/doctor.js +249 -0
  9. package/build/cli/index.js +26 -0
  10. package/build/cli/proc.d.ts +5 -0
  11. package/build/cli/proc.js +20 -0
  12. package/build/cli/ui.d.ts +1 -0
  13. package/build/cli/ui.js +1 -0
  14. package/build/cli/update.d.ts +7 -0
  15. package/build/cli/update.js +117 -0
  16. package/build/cli/updates.d.ts +10 -0
  17. package/build/cli/updates.js +45 -0
  18. package/build/client/.tsbuildinfo +1 -1
  19. package/build/client/dev/error-overlay.js +1 -1
  20. package/build/client/head/metadata.js +3 -1
  21. package/build/client/index.d.ts +5 -1
  22. package/build/client/index.js +2 -0
  23. package/build/client/navigation/navigation.js +1 -1
  24. package/build/client/routing/Router.js +2 -2
  25. package/build/client/search/search.d.ts +26 -0
  26. package/build/client/search/search.js +101 -0
  27. package/build/client/search/use-page-search.d.ts +8 -0
  28. package/build/client/search/use-page-search.js +21 -0
  29. package/build/compiler/.tsbuildinfo +1 -1
  30. package/build/compiler/generate.js +33 -24
  31. package/build/compiler/index.d.ts +2 -0
  32. package/build/compiler/index.js +1 -0
  33. package/build/compiler/pages.d.ts +8 -0
  34. package/build/compiler/pages.js +37 -0
  35. package/build/compiler/plugin.js +3 -1
  36. package/build/compiler/prerender.d.ts +1 -0
  37. package/build/compiler/prerender.js +11 -5
  38. package/build/compiler/seo.js +10 -3
  39. package/build/io/.tsbuildinfo +1 -1
  40. package/examples/basic/client/components/Header.tsx +43 -41
  41. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  42. package/examples/basic/client/public/index.html +18 -16
  43. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
  44. package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
  45. package/examples/basic/client/routes/about.tsx +21 -22
  46. package/examples/basic/client/routes/blog/[id].tsx +26 -18
  47. package/examples/basic/client/routes/features/actions.tsx +67 -67
  48. package/examples/basic/client/routes/features/error/index.tsx +27 -27
  49. package/examples/basic/client/routes/features/head.tsx +38 -38
  50. package/examples/basic/client/routes/features/index.tsx +83 -75
  51. package/examples/basic/client/routes/features/realtime.tsx +34 -32
  52. package/examples/basic/client/routes/features/script.tsx +31 -31
  53. package/examples/basic/client/routes/features/seo.tsx +39 -39
  54. package/examples/basic/client/routes/features/template/index.tsx +20 -20
  55. package/examples/basic/client/routes/features/template/template.tsx +16 -18
  56. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
  57. package/examples/basic/client/routes/gallery/index.tsx +42 -42
  58. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
  59. package/examples/basic/client/routes/get-started.tsx +157 -84
  60. package/examples/basic/client/routes/index.tsx +137 -96
  61. package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
  62. package/examples/basic/client/routes/search.tsx +61 -0
  63. package/examples/basic/client/routes/test.tsx +7 -8
  64. package/examples/basic/client/styles/main.css +624 -552
  65. package/package.json +2 -2
  66. package/presets/eslint.js +10 -3
  67. package/src/cli/configure.ts +363 -353
  68. package/src/cli/create.ts +563 -530
  69. package/src/cli/diagnostics.ts +421 -0
  70. package/src/cli/doctor.ts +318 -0
  71. package/src/cli/features.ts +166 -160
  72. package/src/cli/index.ts +242 -211
  73. package/src/cli/proc.ts +30 -0
  74. package/src/cli/ui.ts +111 -103
  75. package/src/cli/update.ts +150 -0
  76. package/src/cli/updates.ts +69 -0
  77. package/src/client/components/Image.tsx +91 -89
  78. package/src/client/dev/error-overlay.tsx +193 -197
  79. package/src/client/head/metadata.ts +94 -92
  80. package/src/client/index.ts +79 -64
  81. package/src/client/navigation/Link.tsx +94 -100
  82. package/src/client/navigation/navigation.ts +215 -218
  83. package/src/client/routing/Router.tsx +210 -193
  84. package/src/client/routing/hooks.ts +110 -114
  85. package/src/client/routing/lazy.ts +77 -81
  86. package/src/client/search/search.ts +189 -0
  87. package/src/client/search/use-page-search.ts +73 -0
  88. package/src/compiler/config.ts +173 -171
  89. package/src/compiler/fonts.ts +89 -87
  90. package/src/compiler/generate.ts +45 -27
  91. package/src/compiler/image-report.ts +88 -85
  92. package/src/compiler/index.ts +2 -0
  93. package/src/compiler/pages.ts +70 -0
  94. package/src/compiler/plugin.ts +51 -47
  95. package/src/compiler/prerender.ts +152 -130
  96. package/src/compiler/routes.ts +132 -131
  97. package/src/compiler/seo.ts +381 -356
  98. package/src/compiler/vite.ts +155 -145
  99. package/src/io/FastSet.ts +99 -96
  100. package/test/configure.test.ts +94 -90
  101. package/test/doctor.test.ts +140 -0
  102. package/test/dom/Image.test.tsx +73 -46
  103. package/test/dom/Script.test.tsx +48 -45
  104. package/test/dom/action.test.tsx +146 -129
  105. package/test/dom/error-overlay.test.tsx +1 -1
  106. package/test/dom/loader.test.tsx +2 -2
  107. package/test/dom/revalidate.test.tsx +1 -1
  108. package/test/dom/route-head.test.tsx +1 -2
  109. package/test/dom/router-loading.test.tsx +1 -1
  110. package/test/dom/slot.test.tsx +131 -109
  111. package/test/dom/view-transitions.test.tsx +53 -51
  112. package/test/features.test.ts +149 -142
  113. package/test/fonts.test.ts +28 -26
  114. package/test/head.test.ts +45 -35
  115. package/test/metadata.test.ts +42 -41
  116. package/test/pages.test.ts +105 -0
  117. package/test/prerender.test.ts +54 -46
  118. package/test/search.test.ts +114 -0
  119. package/test/seo.test.ts +30 -8
  120. package/test/update.test.ts +44 -0
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { buildPageIndex, pagesModuleSource } from '../src/compiler/pages';
8
+ import type { ScannedRoute } from '../src/compiler/routes';
9
+
10
+ const dirs: string[] = [];
11
+ /** Writes a throwaway routes dir with the given `{ relPath: source }` files, returns its path. */
12
+ function tmpRoutes(files: Record<string, string>): string {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-pages-'));
14
+ dirs.push(dir);
15
+ for (const [rel, src] of Object.entries(files)) {
16
+ const full = path.join(dir, rel);
17
+ fs.mkdirSync(path.dirname(full), { recursive: true });
18
+ fs.writeFileSync(full, src);
19
+ }
20
+ return dir;
21
+ }
22
+ function route(
23
+ dir: string,
24
+ file: string,
25
+ pattern: string,
26
+ extra: Partial<ScannedRoute> = {},
27
+ ): ScannedRoute {
28
+ return { file: path.join(dir, file), pattern, ...extra };
29
+ }
30
+
31
+ afterEach(() => {
32
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('buildPageIndex', () => {
36
+ it('extracts each page metadata and flags dynamic routes', () => {
37
+ const dir = tmpRoutes({
38
+ 'index.tsx': `export const metadata = { title: 'Home' };\nexport default () => null;\n`,
39
+ 'blog/[id].tsx': `export const metadata = { title: 'Post' };\nexport default () => null;\n`,
40
+ });
41
+ const pages = buildPageIndex(process.cwd(), [
42
+ route(dir, 'index.tsx', '/'),
43
+ route(dir, 'blog/[id].tsx', '/blog/:id'),
44
+ ]);
45
+ expect(pages).toEqual([
46
+ { path: '/', dynamic: false, metadata: { title: 'Home' } },
47
+ { path: '/blog/:id', dynamic: true, metadata: { title: 'Post' } },
48
+ ]);
49
+ });
50
+
51
+ it('merges searchHints over static metadata (hints win) for dynamic discoverability', () => {
52
+ const dir = tmpRoutes({
53
+ 'blog/[id].tsx':
54
+ `export const searchHints = { title: 'Blog', keywords: ['posts', 'articles'] };\n` +
55
+ `export const generateMetadata = ({ params }) => ({ title: params.id });\n` +
56
+ `export default () => null;\n`,
57
+ });
58
+ const [page] = buildPageIndex(process.cwd(), [route(dir, 'blog/[id].tsx', '/blog/:id')]);
59
+ expect(page).toEqual({
60
+ path: '/blog/:id',
61
+ dynamic: true,
62
+ metadata: { title: 'Blog', keywords: ['posts', 'articles'] },
63
+ });
64
+ });
65
+
66
+ it('uses an empty metadata object when a route declares none', () => {
67
+ const dir = tmpRoutes({ 'about.tsx': `export default () => null;\n` });
68
+ const pages = buildPageIndex(process.cwd(), [route(dir, 'about.tsx', '/about')]);
69
+ expect(pages).toEqual([{ path: '/about', dynamic: false, metadata: {} }]);
70
+ });
71
+
72
+ it('excludes slots and intercepting routes, and dedupes patterns', () => {
73
+ const dir = tmpRoutes({
74
+ 'index.tsx': `export const metadata = { title: 'Home' };\nexport default () => null;\n`,
75
+ '@modal/photo.tsx': `export default () => null;\n`,
76
+ '(.)photo.tsx': `export default () => null;\n`,
77
+ });
78
+ const pages = buildPageIndex(process.cwd(), [
79
+ route(dir, 'index.tsx', '/'),
80
+ route(dir, '@modal/photo.tsx', '/photo', { slot: 'modal' }),
81
+ route(dir, '(.)photo.tsx', '/photo', { intercept: true }),
82
+ ]);
83
+ expect(pages.map((p) => p.path)).toEqual(['/']);
84
+ });
85
+
86
+ it('sorts the index by path for deterministic output', () => {
87
+ const dir = tmpRoutes({
88
+ 'zed.tsx': `export default () => null;\n`,
89
+ 'about.tsx': `export default () => null;\n`,
90
+ });
91
+ const pages = buildPageIndex(process.cwd(), [
92
+ route(dir, 'zed.tsx', '/zed'),
93
+ route(dir, 'about.tsx', '/about'),
94
+ ]);
95
+ expect(pages.map((p) => p.path)).toEqual(['/about', '/zed']);
96
+ });
97
+ });
98
+
99
+ describe('pagesModuleSource', () => {
100
+ it('emits a typed pages export with JSON-serialized entries', () => {
101
+ const src = pagesModuleSource([{ path: '/', dynamic: false, metadata: { title: 'Home' } }]);
102
+ expect(src).toContain('export const pages: PageMeta[] = [');
103
+ expect(src).toContain('{"path":"/","dynamic":false,"metadata":{"title":"Home"}}');
104
+ });
105
+ });
@@ -1,46 +1,54 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- import ts from 'typescript';
6
- import { afterEach, describe, expect, it } from 'vitest';
7
-
8
- import { extractStaticMetadata } from '../src/compiler/prerender';
9
-
10
- const written: string[] = [];
11
- function tmp(source: string): string {
12
- const file = path.join(os.tmpdir(), `toil-prerender-${String(written.length)}-${process.pid}.tsx`);
13
- fs.writeFileSync(file, source);
14
- written.push(file);
15
- return file;
16
- }
17
- afterEach(() => {
18
- for (const f of written.splice(0)) fs.rmSync(f, { force: true });
19
- });
20
-
21
- describe('extractStaticMetadata', () => {
22
- it('extracts a static metadata object literal (nested objects/arrays)', () => {
23
- const file = tmp(
24
- `export const metadata = { title: 'X', keywords: ['a', 'b'], openGraph: { type: 'website' } };\n` +
25
- `export default function P() { return null; }\n`,
26
- );
27
- expect(extractStaticMetadata(ts, file)).toEqual({
28
- title: 'X',
29
- keywords: ['a', 'b'],
30
- openGraph: { type: 'website' },
31
- });
32
- });
33
-
34
- it('returns null when there is no static metadata export', () => {
35
- expect(extractStaticMetadata(ts, tmp(`export default function P() { return null; }\n`))).toBeNull();
36
- // generateMetadata (a function) is not a static object literal → not extracted.
37
- expect(
38
- extractStaticMetadata(ts, tmp(`export const generateMetadata = () => ({ title: 'X' });\n`)),
39
- ).toBeNull();
40
- });
41
-
42
- it('skips computed/non-literal properties but keeps the static ones', () => {
43
- const file = tmp(`const x = foo();\nexport const metadata = { title: 'X', dyn: x };\n`);
44
- expect(extractStaticMetadata(ts, file)).toEqual({ title: 'X' });
45
- });
46
- });
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import ts from 'typescript';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ import { extractStaticMetadata } from '../src/compiler/prerender';
9
+
10
+ const written: string[] = [];
11
+ function tmp(source: string): string {
12
+ const file = path.join(
13
+ os.tmpdir(),
14
+ `toil-prerender-${String(written.length)}-${process.pid}.tsx`,
15
+ );
16
+ fs.writeFileSync(file, source);
17
+ written.push(file);
18
+ return file;
19
+ }
20
+ afterEach(() => {
21
+ for (const f of written.splice(0)) fs.rmSync(f, { force: true });
22
+ });
23
+
24
+ describe('extractStaticMetadata', () => {
25
+ it('extracts a static metadata object literal (nested objects/arrays)', () => {
26
+ const file = tmp(
27
+ `export const metadata = { title: 'X', keywords: ['a', 'b'], openGraph: { type: 'website' } };\n` +
28
+ `export default function P() { return null; }\n`,
29
+ );
30
+ expect(extractStaticMetadata(ts, file)).toEqual({
31
+ title: 'X',
32
+ keywords: ['a', 'b'],
33
+ openGraph: { type: 'website' },
34
+ });
35
+ });
36
+
37
+ it('returns null when there is no static metadata export', () => {
38
+ expect(
39
+ extractStaticMetadata(ts, tmp(`export default function P() { return null; }\n`)),
40
+ ).toBeNull();
41
+ // generateMetadata (a function) is not a static object literal → not extracted.
42
+ expect(
43
+ extractStaticMetadata(
44
+ ts,
45
+ tmp(`export const generateMetadata = () => ({ title: 'X' });\n`),
46
+ ),
47
+ ).toBeNull();
48
+ });
49
+
50
+ it('skips computed/non-literal properties but keeps the static ones', () => {
51
+ const file = tmp(`const x = foo();\nexport const metadata = { title: 'X', dyn: x };\n`);
52
+ expect(extractStaticMetadata(ts, file)).toEqual({ title: 'X' });
53
+ });
54
+ });
@@ -0,0 +1,114 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ getPages,
5
+ type PageMeta,
6
+ pagePath,
7
+ registerPages,
8
+ searchPages,
9
+ } from '../src/client/search/search';
10
+
11
+ const PAGES: PageMeta[] = [
12
+ {
13
+ path: '/',
14
+ dynamic: false,
15
+ metadata: { title: 'Home', description: 'Welcome to the toil demo site' },
16
+ },
17
+ {
18
+ path: '/about',
19
+ dynamic: false,
20
+ metadata: { title: 'About', description: 'Who we are', keywords: ['team', 'company'] },
21
+ },
22
+ {
23
+ path: '/blog',
24
+ dynamic: false,
25
+ metadata: {
26
+ title: 'Blog',
27
+ description: 'Articles and updates',
28
+ openGraph: { title: 'The Blog', siteName: 'toil' },
29
+ },
30
+ },
31
+ { path: '/blog/:id', dynamic: true, metadata: { title: 'Blog post' } },
32
+ { path: '/get-started', dynamic: false, metadata: {} },
33
+ ];
34
+
35
+ beforeEach(() => {
36
+ registerPages(PAGES);
37
+ });
38
+
39
+ describe('searchPages', () => {
40
+ it('returns no results for an empty / whitespace query', () => {
41
+ expect(searchPages('')).toEqual([]);
42
+ expect(searchPages(' ')).toEqual([]);
43
+ });
44
+
45
+ it('ranks a title match above a description-only match', () => {
46
+ const results = searchPages('about');
47
+ expect(results[0].page.path).toBe('/about');
48
+ // Two pages mention "about"? Only /about does here; the home page does not.
49
+ expect(results.map((r) => r.page.path)).toContain('/about');
50
+ expect(results[0].matches).toContain('title');
51
+ });
52
+
53
+ it('matches keywords', () => {
54
+ const results = searchPages('team');
55
+ expect(results).toHaveLength(1);
56
+ expect(results[0].page.path).toBe('/about');
57
+ expect(results[0].matches).toContain('keywords');
58
+ });
59
+
60
+ it('matches OpenGraph fields', () => {
61
+ const results = searchPages('blog', { fields: ['openGraph'] });
62
+ expect(results.map((r) => r.page.path)).toEqual(['/blog']);
63
+ expect(results[0].matches).toEqual(['openGraph']);
64
+ });
65
+
66
+ it('makes slugs word-searchable via the path field', () => {
67
+ const results = searchPages('started');
68
+ expect(results.map((r) => r.page.path)).toContain('/get-started');
69
+ });
70
+
71
+ it('excludes dynamic routes unless includeDynamic is set', () => {
72
+ expect(searchPages('post').map((r) => r.page.path)).not.toContain('/blog/:id');
73
+ expect(searchPages('post', { includeDynamic: true }).map((r) => r.page.path)).toContain(
74
+ '/blog/:id',
75
+ );
76
+ });
77
+
78
+ it('uses AND semantics: every term must match', () => {
79
+ expect(searchPages('blog updates').map((r) => r.page.path)).toEqual(['/blog']);
80
+ expect(searchPages('blog nonexistentword')).toEqual([]);
81
+ });
82
+
83
+ it('honors the limit option', () => {
84
+ // "toil" appears in the home description and the blog's OpenGraph siteName.
85
+ const all = searchPages('toil');
86
+ expect(all.length).toBeGreaterThan(1);
87
+ expect(searchPages('toil', { limit: 1 })).toHaveLength(1);
88
+ });
89
+
90
+ it('restricts matching to the requested fields', () => {
91
+ // "welcome" only lives in the home description; excluding it yields nothing.
92
+ expect(searchPages('welcome', { fields: ['title', 'keywords'] })).toEqual([]);
93
+ expect(searchPages('welcome', { fields: ['description'] }).map((r) => r.page.path)).toEqual(
94
+ ['/'],
95
+ );
96
+ });
97
+
98
+ it('is case-insensitive', () => {
99
+ expect(searchPages('ABOUT')[0].page.path).toBe('/about');
100
+ });
101
+ });
102
+
103
+ describe('registry helpers', () => {
104
+ it('getPages returns the registered index', () => {
105
+ expect(getPages()).toBe(PAGES);
106
+ });
107
+
108
+ it('pagePath normalizes results, pages, and raw strings', () => {
109
+ const result = searchPages('about')[0];
110
+ expect(pagePath(result)).toBe('/about');
111
+ expect(pagePath(result.page)).toBe('/about');
112
+ expect(pagePath('/raw')).toBe('/raw');
113
+ });
114
+ });
package/test/seo.test.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
3
  import type { ScannedRoute } from '../src/compiler/routes';
4
- import { injectSeoHtml, llmsTxt, robotsTxt, routeSeo, seoHeadTags, sitemapXml } from '../src/compiler/seo';
4
+ import {
5
+ injectSeoHtml,
6
+ llmsTxt,
7
+ robotsTxt,
8
+ routeSeo,
9
+ seoHeadTags,
10
+ sitemapXml,
11
+ } from '../src/compiler/seo';
5
12
 
6
13
  const routes: ScannedRoute[] = [
7
14
  { file: 'a', pattern: '/' },
8
15
  { file: 'b', pattern: '/about' },
9
- { file: 'c', pattern: '/blog/:id' }, // dynamic excluded from sitemap
10
- { file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept excluded
16
+ { file: 'c', pattern: '/blog/:id' }, // dynamic, excluded from sitemap
17
+ { file: 'd', pattern: '/photo/:id', slot: 'modal', intercept: true }, // slot/intercept, excluded
11
18
  ];
12
19
 
13
20
  describe('seoHeadTags', () => {
@@ -30,14 +37,21 @@ describe('seoHeadTags', () => {
30
37
  });
31
38
 
32
39
  it('escapes attribute values', () => {
33
- expect(seoHeadTags({ description: 'a "b" <c>' })).toContain('content="a &quot;b&quot; &lt;c&gt;"');
40
+ expect(seoHeadTags({ description: 'a "b" <c>' })).toContain(
41
+ 'content="a &quot;b&quot; &lt;c&gt;"',
42
+ );
34
43
  });
35
44
 
36
45
  it('renders a full Twitter card + OG image dimensions + fb:app_id', () => {
37
46
  const html = seoHeadTags({
38
47
  title: 'Home',
39
48
  description: 'd',
40
- openGraph: { image: 'https://x.test/og.png', imageAlt: 'alt', imageWidth: 1200, imageHeight: 630 },
49
+ openGraph: {
50
+ image: 'https://x.test/og.png',
51
+ imageAlt: 'alt',
52
+ imageWidth: 1200,
53
+ imageHeight: 630,
54
+ },
41
55
  twitter: { site: '@x' },
42
56
  facebook: { appId: '123' },
43
57
  });
@@ -68,14 +82,22 @@ describe('routeSeo', () => {
68
82
 
69
83
  it('falls back to the site defaults when a route has no metadata', () => {
70
84
  const site = { url: 'https://x.test', title: 'Site' };
71
- expect(routeSeo(site, null, '/x')).toMatchObject({ title: 'Site', url: 'https://x.test/x' });
85
+ expect(routeSeo(site, null, '/x')).toMatchObject({
86
+ title: 'Site',
87
+ url: 'https://x.test/x',
88
+ });
72
89
  });
73
90
  });
74
91
 
75
92
  describe('injectSeoHtml', () => {
76
93
  it('replaces the title + description and inserts the rest before </head>', () => {
77
- const shell = '<!doctype html><html><head><title>old</title><meta name="description" content="" /></head><body></body></html>';
78
- const out = injectSeoHtml(shell, { title: 'New', description: 'fresh', url: 'https://x.test' });
94
+ const shell =
95
+ '<!doctype html><html><head><title>old</title><meta name="description" content="" /></head><body></body></html>';
96
+ const out = injectSeoHtml(shell, {
97
+ title: 'New',
98
+ description: 'fresh',
99
+ url: 'https://x.test',
100
+ });
79
101
  expect(out).toContain('<title>New</title>');
80
102
  expect(out).not.toContain('<title>old</title>');
81
103
  expect(out.match(/name="description"/g)).toHaveLength(1);
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildRows, classifyBump, parseNcuJson } from '../src/cli/updates';
4
+
5
+ describe('classifyBump', () => {
6
+ it('classifies major / minor / patch from ranges', () => {
7
+ expect(classifyBump('^19.2.6', '^20.0.0')).toBe('major');
8
+ expect(classifyBump('^19.2.6', '^19.3.0')).toBe('minor');
9
+ expect(classifyBump('^19.2.6', '^19.2.7')).toBe('patch');
10
+ expect(classifyBump('1.2.3', '1.2.3')).toBe('other');
11
+ });
12
+ });
13
+
14
+ describe('parseNcuJson', () => {
15
+ it('parses the jsonUpgraded object, tolerating surrounding noise', () => {
16
+ expect(parseNcuJson('{"react":"^19.3.0","eslint":"^10.4.1"}')).toEqual({
17
+ react: '^19.3.0',
18
+ eslint: '^10.4.1',
19
+ });
20
+ expect(parseNcuJson('npx noise\n{"a":"1.0.0"}\nbye')).toEqual({ a: '1.0.0' });
21
+ expect(parseNcuJson('{}')).toEqual({});
22
+ expect(parseNcuJson('not json')).toEqual({});
23
+ });
24
+ });
25
+
26
+ describe('buildRows', () => {
27
+ it('joins ncu output with current ranges and sorts major-first then by name', () => {
28
+ const rows = buildRows(
29
+ { react: '^20.0.0', eslint: '^10.4.1', vite: '^8.0.15' },
30
+ { react: '^19.2.6', eslint: '^10.2.0', vite: '^8.0.14' },
31
+ );
32
+ expect(rows.map((r) => `${r.name}:${r.bump}`)).toEqual([
33
+ 'react:major',
34
+ 'eslint:minor',
35
+ 'vite:patch',
36
+ ]);
37
+ expect(rows[0]).toMatchObject({ name: 'react', from: '^19.2.6', to: '^20.0.0' });
38
+ });
39
+
40
+ it('marks a package missing from current deps with a "?" source', () => {
41
+ const rows = buildRows({ foo: '^2.0.0' }, {});
42
+ expect(rows[0]).toMatchObject({ name: 'foo', from: '?', to: '^2.0.0', bump: 'major' });
43
+ });
44
+ });