@aaroncql/pim-agent 0.0.1
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import ky, { HTTPError, TimeoutError, type KyInstance } from "ky";
|
|
2
|
+
import type { WebFetchPage } from "./fetch";
|
|
3
|
+
|
|
4
|
+
type JinaReaderFetch = (
|
|
5
|
+
input: Parameters<typeof fetch>[0],
|
|
6
|
+
init?: Parameters<typeof fetch>[1]
|
|
7
|
+
) => ReturnType<typeof fetch>;
|
|
8
|
+
|
|
9
|
+
type JinaReaderClientOptions = {
|
|
10
|
+
readonly endpoint?: string;
|
|
11
|
+
readonly apiKey?: string;
|
|
12
|
+
readonly fetch?: JinaReaderFetch;
|
|
13
|
+
readonly timeoutMs?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type JinaReaderFetchInput = {
|
|
17
|
+
readonly url: string;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
class JinaReaderClientError extends Error {
|
|
22
|
+
public constructor(message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "JinaReaderClientError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class JinaReaderClient {
|
|
29
|
+
private static readonly defaultTimeoutMs = 20_000;
|
|
30
|
+
|
|
31
|
+
private readonly endpoint: string;
|
|
32
|
+
private readonly headers: Headers;
|
|
33
|
+
private readonly ky: KyInstance;
|
|
34
|
+
private readonly timeoutMs: number;
|
|
35
|
+
|
|
36
|
+
public constructor(options: JinaReaderClientOptions = {}) {
|
|
37
|
+
this.endpoint = JinaReaderClient.normalizeEndpoint(
|
|
38
|
+
options.endpoint ?? "https://r.jina.ai"
|
|
39
|
+
);
|
|
40
|
+
this.headers = JinaReaderClient.buildHeaders(options.apiKey);
|
|
41
|
+
this.timeoutMs = options.timeoutMs ?? JinaReaderClient.defaultTimeoutMs;
|
|
42
|
+
this.ky = ky.create(
|
|
43
|
+
options.fetch === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { fetch: options.fetch as typeof fetch }
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async fetchUrl(input: JinaReaderFetchInput): Promise<WebFetchPage> {
|
|
50
|
+
if (input.signal?.aborted) {
|
|
51
|
+
throw new JinaReaderClientError("Request aborted.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let response: Response;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
response = await this.ky(`${this.endpoint}/${input.url}`, {
|
|
58
|
+
headers: this.headers,
|
|
59
|
+
timeout: this.timeoutMs,
|
|
60
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const aborted = input.signal?.aborted ?? false;
|
|
64
|
+
|
|
65
|
+
if (JinaReaderClient.isAbortError(error) || aborted) {
|
|
66
|
+
throw new JinaReaderClientError("Request aborted.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (error instanceof TimeoutError) {
|
|
70
|
+
throw new JinaReaderClientError(
|
|
71
|
+
`Request timed out after ${this.timeoutMs}ms.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (error instanceof HTTPError) {
|
|
76
|
+
throw new JinaReaderClientError(
|
|
77
|
+
`Request failed with HTTP ${error.response.status}: ${JinaReaderClient.excerpt(JinaReaderClient.stringifyErrorData(error.data))}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new JinaReaderClientError(
|
|
82
|
+
`Request failed: ${describeError(error)}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return JinaReaderClient.parseResponse(
|
|
87
|
+
input.url,
|
|
88
|
+
response,
|
|
89
|
+
await response.text()
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private static buildHeaders(apiKey: string | undefined): Headers {
|
|
94
|
+
const headers = new Headers({ Accept: "application/json" });
|
|
95
|
+
|
|
96
|
+
if (apiKey !== undefined && apiKey.length > 0) {
|
|
97
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private static normalizeEndpoint(endpoint: string): string {
|
|
104
|
+
return endpoint.replace(/\/+$/u, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static parseResponse(
|
|
108
|
+
requestedUrl: string,
|
|
109
|
+
response: Response,
|
|
110
|
+
responseText: string
|
|
111
|
+
): WebFetchPage {
|
|
112
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
113
|
+
const declaresJson =
|
|
114
|
+
contentType.includes("application/json") || contentType.includes("+json");
|
|
115
|
+
const parsedJson = JinaReaderClient.tryParseJson(responseText);
|
|
116
|
+
|
|
117
|
+
if (parsedJson === undefined) {
|
|
118
|
+
if (declaresJson) {
|
|
119
|
+
throw new JinaReaderClientError("Response contained malformed JSON.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return JinaReaderClient.createPage({
|
|
123
|
+
title: "",
|
|
124
|
+
url: requestedUrl,
|
|
125
|
+
content: responseText,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return JinaReaderClient.parseJsonPayload(requestedUrl, parsedJson);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static parseJsonPayload(
|
|
133
|
+
requestedUrl: string,
|
|
134
|
+
parsedJson: unknown
|
|
135
|
+
): WebFetchPage {
|
|
136
|
+
const responseRecord = JinaReaderClient.asRecord(parsedJson);
|
|
137
|
+
const payload =
|
|
138
|
+
JinaReaderClient.asRecord(responseRecord?.["data"]) ?? responseRecord;
|
|
139
|
+
|
|
140
|
+
if (payload === undefined) {
|
|
141
|
+
throw new JinaReaderClientError("Response contained invalid payload.");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return JinaReaderClient.createPage({
|
|
145
|
+
title: JinaReaderClient.optionalString(payload["title"], "title") ?? "",
|
|
146
|
+
url:
|
|
147
|
+
JinaReaderClient.optionalString(payload["url"], "url") ?? requestedUrl,
|
|
148
|
+
content: JinaReaderClient.requiredString(payload["content"], "content"),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static createPage(page: WebFetchPage): WebFetchPage {
|
|
153
|
+
if (page.content.trim().length === 0) {
|
|
154
|
+
throw new JinaReaderClientError("Response contained empty content.");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return page;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private static tryParseJson(text: string): unknown | undefined {
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(text);
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private static requiredString(value: unknown, name: string): string {
|
|
169
|
+
if (typeof value !== "string") {
|
|
170
|
+
throw new JinaReaderClientError(
|
|
171
|
+
`Response contained invalid payload: expected string ${name}.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static optionalString(
|
|
179
|
+
value: unknown,
|
|
180
|
+
name: string
|
|
181
|
+
): string | undefined {
|
|
182
|
+
if (value === undefined || value === null) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (typeof value !== "string") {
|
|
187
|
+
throw new JinaReaderClientError(
|
|
188
|
+
`Response contained invalid payload: expected string ${name}.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private static asRecord(
|
|
196
|
+
value: unknown
|
|
197
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
198
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return value as Readonly<Record<string, unknown>>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private static stringifyErrorData(data: unknown): string {
|
|
206
|
+
if (typeof data === "string") {
|
|
207
|
+
return data;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (data === undefined) {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return JSON.stringify(data);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private static excerpt(text: string): string {
|
|
218
|
+
const excerpt = text.replaceAll(/\s+/gu, " ").trim().slice(0, 200);
|
|
219
|
+
|
|
220
|
+
return excerpt.length === 0 ? "empty response body" : excerpt;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private static isAbortError(error: unknown): boolean {
|
|
224
|
+
return error instanceof Error && error.name === "AbortError";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function describeError(error: unknown): string {
|
|
229
|
+
return error instanceof Error ? error.message : String(error);
|
|
230
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { platform } from "node:os";
|
|
2
|
+
import type { WebFetchPage } from "./fetch";
|
|
3
|
+
import { createMarkdownSnapshotScript } from "./WebViewMarkdownSnapshot";
|
|
4
|
+
|
|
5
|
+
export type WebViewLike = {
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly title: string;
|
|
8
|
+
navigate: (url: string) => Promise<void>;
|
|
9
|
+
evaluate: <T = unknown>(script: string) => Promise<T>;
|
|
10
|
+
close: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WebViewFactory = () => WebViewLike;
|
|
14
|
+
|
|
15
|
+
type WebViewFetchClientOptions = {
|
|
16
|
+
readonly factory?: WebViewFactory;
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type WebViewFetchInput = {
|
|
21
|
+
readonly url: string;
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type WebViewSnapshot = {
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly content: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class WebViewFetchClientError extends Error {
|
|
32
|
+
public constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "WebViewFetchClientError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class WebViewFetchClient {
|
|
39
|
+
private static readonly defaultTimeoutMs = 20_000;
|
|
40
|
+
|
|
41
|
+
private readonly factory: WebViewFactory;
|
|
42
|
+
private readonly timeoutMs: number;
|
|
43
|
+
|
|
44
|
+
public constructor(options: WebViewFetchClientOptions = {}) {
|
|
45
|
+
this.factory = options.factory ?? WebViewFetchClient.defaultFactory;
|
|
46
|
+
this.timeoutMs = options.timeoutMs ?? WebViewFetchClient.defaultTimeoutMs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static defaultFactory(): WebViewLike {
|
|
50
|
+
return new Bun.WebView(
|
|
51
|
+
platform() === "darwin" ? undefined : { backend: "chrome" }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public async fetchHtml(input: WebViewFetchInput): Promise<WebFetchPage> {
|
|
56
|
+
return this.capturePage(input, WebViewFetchClient.htmlSnapshotScript());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public async fetchMarkdown(input: WebViewFetchInput): Promise<WebFetchPage> {
|
|
60
|
+
return this.capturePage(input, createMarkdownSnapshotScript());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async capturePage(
|
|
64
|
+
input: WebViewFetchInput,
|
|
65
|
+
snapshotScript: string
|
|
66
|
+
): Promise<WebFetchPage> {
|
|
67
|
+
const signal = input.signal;
|
|
68
|
+
|
|
69
|
+
if (signal?.aborted) {
|
|
70
|
+
throw new WebViewFetchClientError("Request aborted.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let view: WebViewLike;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
view = this.factory();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new WebViewFetchClientError(
|
|
79
|
+
`Request failed: ${describeError(error)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let aborted = false;
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
const onAbort = () => {
|
|
86
|
+
aborted = true;
|
|
87
|
+
WebViewFetchClient.safeClose(view);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
91
|
+
|
|
92
|
+
const timeoutHandle = setTimeout(() => {
|
|
93
|
+
timedOut = true;
|
|
94
|
+
WebViewFetchClient.safeClose(view);
|
|
95
|
+
}, this.timeoutMs);
|
|
96
|
+
|
|
97
|
+
const checkInterrupted = (): void => {
|
|
98
|
+
if (timedOut) {
|
|
99
|
+
throw new WebViewFetchClientError(
|
|
100
|
+
`Request timed out after ${this.timeoutMs}ms.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (aborted) {
|
|
104
|
+
throw new WebViewFetchClientError("Request aborted.");
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await view.navigate(input.url);
|
|
110
|
+
checkInterrupted();
|
|
111
|
+
|
|
112
|
+
const snapshot = WebViewFetchClient.readSnapshot(
|
|
113
|
+
await view.evaluate<unknown>(snapshotScript)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (snapshot.content.trim().length === 0) {
|
|
117
|
+
throw new WebViewFetchClientError("Response contained empty content.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const finalUrl =
|
|
121
|
+
snapshot.url.length > 0
|
|
122
|
+
? snapshot.url
|
|
123
|
+
: view.url.length > 0
|
|
124
|
+
? view.url
|
|
125
|
+
: input.url;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
title: snapshot.title,
|
|
129
|
+
url: finalUrl,
|
|
130
|
+
content: snapshot.content,
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
checkInterrupted();
|
|
134
|
+
|
|
135
|
+
if (error instanceof WebViewFetchClientError) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new WebViewFetchClientError(
|
|
140
|
+
`Request failed: ${describeError(error)}`
|
|
141
|
+
);
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timeoutHandle);
|
|
144
|
+
signal?.removeEventListener("abort", onAbort);
|
|
145
|
+
WebViewFetchClient.safeClose(view);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static htmlSnapshotScript(): string {
|
|
150
|
+
return String.raw`(() => ({
|
|
151
|
+
title: document.title,
|
|
152
|
+
url: location.href,
|
|
153
|
+
content: document.documentElement.outerHTML,
|
|
154
|
+
}))()`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private static readSnapshot(value: unknown): WebViewSnapshot {
|
|
158
|
+
if (typeof value !== "object" || value === null) {
|
|
159
|
+
throw new WebViewFetchClientError("Response contained invalid payload.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const record = value as Record<string, unknown>;
|
|
163
|
+
|
|
164
|
+
if (typeof record["content"] !== "string") {
|
|
165
|
+
throw new WebViewFetchClientError("Response contained invalid payload.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
content: record["content"],
|
|
170
|
+
title: typeof record["title"] === "string" ? record["title"] : "",
|
|
171
|
+
url: typeof record["url"] === "string" ? record["url"] : "",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private static safeClose(view: WebViewLike): void {
|
|
176
|
+
try {
|
|
177
|
+
view.close();
|
|
178
|
+
} catch {
|
|
179
|
+
// close() throws if already closed; treat as idempotent.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function describeError(error: unknown): string {
|
|
185
|
+
return error instanceof Error ? error.message : String(error);
|
|
186
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createMarkdownSnapshotScript,
|
|
4
|
+
renderMarkdownSnapshotTree,
|
|
5
|
+
type MarkdownSnapshotElementNode,
|
|
6
|
+
type MarkdownSnapshotNode,
|
|
7
|
+
} from "./WebViewMarkdownSnapshot";
|
|
8
|
+
|
|
9
|
+
const text = (value: string): MarkdownSnapshotNode => ({
|
|
10
|
+
type: "text",
|
|
11
|
+
text: value,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const element = (
|
|
15
|
+
tagName: string,
|
|
16
|
+
children: readonly MarkdownSnapshotNode[] = [],
|
|
17
|
+
options: Partial<MarkdownSnapshotElementNode> = {}
|
|
18
|
+
): MarkdownSnapshotElementNode => ({
|
|
19
|
+
type: "element",
|
|
20
|
+
tagName,
|
|
21
|
+
children,
|
|
22
|
+
...options,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("createMarkdownSnapshotScript", () => {
|
|
26
|
+
test("builds a parseable browser snapshot script from stringified functions", () => {
|
|
27
|
+
const script = createMarkdownSnapshotScript();
|
|
28
|
+
|
|
29
|
+
expect(script).toStartWith("(function captureMarkdownSnapshot");
|
|
30
|
+
expect(script).toContain("function renderMarkdownSnapshotTree");
|
|
31
|
+
expect(script).toContain("function serializeNode");
|
|
32
|
+
expect(script).not.toContain("const renderMarkdownSnapshotTree =");
|
|
33
|
+
expect(() => new Function(script)).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("renderMarkdownSnapshotTree", () => {
|
|
38
|
+
test("renders headings, inline formatting, code, and absolute links", () => {
|
|
39
|
+
const root = element("body", [
|
|
40
|
+
element("h1", [text("Hello")]),
|
|
41
|
+
element("p", [
|
|
42
|
+
text("Read "),
|
|
43
|
+
element("strong", [text("docs")]),
|
|
44
|
+
text(" at "),
|
|
45
|
+
element("a", [text("guide")], {
|
|
46
|
+
attributes: [{ name: "href", value: "/guide" }],
|
|
47
|
+
}),
|
|
48
|
+
text(" with "),
|
|
49
|
+
element("code", [text("x")], { textContent: "x" }),
|
|
50
|
+
]),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
expect(renderMarkdownSnapshotTree(root, "https://example.test/base/")).toBe(
|
|
54
|
+
[
|
|
55
|
+
"# Hello",
|
|
56
|
+
"",
|
|
57
|
+
"Read **docs** at [guide](https://example.test/guide) with `x`",
|
|
58
|
+
].join("\n")
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("renders lists, blockquotes, tables, and fenced code blocks", () => {
|
|
63
|
+
const root = element("body", [
|
|
64
|
+
element("ul", [
|
|
65
|
+
element("li", [text("one")]),
|
|
66
|
+
element("li", [text("two")]),
|
|
67
|
+
]),
|
|
68
|
+
element("blockquote", [element("p", [text("quoted")])]),
|
|
69
|
+
element("table", [
|
|
70
|
+
element("tbody", [
|
|
71
|
+
element("tr", [
|
|
72
|
+
element("th", [text("Name")]),
|
|
73
|
+
element("th", [text("Value")]),
|
|
74
|
+
]),
|
|
75
|
+
element("tr", [
|
|
76
|
+
element("td", [text("A|B")]),
|
|
77
|
+
element("td", [text("2")]),
|
|
78
|
+
]),
|
|
79
|
+
]),
|
|
80
|
+
]),
|
|
81
|
+
element("pre", [element("code", [text("const x = 1;")])], {
|
|
82
|
+
textContent: "const x = 1;",
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
expect(renderMarkdownSnapshotTree(root, "https://example.test/")).toBe(
|
|
87
|
+
[
|
|
88
|
+
"- one",
|
|
89
|
+
"- two",
|
|
90
|
+
"",
|
|
91
|
+
"> quoted",
|
|
92
|
+
"",
|
|
93
|
+
"| Name | Value |",
|
|
94
|
+
"| --- | --- |",
|
|
95
|
+
"| A\\|B | 2 |",
|
|
96
|
+
"",
|
|
97
|
+
"```",
|
|
98
|
+
"const x = 1;",
|
|
99
|
+
"```",
|
|
100
|
+
].join("\n")
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("skips hidden content and unsafe links", () => {
|
|
105
|
+
const root = element("body", [
|
|
106
|
+
element("p", [
|
|
107
|
+
text("shown"),
|
|
108
|
+
element("span", [text("hidden")], { ariaHidden: "true" }),
|
|
109
|
+
element("a", [text("unsafe")], {
|
|
110
|
+
attributes: [{ name: "href", value: "javascript:alert(1)" }],
|
|
111
|
+
}),
|
|
112
|
+
]),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
expect(renderMarkdownSnapshotTree(root, "https://example.test/")).toBe(
|
|
116
|
+
"shown unsafe"
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|