@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,200 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface SnapcraftPublisher {
|
|
5
|
+
"display-name"?: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
validation?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SnapcraftChannel {
|
|
12
|
+
name?: string;
|
|
13
|
+
track?: string;
|
|
14
|
+
risk?: string;
|
|
15
|
+
branch?: string | null;
|
|
16
|
+
architecture?: string;
|
|
17
|
+
"released-at"?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SnapcraftDownload {
|
|
21
|
+
size?: number;
|
|
22
|
+
url?: string;
|
|
23
|
+
"sha3-384"?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SnapcraftChannelMapEntry {
|
|
27
|
+
channel?: SnapcraftChannel;
|
|
28
|
+
version?: string;
|
|
29
|
+
revision?: number | string;
|
|
30
|
+
download?: SnapcraftDownload;
|
|
31
|
+
type?: string;
|
|
32
|
+
"created-at"?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SnapcraftSnap {
|
|
36
|
+
name?: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
summary?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
publisher?: SnapcraftPublisher;
|
|
41
|
+
version?: string;
|
|
42
|
+
confinement?: string;
|
|
43
|
+
base?: string;
|
|
44
|
+
downloads?: number;
|
|
45
|
+
download?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SnapcraftResponse {
|
|
49
|
+
name?: string;
|
|
50
|
+
title?: string;
|
|
51
|
+
summary?: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
publisher?: SnapcraftPublisher;
|
|
54
|
+
version?: string;
|
|
55
|
+
confinement?: string;
|
|
56
|
+
base?: string;
|
|
57
|
+
downloads?: number;
|
|
58
|
+
download?: number;
|
|
59
|
+
snap?: SnapcraftSnap;
|
|
60
|
+
"channel-map"?: SnapcraftChannelMapEntry[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatPublisher(publisher?: SnapcraftPublisher): string | null {
|
|
64
|
+
if (!publisher) return null;
|
|
65
|
+
const displayName = publisher["display-name"] ?? publisher.username ?? publisher.id;
|
|
66
|
+
if (!displayName) return null;
|
|
67
|
+
if (publisher.username && displayName !== publisher.username) {
|
|
68
|
+
return `${displayName} (@${publisher.username})`;
|
|
69
|
+
}
|
|
70
|
+
return displayName;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatChannelName(channel?: SnapcraftChannel): string | null {
|
|
74
|
+
if (!channel) return null;
|
|
75
|
+
if (channel.name?.includes("/")) return channel.name;
|
|
76
|
+
if (channel.track && channel.risk) {
|
|
77
|
+
const branch = channel.branch ? `/${channel.branch}` : "";
|
|
78
|
+
return `${channel.track}/${channel.risk}${branch}`;
|
|
79
|
+
}
|
|
80
|
+
return channel.name ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pickVersionFromChannels(entries: SnapcraftChannelMapEntry[]): string | undefined {
|
|
84
|
+
const stable = entries.find((entry) => entry.channel?.risk === "stable" && entry.version);
|
|
85
|
+
if (stable?.version) return stable.version;
|
|
86
|
+
const first = entries.find((entry) => entry.version);
|
|
87
|
+
return first?.version;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractDownloads(snapInfo: SnapcraftSnap | SnapcraftResponse, data: SnapcraftResponse): number | null {
|
|
91
|
+
const candidates = [snapInfo.downloads, snapInfo.download, data.downloads, data.download];
|
|
92
|
+
for (const value of candidates) {
|
|
93
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const handleSnapcraft: SpecialHandler = async (
|
|
99
|
+
url: string,
|
|
100
|
+
timeout: number,
|
|
101
|
+
signal?: AbortSignal,
|
|
102
|
+
): Promise<RenderResult | null> => {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = new URL(url);
|
|
105
|
+
if (parsed.hostname !== "snapcraft.io" && parsed.hostname !== "www.snapcraft.io") return null;
|
|
106
|
+
|
|
107
|
+
const installMatch = parsed.pathname.match(/^\/install\/([^/]+)\/?$/);
|
|
108
|
+
const directMatch = parsed.pathname.match(/^\/([^/]+)\/?$/);
|
|
109
|
+
if (!installMatch && !directMatch) return null;
|
|
110
|
+
|
|
111
|
+
const snapName = decodeURIComponent((installMatch ?? directMatch)![1]);
|
|
112
|
+
const fetchedAt = new Date().toISOString();
|
|
113
|
+
|
|
114
|
+
const apiUrl = `https://api.snapcraft.io/v2/snaps/info/${encodeURIComponent(snapName)}`;
|
|
115
|
+
const result = await loadPage(apiUrl, {
|
|
116
|
+
timeout,
|
|
117
|
+
signal,
|
|
118
|
+
headers: {
|
|
119
|
+
Accept: "application/json",
|
|
120
|
+
"Snap-Device-Series": "16",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (!result.ok) return null;
|
|
124
|
+
|
|
125
|
+
let data: SnapcraftResponse;
|
|
126
|
+
try {
|
|
127
|
+
data = JSON.parse(result.content) as SnapcraftResponse;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const snapInfo = data.snap ?? data;
|
|
133
|
+
const name = snapInfo.title ?? snapInfo.name ?? data.name ?? snapName;
|
|
134
|
+
const summary = snapInfo.summary ?? data.summary;
|
|
135
|
+
const description = snapInfo.description ?? data.description;
|
|
136
|
+
const publisher = formatPublisher(snapInfo.publisher ?? data.publisher);
|
|
137
|
+
const confinement = snapInfo.confinement ?? data.confinement;
|
|
138
|
+
const base = snapInfo.base ?? data.base;
|
|
139
|
+
|
|
140
|
+
const channelMap = data["channel-map"] ?? [];
|
|
141
|
+
let version = snapInfo.version ?? data.version;
|
|
142
|
+
if (!version && channelMap.length > 0) {
|
|
143
|
+
version = pickVersionFromChannels(channelMap);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const downloads = extractDownloads(snapInfo, data);
|
|
147
|
+
|
|
148
|
+
const channels = new Map<string, { version?: string; architectures: Set<string> }>();
|
|
149
|
+
for (const entry of channelMap) {
|
|
150
|
+
const channelName = formatChannelName(entry.channel);
|
|
151
|
+
if (!channelName) continue;
|
|
152
|
+
const existing = channels.get(channelName) ?? { architectures: new Set<string>() };
|
|
153
|
+
if (!existing.version && entry.version) existing.version = entry.version;
|
|
154
|
+
if (entry.channel?.architecture) existing.architectures.add(entry.channel.architecture);
|
|
155
|
+
channels.set(channelName, existing);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let md = `# ${name}\n\n`;
|
|
159
|
+
if (summary) md += `${summary}\n\n`;
|
|
160
|
+
|
|
161
|
+
md += `**Version:** ${version ?? "unknown"}`;
|
|
162
|
+
if (confinement) md += ` · **Confinement:** ${confinement}`;
|
|
163
|
+
if (base) md += ` · **Base:** ${base}`;
|
|
164
|
+
md += "\n";
|
|
165
|
+
if (publisher) md += `**Publisher:** ${publisher}\n`;
|
|
166
|
+
if (downloads !== null) md += `**Downloads:** ${formatCount(downloads)}\n`;
|
|
167
|
+
md += "\n";
|
|
168
|
+
|
|
169
|
+
if (channels.size > 0) {
|
|
170
|
+
md += "## Channels\n\n";
|
|
171
|
+
const sortedChannels = Array.from(channels.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
172
|
+
for (const [channelName, info] of sortedChannels) {
|
|
173
|
+
const arches = Array.from(info.architectures).sort();
|
|
174
|
+
const versionSuffix = info.version ? `: ${info.version}` : "";
|
|
175
|
+
const archSuffix = arches.length > 0 ? ` (${arches.join(", ")})` : "";
|
|
176
|
+
md += `- ${channelName}${versionSuffix}${archSuffix}\n`;
|
|
177
|
+
}
|
|
178
|
+
md += "\n";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const descriptionText = description ?? summary;
|
|
182
|
+
if (descriptionText) {
|
|
183
|
+
md += `## Description\n\n${descriptionText}\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const output = finalizeOutput(md);
|
|
187
|
+
return {
|
|
188
|
+
url,
|
|
189
|
+
finalUrl: url,
|
|
190
|
+
contentType: "text/markdown",
|
|
191
|
+
method: "snapcraft",
|
|
192
|
+
content: output.content,
|
|
193
|
+
fetchedAt,
|
|
194
|
+
truncated: output.truncated,
|
|
195
|
+
notes: ["Fetched via Snapcraft API"],
|
|
196
|
+
};
|
|
197
|
+
} catch {}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleBluesky } from "./bluesky";
|
|
3
|
+
import { handleMastodon } from "./mastodon";
|
|
4
|
+
|
|
5
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
6
|
+
|
|
7
|
+
describe.skipIf(SKIP)("handleMastodon", () => {
|
|
8
|
+
it("returns null for non-Mastodon URLs", async () => {
|
|
9
|
+
const result = await handleMastodon("https://example.com", 20);
|
|
10
|
+
expect(result).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for URLs without @user pattern", async () => {
|
|
14
|
+
const result = await handleMastodon("https://mastodon.social/about", 20);
|
|
15
|
+
expect(result).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it(
|
|
19
|
+
"fetches a Mastodon profile",
|
|
20
|
+
async () => {
|
|
21
|
+
// @Gargron is Eugen Rochko, creator of Mastodon - very stable
|
|
22
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
|
|
23
|
+
expect(result).not.toBeNull();
|
|
24
|
+
expect(result?.method).toBe("mastodon");
|
|
25
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
26
|
+
expect(result?.content).toContain("Gargron");
|
|
27
|
+
expect(result?.content).toContain("@Gargron");
|
|
28
|
+
expect(result?.content).toContain("**Followers:**");
|
|
29
|
+
expect(result?.content).toContain("**Following:**");
|
|
30
|
+
expect(result?.content).toContain("**Posts:**");
|
|
31
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
32
|
+
expect(result?.truncated).toBeDefined();
|
|
33
|
+
expect(result?.notes?.[0]).toContain("Mastodon API");
|
|
34
|
+
},
|
|
35
|
+
{ timeout: 30000 },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
it(
|
|
39
|
+
"fetches a Mastodon post",
|
|
40
|
+
async () => {
|
|
41
|
+
// Gargron's post ID 1 - the first ever Mastodon post
|
|
42
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron/1", 20);
|
|
43
|
+
// Post 1 may not exist anymore; check gracefully
|
|
44
|
+
if (result !== null) {
|
|
45
|
+
expect(result.method).toBe("mastodon");
|
|
46
|
+
expect(result.contentType).toBe("text/markdown");
|
|
47
|
+
expect(result.content).toContain("Post by");
|
|
48
|
+
expect(result.content).toContain("@Gargron");
|
|
49
|
+
expect(result.fetchedAt).toBeTruthy();
|
|
50
|
+
expect(result.truncated).toBeDefined();
|
|
51
|
+
expect(result.notes?.[0]).toContain("Mastodon API");
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ timeout: 30000 },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
it(
|
|
58
|
+
"handles a stable pinned post",
|
|
59
|
+
async () => {
|
|
60
|
+
// Use a well-known post from mastodon.social - Gargron's announcement post
|
|
61
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron/109318821117356215", 20);
|
|
62
|
+
// May not exist, check gracefully
|
|
63
|
+
if (result !== null) {
|
|
64
|
+
expect(result.method).toBe("mastodon");
|
|
65
|
+
expect(result.contentType).toBe("text/markdown");
|
|
66
|
+
expect(result.content).toContain("@Gargron");
|
|
67
|
+
expect(result.content).toContain("replies");
|
|
68
|
+
expect(result.content).toContain("boosts");
|
|
69
|
+
expect(result.content).toContain("favorites");
|
|
70
|
+
expect(result.fetchedAt).toBeTruthy();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ timeout: 30000 },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it(
|
|
77
|
+
"includes recent posts in profile",
|
|
78
|
+
async () => {
|
|
79
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
|
|
80
|
+
expect(result).not.toBeNull();
|
|
81
|
+
// May include recent posts section
|
|
82
|
+
if (result?.content?.includes("## Recent Posts")) {
|
|
83
|
+
expect(result.content).toMatch(/###\s+\w+/); // Date header
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ timeout: 30000 },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
it("returns null for non-Mastodon instance with @user pattern", async () => {
|
|
90
|
+
// A site that has @user pattern but isn't Mastodon
|
|
91
|
+
const result = await handleMastodon("https://twitter.com/@jack", 20);
|
|
92
|
+
expect(result).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe.skipIf(SKIP)("handleBluesky", () => {
|
|
97
|
+
it("returns null for non-Bluesky URLs", async () => {
|
|
98
|
+
const result = await handleBluesky("https://example.com", 20);
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns null for bsky.app URLs without profile path", async () => {
|
|
103
|
+
const result = await handleBluesky("https://bsky.app/about", 20);
|
|
104
|
+
expect(result).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it(
|
|
108
|
+
"fetches a Bluesky profile",
|
|
109
|
+
async () => {
|
|
110
|
+
// bsky.app official account - stable
|
|
111
|
+
const result = await handleBluesky("https://bsky.app/profile/bsky.app", 20);
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result?.method).toBe("bluesky-api");
|
|
114
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
115
|
+
expect(result?.content).toContain("bsky.app");
|
|
116
|
+
expect(result?.content).toContain("@bsky.app");
|
|
117
|
+
expect(result?.content).toContain("**Followers:**");
|
|
118
|
+
expect(result?.content).toContain("**Following:**");
|
|
119
|
+
expect(result?.content).toContain("**Posts:**");
|
|
120
|
+
expect(result?.content).toContain("**DID:**");
|
|
121
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
122
|
+
expect(result?.truncated).toBeDefined();
|
|
123
|
+
expect(result?.notes).toContain("Fetched via AT Protocol API");
|
|
124
|
+
},
|
|
125
|
+
{ timeout: 30000 },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
it(
|
|
129
|
+
"fetches Jay Graber's profile",
|
|
130
|
+
async () => {
|
|
131
|
+
// Jay Graber - CEO of Bluesky, very stable
|
|
132
|
+
const result = await handleBluesky("https://bsky.app/profile/jay.bsky.team", 20);
|
|
133
|
+
expect(result).not.toBeNull();
|
|
134
|
+
expect(result?.method).toBe("bluesky-api");
|
|
135
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
136
|
+
expect(result?.content).toContain("@jay.bsky.team");
|
|
137
|
+
expect(result?.content).toContain("**Followers:**");
|
|
138
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
139
|
+
expect(result?.truncated).toBeDefined();
|
|
140
|
+
},
|
|
141
|
+
{ timeout: 30000 },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it(
|
|
145
|
+
"fetches a Bluesky post",
|
|
146
|
+
async () => {
|
|
147
|
+
// A post from bsky.app - use a well-known stable post
|
|
148
|
+
const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
|
|
149
|
+
// Post may not exist, check gracefully
|
|
150
|
+
if (result !== null) {
|
|
151
|
+
expect(result.method).toBe("bluesky-api");
|
|
152
|
+
expect(result.contentType).toBe("text/markdown");
|
|
153
|
+
expect(result.content).toContain("# Bluesky Post");
|
|
154
|
+
expect(result.content).toContain("@bsky.app");
|
|
155
|
+
expect(result.fetchedAt).toBeTruthy();
|
|
156
|
+
expect(result.truncated).toBeDefined();
|
|
157
|
+
expect(result.notes?.[0]).toContain("AT URI");
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{ timeout: 30000 },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it(
|
|
164
|
+
"includes post stats",
|
|
165
|
+
async () => {
|
|
166
|
+
const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
|
|
167
|
+
// Stats include likes, reposts, replies
|
|
168
|
+
if (result?.content) {
|
|
169
|
+
// Should have some engagement markers
|
|
170
|
+
const hasStats =
|
|
171
|
+
result.content.includes("❤️") || result.content.includes("🔁") || result.content.includes("💬");
|
|
172
|
+
expect(hasStats || result.content.includes("# Bluesky Post")).toBe(true);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
{ timeout: 30000 },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
it(
|
|
179
|
+
"handles www.bsky.app URLs",
|
|
180
|
+
async () => {
|
|
181
|
+
const result = await handleBluesky("https://www.bsky.app/profile/bsky.app", 20);
|
|
182
|
+
expect(result).not.toBeNull();
|
|
183
|
+
expect(result?.method).toBe("bluesky-api");
|
|
184
|
+
},
|
|
185
|
+
{ timeout: 30000 },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
it("returns null for invalid profile handle", async () => {
|
|
189
|
+
const result = await handleBluesky("https://bsky.app/profile/", 20);
|
|
190
|
+
expect(result).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleReddit } from "./reddit";
|
|
3
|
+
import { handleStackOverflow } from "./stackoverflow";
|
|
4
|
+
import { handleTwitter } from "./twitter";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleTwitter", () => {
|
|
9
|
+
it("returns null for non-Twitter URLs", async () => {
|
|
10
|
+
const result = await handleTwitter("https://example.com", 10);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it(
|
|
15
|
+
"handles twitter.com status URLs",
|
|
16
|
+
async () => {
|
|
17
|
+
const result = await handleTwitter("https://twitter.com/jack/status/20", 10000);
|
|
18
|
+
expect(result).not.toBeNull();
|
|
19
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
20
|
+
expect(result?.contentType).toMatch(/^text\/(markdown|plain)$/);
|
|
21
|
+
// Either successful fetch or blocked/unavailable message
|
|
22
|
+
if (result?.method === "twitter-nitter") {
|
|
23
|
+
expect(result?.content).toContain("Tweet by");
|
|
24
|
+
expect(result?.notes?.[0]).toContain("Via Nitter");
|
|
25
|
+
} else if (result?.method === "twitter-blocked") {
|
|
26
|
+
expect(result?.content).toContain("blocks automated access");
|
|
27
|
+
expect(result?.notes?.[0]).toContain("Nitter instances unavailable");
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{ timeout: 30000 },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
it(
|
|
34
|
+
"handles x.com status URLs",
|
|
35
|
+
async () => {
|
|
36
|
+
const result = await handleTwitter("https://x.com/elonmusk/status/1", 10000);
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
39
|
+
expect(result?.contentType).toMatch(/^text\/(markdown|plain)$/);
|
|
40
|
+
// Either successful fetch or blocked/unavailable message
|
|
41
|
+
if (result?.method === "twitter-nitter") {
|
|
42
|
+
expect(result?.finalUrl).toContain("nitter");
|
|
43
|
+
} else if (result?.method === "twitter-blocked") {
|
|
44
|
+
expect(result?.content).toContain("blocks automated access");
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{ timeout: 30000 },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it(
|
|
51
|
+
"handles www.twitter.com URLs",
|
|
52
|
+
async () => {
|
|
53
|
+
const result = await handleTwitter("https://www.twitter.com/twitter/status/1", 10000);
|
|
54
|
+
expect(result).not.toBeNull();
|
|
55
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
56
|
+
},
|
|
57
|
+
{ timeout: 30000 },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it(
|
|
61
|
+
"handles www.x.com URLs",
|
|
62
|
+
async () => {
|
|
63
|
+
const result = await handleTwitter("https://www.x.com/twitter/status/1", 10000);
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
66
|
+
},
|
|
67
|
+
{ timeout: 30000 },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it(
|
|
71
|
+
"may fail due to Nitter availability",
|
|
72
|
+
async () => {
|
|
73
|
+
// Test that failure returns helpful message instead of null
|
|
74
|
+
const result = await handleTwitter("https://twitter.com/nonexistent/status/999999999999999999", 10000);
|
|
75
|
+
expect(result).not.toBeNull();
|
|
76
|
+
// Should return blocked message when Nitter fails
|
|
77
|
+
if (result?.method === "twitter-blocked") {
|
|
78
|
+
expect(result?.content).toContain("Nitter instances were unavailable");
|
|
79
|
+
expect(result?.content).toContain("Try:");
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{ timeout: 30000 },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe.skipIf(SKIP)("handleReddit", () => {
|
|
87
|
+
it("returns null for non-Reddit URLs", async () => {
|
|
88
|
+
const result = await handleReddit("https://example.com", 10);
|
|
89
|
+
expect(result).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("fetches subreddit", async () => {
|
|
93
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result?.method).toBe("reddit");
|
|
96
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
97
|
+
expect(result?.content).toContain("# r/programming");
|
|
98
|
+
expect(result?.content).toMatch(/\*\*.*\*\*/); // Contains bold formatting
|
|
99
|
+
expect(result?.notes).toContain("Fetched via Reddit JSON API");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("fetches individual post", async () => {
|
|
103
|
+
// Use a more reliable recent post URL
|
|
104
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
|
|
105
|
+
// Individual post may fail if post doesn't exist, check if we get data
|
|
106
|
+
if (result !== null) {
|
|
107
|
+
expect(result.method).toBe("reddit");
|
|
108
|
+
expect(result.contentType).toBe("text/markdown");
|
|
109
|
+
expect(result.content).toContain("# r/");
|
|
110
|
+
expect(result.notes).toContain("Fetched via Reddit JSON API");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("includes comments in post when available", async () => {
|
|
115
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
|
|
116
|
+
// Comments test - just verify structure if post with comments is found
|
|
117
|
+
if (result?.content?.includes("## Top Comments")) {
|
|
118
|
+
expect(result.content).toContain("### u/");
|
|
119
|
+
expect(result.content).toContain("points");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("handles old.reddit.com", async () => {
|
|
124
|
+
const result = await handleReddit("https://old.reddit.com/r/programming/", 20000);
|
|
125
|
+
expect(result).not.toBeNull();
|
|
126
|
+
expect(result?.method).toBe("reddit");
|
|
127
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
128
|
+
expect(result?.content).toContain("# r/");
|
|
129
|
+
expect(result?.notes).toContain("Fetched via Reddit JSON API");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("handles reddit.com without www", async () => {
|
|
133
|
+
const result = await handleReddit("https://reddit.com/r/programming/", 20000);
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(result?.method).toBe("reddit");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("handles URLs with query parameters", async () => {
|
|
139
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/?sort=top", 20000);
|
|
140
|
+
expect(result).not.toBeNull();
|
|
141
|
+
expect(result?.method).toBe("reddit");
|
|
142
|
+
expect(result?.content).toContain("# r/");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null for malformed Reddit URLs", async () => {
|
|
146
|
+
const result = await handleReddit("https://www.reddit.com/invalid", 20000);
|
|
147
|
+
// May return null or empty result
|
|
148
|
+
if (result !== null) {
|
|
149
|
+
expect(result.content).toBeDefined();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe.skipIf(SKIP)("handleStackOverflow", () => {
|
|
155
|
+
it("returns null for non-SO URLs", async () => {
|
|
156
|
+
const result = await handleStackOverflow("https://example.com", 10);
|
|
157
|
+
expect(result).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns null for SO URLs without question ID", async () => {
|
|
161
|
+
const result = await handleStackOverflow("https://stackoverflow.com/", 10);
|
|
162
|
+
expect(result).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("fetches a known question", async () => {
|
|
166
|
+
// Use a well-known question that definitely exists
|
|
167
|
+
const result = await handleStackOverflow(
|
|
168
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
169
|
+
20000,
|
|
170
|
+
);
|
|
171
|
+
// API may fail or rate limit, check gracefully
|
|
172
|
+
if (result !== null) {
|
|
173
|
+
expect(result.method).toBe("stackoverflow");
|
|
174
|
+
expect(result.contentType).toBe("text/markdown");
|
|
175
|
+
expect(result.content).toContain("# ");
|
|
176
|
+
expect(result.content).toContain("**Score:");
|
|
177
|
+
expect(result.content).toContain("**Tags:");
|
|
178
|
+
expect(result.content).toContain("## Question");
|
|
179
|
+
expect(result.notes).toContain("Fetched via Stack Exchange API");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("includes answers", async () => {
|
|
184
|
+
const result = await handleStackOverflow(
|
|
185
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
186
|
+
20000,
|
|
187
|
+
);
|
|
188
|
+
if (result?.content?.includes("## Answers")) {
|
|
189
|
+
expect(result.content).toContain("### Score:");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("shows accepted answer marker when present", async () => {
|
|
194
|
+
const result = await handleStackOverflow(
|
|
195
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
196
|
+
20000,
|
|
197
|
+
);
|
|
198
|
+
// Some questions may have accepted answers
|
|
199
|
+
if (result?.content?.includes("(Accepted)")) {
|
|
200
|
+
expect(result.content).toContain("## Answers");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("handles stackoverflow.com", async () => {
|
|
205
|
+
const result = await handleStackOverflow(
|
|
206
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array",
|
|
207
|
+
20000,
|
|
208
|
+
);
|
|
209
|
+
expect(result).not.toBeNull();
|
|
210
|
+
expect(result?.method).toBe("stackoverflow");
|
|
211
|
+
expect(result?.content).toContain("# ");
|
|
212
|
+
expect(result?.content).toContain("## Question");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("handles other StackExchange sites", async () => {
|
|
216
|
+
const result = await handleStackOverflow("https://math.stackexchange.com/questions/1000/", 20000);
|
|
217
|
+
// API may fail, check gracefully
|
|
218
|
+
if (result !== null) {
|
|
219
|
+
expect(result.method).toBe("stackoverflow");
|
|
220
|
+
expect(result.contentType).toBe("text/markdown");
|
|
221
|
+
expect(result.content).toContain("# ");
|
|
222
|
+
expect(result.notes).toContain("Fetched via Stack Exchange API");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("extracts question ID from URL", async () => {
|
|
227
|
+
const result = await handleStackOverflow(
|
|
228
|
+
"https://stackoverflow.com/questions/1234567/some-long-question-title",
|
|
229
|
+
20000,
|
|
230
|
+
);
|
|
231
|
+
// Should attempt to fetch, may or may not exist
|
|
232
|
+
// Either returns valid result or null
|
|
233
|
+
if (result !== null) {
|
|
234
|
+
expect(result.method).toBe("stackoverflow");
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("handles URLs without trailing slash", async () => {
|
|
239
|
+
const result = await handleStackOverflow("https://stackoverflow.com/questions/11227809", 20000);
|
|
240
|
+
// API may fail, check gracefully
|
|
241
|
+
if (result !== null) {
|
|
242
|
+
expect(result.method).toBe("stackoverflow");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("includes question metadata", async () => {
|
|
247
|
+
const result = await handleStackOverflow(
|
|
248
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
249
|
+
20000,
|
|
250
|
+
);
|
|
251
|
+
// API may fail, check gracefully
|
|
252
|
+
if (result !== null) {
|
|
253
|
+
expect(result.content).toContain("**Score:");
|
|
254
|
+
expect(result.content).toContain("**Answers:");
|
|
255
|
+
expect(result.content).toContain("**Tags:");
|
|
256
|
+
expect(result.content).toContain("**Asked by:");
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|