@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.
- package/CHANGELOG.md +90 -0
- package/package.json +5 -5
- package/src/cli/args.ts +4 -0
- package/src/core/agent-session.ts +29 -2
- package/src/core/bash-executor.ts +2 -1
- package/src/core/custom-commands/bundled/review/index.ts +369 -14
- package/src/core/custom-commands/bundled/wt/index.ts +1 -1
- package/src/core/session-manager.ts +158 -246
- package/src/core/session-storage.ts +379 -0
- package/src/core/settings-manager.ts +155 -4
- package/src/core/system-prompt.ts +62 -64
- package/src/core/tools/ask.ts +5 -4
- package/src/core/tools/bash-interceptor.ts +26 -61
- package/src/core/tools/bash.ts +13 -8
- package/src/core/tools/complete.ts +2 -4
- package/src/core/tools/edit-diff.ts +11 -4
- package/src/core/tools/edit.ts +7 -13
- package/src/core/tools/find.ts +111 -50
- package/src/core/tools/gemini-image.ts +128 -147
- package/src/core/tools/grep.ts +397 -415
- package/src/core/tools/index.test.ts +5 -1
- package/src/core/tools/index.ts +6 -8
- package/src/core/tools/jtd-to-json-schema.ts +174 -196
- package/src/core/tools/ls.ts +12 -10
- package/src/core/tools/lsp/client.ts +58 -9
- package/src/core/tools/lsp/config.ts +205 -656
- package/src/core/tools/lsp/defaults.json +465 -0
- package/src/core/tools/lsp/index.ts +55 -32
- package/src/core/tools/lsp/rust-analyzer.ts +49 -10
- package/src/core/tools/lsp/types.ts +1 -0
- package/src/core/tools/lsp/utils.ts +1 -1
- package/src/core/tools/read.ts +152 -76
- package/src/core/tools/render-utils.ts +70 -10
- package/src/core/tools/review.ts +38 -126
- package/src/core/tools/task/artifacts.ts +5 -4
- package/src/core/tools/task/executor.ts +204 -67
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/name-generator.ts +1544 -214
- package/src/core/tools/task/parallel.ts +30 -3
- package/src/core/tools/task/render.ts +85 -39
- package/src/core/tools/task/types.ts +34 -11
- package/src/core/tools/task/worker.ts +152 -27
- package/src/core/tools/web-fetch.ts +220 -1657
- package/src/core/tools/web-scrapers/academic.test.ts +239 -0
- package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
- package/src/core/tools/web-scrapers/arxiv.ts +88 -0
- package/src/core/tools/web-scrapers/aur.ts +175 -0
- package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
- package/src/core/tools/web-scrapers/bluesky.ts +284 -0
- package/src/core/tools/web-scrapers/brew.ts +177 -0
- package/src/core/tools/web-scrapers/business.test.ts +82 -0
- package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
- package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
- package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
- package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
- package/src/core/tools/web-scrapers/clojars.ts +180 -0
- package/src/core/tools/web-scrapers/coingecko.ts +184 -0
- package/src/core/tools/web-scrapers/crates-io.ts +128 -0
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-scrapers/devto.ts +177 -0
- package/src/core/tools/web-scrapers/discogs.ts +308 -0
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
- package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
- package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
- package/src/core/tools/web-scrapers/flathub.ts +239 -0
- package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-scrapers/github-gist.ts +68 -0
- package/src/core/tools/web-scrapers/github.ts +455 -0
- package/src/core/tools/web-scrapers/gitlab.ts +456 -0
- package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
- package/src/core/tools/web-scrapers/hackage.ts +94 -0
- package/src/core/tools/web-scrapers/hackernews.ts +208 -0
- package/src/core/tools/web-scrapers/hex.ts +121 -0
- package/src/core/tools/web-scrapers/huggingface.ts +385 -0
- package/src/core/tools/web-scrapers/iacr.ts +86 -0
- package/src/core/tools/web-scrapers/index.ts +250 -0
- package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
- package/src/core/tools/web-scrapers/lemmy.ts +220 -0
- package/src/core/tools/web-scrapers/lobsters.ts +186 -0
- package/src/core/tools/web-scrapers/mastodon.ts +310 -0
- package/src/core/tools/web-scrapers/maven.ts +152 -0
- package/src/core/tools/web-scrapers/mdn.ts +174 -0
- package/src/core/tools/web-scrapers/media.test.ts +138 -0
- package/src/core/tools/web-scrapers/metacpan.ts +253 -0
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/web-scrapers/npm.ts +114 -0
- package/src/core/tools/web-scrapers/nuget.ts +205 -0
- package/src/core/tools/web-scrapers/nvd.ts +243 -0
- package/src/core/tools/web-scrapers/ollama.ts +267 -0
- package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
- package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
- package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/web-scrapers/osv.ts +189 -0
- package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
- package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
- package/src/core/tools/web-scrapers/packagist.ts +174 -0
- package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
- package/src/core/tools/web-scrapers/pubmed.ts +178 -0
- package/src/core/tools/web-scrapers/pypi.ts +129 -0
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
- package/src/core/tools/web-scrapers/reddit.ts +104 -0
- package/src/core/tools/web-scrapers/repology.ts +262 -0
- package/src/core/tools/web-scrapers/research.test.ts +107 -0
- package/src/core/tools/web-scrapers/rfc.ts +209 -0
- package/src/core/tools/web-scrapers/rubygems.ts +117 -0
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
- package/src/core/tools/web-scrapers/security.test.ts +103 -0
- package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
- package/src/core/tools/web-scrapers/social.test.ts +259 -0
- package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
- package/src/core/tools/web-scrapers/spdx.ts +121 -0
- package/src/core/tools/web-scrapers/spotify.ts +218 -0
- package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
- package/src/core/tools/web-scrapers/standards.test.ts +122 -0
- package/src/core/tools/web-scrapers/terraform.ts +304 -0
- package/src/core/tools/web-scrapers/tldr.ts +51 -0
- package/src/core/tools/web-scrapers/twitter.ts +96 -0
- package/src/core/tools/web-scrapers/types.ts +234 -0
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/web-scrapers/vimeo.ts +152 -0
- package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
- package/src/core/tools/web-scrapers/w3c.ts +163 -0
- package/src/core/tools/web-scrapers/wikidata.ts +357 -0
- package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
- package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
- package/src/core/tools/web-scrapers/youtube.ts +371 -0
- package/src/core/tools/write.ts +21 -18
- package/src/core/voice.ts +3 -2
- package/src/lib/worktree/collapse.ts +2 -1
- package/src/lib/worktree/git.ts +2 -18
- package/src/main.ts +59 -3
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
- package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
- package/src/modes/interactive/components/hook-editor.ts +2 -1
- package/src/modes/interactive/components/model-selector.ts +19 -4
- package/src/modes/interactive/interactive-mode.ts +41 -38
- package/src/modes/interactive/theme/theme.ts +58 -58
- package/src/modes/rpc/rpc-mode.ts +10 -9
- package/src/prompts/review-request.md +27 -0
- package/src/prompts/reviewer.md +64 -68
- package/src/prompts/tools/output.md +22 -3
- package/src/prompts/tools/task.md +32 -33
- package/src/utils/clipboard.ts +2 -1
- package/src/utils/tools-manager.ts +110 -8
- 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
|
+
});
|