@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.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 (157) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +55 -32
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +152 -76
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleWikipedia } from "./wikipedia";
3
+
4
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
5
+
6
+ describe.skipIf(SKIP)("handleWikipedia", () => {
7
+ it("returns null for non-Wikipedia URLs", async () => {
8
+ const result = await handleWikipedia("https://example.com", 10);
9
+ expect(result).toBeNull();
10
+ });
11
+
12
+ it("returns null for Wikipedia URLs without /wiki/ path", async () => {
13
+ const result = await handleWikipedia("https://en.wikipedia.org/", 10);
14
+ expect(result).toBeNull();
15
+ });
16
+
17
+ it("fetches a known article with full metadata", async () => {
18
+ // "Computer" is a stable, well-established article
19
+ const result = await handleWikipedia("https://en.wikipedia.org/wiki/Computer", 20);
20
+ expect(result).not.toBeNull();
21
+ expect(result?.method).toBe("wikipedia");
22
+ expect(result?.contentType).toBe("text/markdown");
23
+ expect(result?.content).toContain("Computer");
24
+ expect(result?.url).toBe("https://en.wikipedia.org/wiki/Computer");
25
+ expect(result?.finalUrl).toBe("https://en.wikipedia.org/wiki/Computer");
26
+ expect(result?.truncated).toBe(false);
27
+ expect(result?.notes).toContain("Fetched via Wikipedia API");
28
+ expect(result?.fetchedAt).toBeDefined();
29
+ // Should be a valid ISO timestamp
30
+ expect(() => new Date(result?.fetchedAt ?? "")).not.toThrow();
31
+ // The handler should filter out References and External links sections
32
+ const content = result?.content ?? "";
33
+ const hasReferencesHeading = /^## References$/m.test(content);
34
+ const hasExternalLinksHeading = /^## External links$/m.test(content);
35
+ // At least one of these should be filtered out
36
+ expect(hasReferencesHeading || hasExternalLinksHeading).toBe(false);
37
+ });
38
+
39
+ it("handles different language wikis", async () => {
40
+ // German Wikipedia article for "Computer"
41
+ const result = await handleWikipedia("https://de.wikipedia.org/wiki/Computer", 20);
42
+ expect(result).not.toBeNull();
43
+ expect(result?.method).toBe("wikipedia");
44
+ expect(result?.contentType).toBe("text/markdown");
45
+ expect(result?.content).toContain("Computer");
46
+ });
47
+
48
+ it("handles article with special characters in title", async () => {
49
+ // Article with special characters: "C++"
50
+ const result = await handleWikipedia("https://en.wikipedia.org/wiki/C%2B%2B", 20);
51
+ expect(result).not.toBeNull();
52
+ expect(result?.method).toBe("wikipedia");
53
+ expect(result?.contentType).toBe("text/markdown");
54
+ expect(result?.content).toMatch(/C\+\+/);
55
+ });
56
+
57
+ it("handles article with spaces and parentheses in title", async () => {
58
+ // Artificial intelligence uses underscores for spaces
59
+ const result = await handleWikipedia("https://en.wikipedia.org/wiki/Artificial_intelligence", 20);
60
+ expect(result).not.toBeNull();
61
+ expect(result?.method).toBe("wikipedia");
62
+ expect(result?.contentType).toBe("text/markdown");
63
+ expect(result?.content).toMatch(/[Aa]rtificial intelligence/);
64
+ });
65
+
66
+ it("handles non-existent articles gracefully", async () => {
67
+ const result = await handleWikipedia(
68
+ "https://en.wikipedia.org/wiki/ThisArticleDefinitelyDoesNotExist123456789",
69
+ 20,
70
+ );
71
+ expect(result).toBeNull();
72
+ });
73
+ });
@@ -0,0 +1,95 @@
1
+ import { parse as parseHtml } from "node-html-parser";
2
+ import type { RenderResult, SpecialHandler } from "./types";
3
+ import { finalizeOutput, loadPage } from "./types";
4
+
5
+ /**
6
+ * Handle Wikipedia URLs via Wikipedia API
7
+ */
8
+ export const handleWikipedia: SpecialHandler = async (
9
+ url: string,
10
+ timeout: number,
11
+ signal?: AbortSignal,
12
+ ): Promise<RenderResult | null> => {
13
+ try {
14
+ const parsed = new URL(url);
15
+ // Match *.wikipedia.org
16
+ const wikiMatch = parsed.hostname.match(/^(\w+)\.wikipedia\.org$/);
17
+ if (!wikiMatch) return null;
18
+
19
+ const lang = wikiMatch[1];
20
+ const titleMatch = parsed.pathname.match(/\/wiki\/(.+)/);
21
+ if (!titleMatch) return null;
22
+
23
+ const title = decodeURIComponent(titleMatch[1]);
24
+ const fetchedAt = new Date().toISOString();
25
+
26
+ // Use Wikipedia API to get plain text extract
27
+ const apiUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
28
+ const summaryResult = await loadPage(apiUrl, { timeout, signal });
29
+
30
+ let md = "";
31
+
32
+ if (summaryResult.ok) {
33
+ const summary = JSON.parse(summaryResult.content) as {
34
+ title: string;
35
+ description?: string;
36
+ extract: string;
37
+ };
38
+ md = `# ${summary.title}\n\n`;
39
+ if (summary.description) md += `*${summary.description}*\n\n`;
40
+ md += `${summary.extract}\n\n---\n\n`;
41
+ }
42
+
43
+ // Get full article content via mobile-html or parse API
44
+ const contentUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/mobile-html/${encodeURIComponent(title)}`;
45
+ const contentResult = await loadPage(contentUrl, { timeout, signal });
46
+
47
+ if (contentResult.ok) {
48
+ const doc = parseHtml(contentResult.content);
49
+
50
+ // Extract main content sections
51
+ const sections = doc.querySelectorAll("section");
52
+ for (const section of sections) {
53
+ const heading = section.querySelector("h2, h3, h4");
54
+ const headingText = heading?.text?.trim();
55
+
56
+ // Skip certain sections
57
+ if (
58
+ headingText &&
59
+ ["References", "External links", "See also", "Notes", "Further reading"].includes(headingText)
60
+ ) {
61
+ continue;
62
+ }
63
+
64
+ if (headingText) {
65
+ const level = heading?.tagName === "H2" ? "##" : "###";
66
+ md += `${level} ${headingText}\n\n`;
67
+ }
68
+
69
+ const paragraphs = section.querySelectorAll("p");
70
+ for (const p of paragraphs) {
71
+ const text = p.text?.trim();
72
+ if (text && text.length > 20) {
73
+ md += `${text}\n\n`;
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ if (!md) return null;
80
+
81
+ const output = finalizeOutput(md);
82
+ return {
83
+ url,
84
+ finalUrl: url,
85
+ contentType: "text/markdown",
86
+ method: "wikipedia",
87
+ content: output.content,
88
+ fetchedAt,
89
+ truncated: output.truncated,
90
+ notes: ["Fetched via Wikipedia API"],
91
+ };
92
+ } catch {}
93
+
94
+ return null;
95
+ };
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { handleYouTube } from "./youtube";
3
+
4
+ const SKIP = !process.env.WEB_FETCH_INTEGRATION;
5
+
6
+ describe.skipIf(SKIP)("handleYouTube", () => {
7
+ it("returns null for non-YouTube URLs", async () => {
8
+ const result = await handleYouTube("https://example.com", 10);
9
+ expect(result).toBeNull();
10
+ });
11
+
12
+ it("returns null for invalid YouTube URLs", async () => {
13
+ const result = await handleYouTube("https://youtube.com/invalid", 10);
14
+ expect(result).toBeNull();
15
+ });
16
+
17
+ it("handles youtube.com/watch?v= format", async () => {
18
+ // Use Rick Astley's "Never Gonna Give You Up" - a stable, well-known video
19
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
20
+ expect(result).not.toBeNull();
21
+ expect(result?.method).toMatch(/^youtube/);
22
+ expect(result?.contentType).toBe("text/markdown");
23
+ expect(result?.content).toContain("Video ID");
24
+ expect(result?.content).toContain("dQw4w9WgXcQ");
25
+ }, 30000);
26
+
27
+ it("handles youtu.be/ short format", async () => {
28
+ const result = await handleYouTube("https://youtu.be/dQw4w9WgXcQ", 30);
29
+ expect(result).not.toBeNull();
30
+ expect(result?.method).toMatch(/^youtube/);
31
+ expect(result?.content).toContain("dQw4w9WgXcQ");
32
+ }, 30000);
33
+
34
+ it("handles youtube.com/shorts/ format", async () => {
35
+ // Use a stable YouTube Shorts video
36
+ const result = await handleYouTube("https://www.youtube.com/shorts/jNQXAC9IVRw", 30);
37
+ expect(result).not.toBeNull();
38
+ expect(result?.method).toMatch(/^youtube/);
39
+ expect(result?.content).toContain("jNQXAC9IVRw");
40
+ }, 30000);
41
+
42
+ it("handles youtube.com/embed/ format", async () => {
43
+ const result = await handleYouTube("https://www.youtube.com/embed/dQw4w9WgXcQ", 30);
44
+ expect(result).not.toBeNull();
45
+ expect(result?.method).toMatch(/^youtube/);
46
+ expect(result?.content).toContain("dQw4w9WgXcQ");
47
+ }, 30000);
48
+
49
+ it("handles youtube.com/v/ format", async () => {
50
+ const result = await handleYouTube("https://www.youtube.com/v/dQw4w9WgXcQ", 30);
51
+ expect(result).not.toBeNull();
52
+ expect(result?.method).toMatch(/^youtube/);
53
+ expect(result?.content).toContain("dQw4w9WgXcQ");
54
+ }, 30000);
55
+
56
+ it("handles m.youtube.com mobile URLs", async () => {
57
+ const result = await handleYouTube("https://m.youtube.com/watch?v=dQw4w9WgXcQ", 30);
58
+ expect(result).not.toBeNull();
59
+ expect(result?.method).toMatch(/^youtube/);
60
+ expect(result?.content).toContain("dQw4w9WgXcQ");
61
+ }, 30000);
62
+
63
+ it("extracts video metadata when yt-dlp is available", async () => {
64
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
65
+ expect(result).not.toBeNull();
66
+
67
+ // If yt-dlp is available, should have metadata
68
+ if (result?.method === "youtube") {
69
+ expect(result.content).toContain("Video ID");
70
+ expect(result.content).toContain("Channel");
71
+ // May have duration, views, upload date, etc.
72
+ }
73
+
74
+ // If yt-dlp is not available, should indicate that
75
+ if (result?.method === "youtube-no-ytdlp") {
76
+ expect(result.content).toContain("yt-dlp could not be installed");
77
+ expect(result.notes).toContain("yt-dlp installation failed");
78
+ }
79
+ }, 30000);
80
+
81
+ it("handles videos with transcripts gracefully", async () => {
82
+ // This video should have captions
83
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
84
+ expect(result).not.toBeNull();
85
+
86
+ if (result?.method === "youtube") {
87
+ // Either has transcript or explicitly notes it's not available
88
+ const hasTranscript = result.content.includes("Transcript");
89
+ const noTranscriptNote = result.content.includes("No transcript available");
90
+ expect(hasTranscript || noTranscriptNote).toBe(true);
91
+ }
92
+ }, 30000);
93
+
94
+ it("handles videos without transcripts gracefully", async () => {
95
+ // Many music videos lack captions, but this is not guaranteed
96
+ // Just verify the handler doesn't crash and provides some info
97
+ const result = await handleYouTube("https://www.youtube.com/watch?v=kJQP7kiw5Fk", 30);
98
+ expect(result).not.toBeNull();
99
+
100
+ if (result?.method === "youtube") {
101
+ // Should still have basic metadata
102
+ expect(result.content).toContain("Video ID");
103
+ }
104
+ }, 30000);
105
+
106
+ it("returns appropriate response when yt-dlp is not available", async () => {
107
+ // We can't force yt-dlp to be unavailable in tests, but we can verify
108
+ // the return structure matches expectations for both cases
109
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
110
+ expect(result).not.toBeNull();
111
+
112
+ // Should have one of these two methods
113
+ expect(["youtube", "youtube-no-ytdlp"]).toContain(result!.method);
114
+
115
+ // Both should have required fields
116
+ expect(result?.url).toBe("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
117
+ expect(result?.finalUrl).toContain("youtube.com");
118
+ expect(result?.fetchedAt).toBeTruthy();
119
+ expect(typeof result?.truncated).toBe("boolean");
120
+ expect(Array.isArray(result?.notes)).toBe(true);
121
+ }, 30000);
122
+
123
+ it("normalizes video URLs to canonical format", async () => {
124
+ // Different input formats should normalize to same canonical URL
125
+ const result = await handleYouTube("https://youtu.be/dQw4w9WgXcQ", 30);
126
+ expect(result).not.toBeNull();
127
+ expect(result?.finalUrl).toBe("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
128
+ }, 30000);
129
+
130
+ it("handles playlist URLs by extracting video ID", async () => {
131
+ const result = await handleYouTube(
132
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf",
133
+ 30,
134
+ );
135
+ expect(result).not.toBeNull();
136
+ expect(result?.content).toContain("dQw4w9WgXcQ");
137
+ }, 30000);
138
+
139
+ it("includes subtitle source information when available", async () => {
140
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
141
+
142
+ if (result?.method === "youtube") {
143
+ // If transcript is present, should note the source
144
+ const hasManualNote = result.notes.includes("Using manual subtitles");
145
+ const hasAutoNote = result.notes.includes("Using auto-generated captions");
146
+ const hasNoSubsNote = result.notes.includes("No subtitles/captions available");
147
+
148
+ // Should have exactly one of these
149
+ const noteCount = [hasManualNote, hasAutoNote, hasNoSubsNote].filter(Boolean).length;
150
+ expect(noteCount).toBeGreaterThanOrEqual(1);
151
+ }
152
+ }, 30000);
153
+
154
+ it("formats duration in human readable format", async () => {
155
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
156
+
157
+ if (result?.method === "youtube" && result.content.includes("Duration")) {
158
+ // Should have duration in M:SS or H:MM:SS format
159
+ expect(result.content).toMatch(/Duration.*\d+:\d{2}/);
160
+ }
161
+ }, 30000);
162
+
163
+ it("formats view count in readable format", async () => {
164
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
165
+
166
+ if (result?.method === "youtube" && result.content.includes("Views")) {
167
+ // Should have views formatted (e.g., 1.5B, 100M, 10.5K)
168
+ expect(result.content).toMatch(/Views.*\d+(\.\d+)?[KM]?/);
169
+ }
170
+ }, 30000);
171
+
172
+ it("includes upload date when available", async () => {
173
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
174
+
175
+ if (result?.method === "youtube" && result.content.includes("Uploaded")) {
176
+ // Should have date in YYYY-MM-DD format
177
+ expect(result.content).toMatch(/Uploaded.*\d{4}-\d{2}-\d{2}/);
178
+ }
179
+ }, 30000);
180
+
181
+ it("truncates long descriptions", async () => {
182
+ const result = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
183
+
184
+ if (result?.method === "youtube" && result.content.includes("Description")) {
185
+ // Description section should exist
186
+ expect(result.content).toContain("## Description");
187
+ }
188
+ }, 30000);
189
+
190
+ it("handles www prefix variations", async () => {
191
+ const withWww = await handleYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ", 30);
192
+ const withoutWww = await handleYouTube("https://youtube.com/watch?v=dQw4w9WgXcQ", 30);
193
+
194
+ expect(withWww).not.toBeNull();
195
+ expect(withoutWww).not.toBeNull();
196
+ expect(withWww?.finalUrl).toBe(withoutWww?.finalUrl);
197
+ }, 30000);
198
+ });