@link-assistant/web-capture 2.0.0 → 2.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/CHANGELOG.md +11 -0
- package/bin/web-capture.js +2 -5
- package/package.json +22 -11
- package/src/archive.js +0 -1
- package/src/binary-download.js +0 -1
- package/src/docx.js +13 -10
- package/src/fetch.js +0 -1
- package/src/gdocs-images.js +0 -1
- package/src/gdocs.js +0 -1
- package/src/github.js +0 -1
- package/src/lib.js +0 -1
- package/src/markdown.js +0 -1
- package/src/shared-dialog.js +0 -1
- package/src/stream.js +0 -1
- package/src/transport.js +23 -5
- package/src/txt.js +0 -1
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.dockerignore +0 -6
- package/.jscpd.json +0 -19
- package/.lenv +0 -8
- package/.prettierignore +0 -8
- package/.prettierrc +0 -10
- package/Dockerfile +0 -52
- package/docker-compose.yml +0 -11
- package/eslint.config.js +0 -112
- package/examples/js/capture_cache.js +0 -12
- package/examples/js/engine_comparison.js +0 -39
- package/examples/js/html_download.js +0 -23
- package/examples/js/image_download.js +0 -36
- package/examples/js/markdown_download.js +0 -23
- package/examples/js/playwright_example.js +0 -23
- package/examples/python/capture_client.py +0 -20
- package/examples/python/html_download.py +0 -3
- package/examples/python/image_download.py +0 -16
- package/examples/python/markdown_download.py +0 -3
- package/examples/python/playwright_example.py +0 -22
- package/jest.config.mjs +0 -17
- package/tests/ci/dependency-freshness.test.js +0 -74
- package/tests/ci/detect-code-changes.test.js +0 -187
- package/tests/ci/docker-install.test.js +0 -31
- package/tests/ci/rust-dockerfile.test.js +0 -54
- package/tests/ci/workflow-policy.test.js +0 -79
- package/tests/e2e/docker.test.js +0 -186
- package/tests/e2e/process.test.js +0 -215
- package/tests/fixtures/bold-coalesce-and-leakage.html +0 -27
- package/tests/fixtures/deepwiki/deepwiki-full-page.png +0 -0
- package/tests/fixtures/deepwiki/index.md +0 -1500
- package/tests/fixtures/deepwiki/part-1.md +0 -776
- package/tests/fixtures/habr-articles.js +0 -45
- package/tests/fixtures/heading-numbering.html +0 -19
- package/tests/fixtures/list-item-with-br.html +0 -8
- package/tests/fixtures/ol-continuous-numbering.html +0 -12
- package/tests/fixtures/paragraph-vs-line-break.html +0 -11
- package/tests/helpers/nock.js +0 -10
- package/tests/helpers/screenshot.js +0 -10
- package/tests/helpers/search-fixture.cjs +0 -17
- package/tests/helpers/temporary-directory.js +0 -12
- package/tests/integration/api-endpoints.test.js +0 -418
- package/tests/integration/archive-default-layout.test.js +0 -37
- package/tests/integration/browser-engines.test.js +0 -80
- package/tests/integration/browser-packaged-path.test.js +0 -45
- package/tests/integration/deepwiki-download.test.js +0 -144
- package/tests/integration/deepwiki-rendered-page.mock.test.js +0 -121
- package/tests/integration/formalai-contract.test.js +0 -214
- package/tests/integration/gdocs-public-doc.test.js +0 -315
- package/tests/integration/github-readme.test.js +0 -133
- package/tests/integration/google-drive-image.test.js +0 -246
- package/tests/integration/habr-article.test.js +0 -337
- package/tests/integration/search-endpoint.test.js +0 -88
- package/tests/integration/stackoverflow-download.test.js +0 -173
- package/tests/integration/wikipedia-download.test.js +0 -134
- package/tests/jest.setup.mjs +0 -7
- package/tests/mock/index.test.js +0 -225
- package/tests/unit/animation.test.js +0 -33
- package/tests/unit/batch.test.js +0 -114
- package/tests/unit/bold-coalesce-and-leakage.test.js +0 -54
- package/tests/unit/browser-launch-options.test.js +0 -42
- package/tests/unit/browser.test.js +0 -172
- package/tests/unit/cache.test.js +0 -182
- package/tests/unit/cli.test.js +0 -399
- package/tests/unit/convertGoogleDriveUrl.test.js +0 -89
- package/tests/unit/convertRelativeUrls.test.js +0 -34
- package/tests/unit/extract-images.test.js +0 -259
- package/tests/unit/fetch-html.test.js +0 -54
- package/tests/unit/figures.test.js +0 -74
- package/tests/unit/gdocs-export-headers.test.js +0 -28
- package/tests/unit/gdocs-preprocess.test.js +0 -36
- package/tests/unit/gdocs.test.js +0 -1490
- package/tests/unit/github.test.js +0 -156
- package/tests/unit/heading-image.test.js +0 -49
- package/tests/unit/heading-numbering.test.js +0 -28
- package/tests/unit/html2md-br-in-list-item.test.js +0 -23
- package/tests/unit/html2md-ol-numbering.test.js +0 -20
- package/tests/unit/html2md.test.js +0 -695
- package/tests/unit/image-mode-defaults.test.js +0 -54
- package/tests/unit/kreuzberg.test.js +0 -249
- package/tests/unit/latex.test.js +0 -123
- package/tests/unit/localize-images.test.js +0 -114
- package/tests/unit/markdown.test.js +0 -36
- package/tests/unit/metadata.test.js +0 -140
- package/tests/unit/package-runtime-dependencies.test.js +0 -74
- package/tests/unit/paragraph-vs-line-break.test.js +0 -56
- package/tests/unit/postprocess.test.js +0 -110
- package/tests/unit/proxy-headers.test.js +0 -100
- package/tests/unit/publish-verification.test.js +0 -54
- package/tests/unit/retry.test.js +0 -111
- package/tests/unit/search.test.js +0 -339
- package/tests/unit/shared-dialog.test.js +0 -173
- package/tests/unit/stackoverflow.test.js +0 -32
- package/tests/unit/stream.test.js +0 -83
- package/tests/unit/verify.test.js +0 -182
- package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +0 -375
- package/tests/xpaste/xpaste.e2e.test.js +0 -36
- package/tests/xpaste/xpaste.integration.test.js +0 -41
- package/tests/xpaste/xpaste.mock.test.js +0 -261
- package/tests/xpaste/xpaste.unit.test.js +0 -119
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the structured search-provider capture module (issue #130).
|
|
3
|
-
*
|
|
4
|
-
* Parsers are pure functions tested against fixtures; the orchestrating
|
|
5
|
-
* `search()` is exercised with an injected fetch so no network is required.
|
|
6
|
-
*/
|
|
7
|
-
import { jest } from '@jest/globals';
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
search,
|
|
11
|
-
parseSearchResults,
|
|
12
|
-
buildSearchUrl,
|
|
13
|
-
looksLikeCaptcha,
|
|
14
|
-
formatSearchAsMarkdown,
|
|
15
|
-
SEARCH_PROVIDERS,
|
|
16
|
-
DEFAULT_PROVIDER,
|
|
17
|
-
} from '../../src/search.js';
|
|
18
|
-
|
|
19
|
-
const WIKI_JSON = JSON.stringify({
|
|
20
|
-
pages: [
|
|
21
|
-
{
|
|
22
|
-
id: 1,
|
|
23
|
-
key: 'Formal_methods',
|
|
24
|
-
title: 'Formal methods',
|
|
25
|
-
excerpt: 'the <span class="searchmatch">study</span> of <b>formal</b>',
|
|
26
|
-
description: 'mathematically rigorous techniques',
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: 2,
|
|
30
|
-
key: 'Formal_system',
|
|
31
|
-
title: 'Formal system',
|
|
32
|
-
excerpt: 'an abstract structure',
|
|
33
|
-
description: '',
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const DDG_HTML = `
|
|
39
|
-
<div class="result__body">
|
|
40
|
-
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=abc">First & Best</a>
|
|
41
|
-
<div class="result__snippet">Snippet about the <b>first</b> result</div>
|
|
42
|
-
</div>
|
|
43
|
-
<div class="result__body">
|
|
44
|
-
<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fb">Second result</a>
|
|
45
|
-
<div class="result__snippet">Snippet two</div>
|
|
46
|
-
</div>
|
|
47
|
-
`;
|
|
48
|
-
|
|
49
|
-
const BING_HTML = `
|
|
50
|
-
<ol id="b_results">
|
|
51
|
-
<li class="b_algo">
|
|
52
|
-
<h2><a href="https://bing-result.example/1">Bing One</a></h2>
|
|
53
|
-
<div class="b_caption"><p>Bing snippet one</p></div>
|
|
54
|
-
</li>
|
|
55
|
-
</ol>
|
|
56
|
-
`;
|
|
57
|
-
|
|
58
|
-
describe('search module (#130)', () => {
|
|
59
|
-
describe('buildSearchUrl', () => {
|
|
60
|
-
it('builds the Wikipedia REST API url with limit', () => {
|
|
61
|
-
expect(buildSearchUrl('wikipedia', 'formal ai', 5)).toBe(
|
|
62
|
-
'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal%20ai&limit=5'
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('builds the DuckDuckGo HTML url', () => {
|
|
67
|
-
expect(buildSearchUrl('duckduckgo', 'a b')).toContain(
|
|
68
|
-
'https://html.duckduckgo.com/html/?q=a%20b'
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('throws for an unknown provider', () => {
|
|
73
|
-
expect(() => buildSearchUrl('yahoo', 'x')).toThrow(
|
|
74
|
-
/Unknown search provider/
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('keeps URL construction and response parsing transport-independent', () => {
|
|
80
|
-
const sourceUrl = buildSearchUrl('wikipedia', 'formal ai', 1);
|
|
81
|
-
const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
|
|
82
|
-
limit: 1,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
expect(sourceUrl).toBe(
|
|
86
|
-
'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal%20ai&limit=1'
|
|
87
|
-
);
|
|
88
|
-
expect(results).toEqual([
|
|
89
|
-
{
|
|
90
|
-
rank: 1,
|
|
91
|
-
title: 'Formal methods',
|
|
92
|
-
url: 'https://en.wikipedia.org/wiki/Formal_methods',
|
|
93
|
-
snippet: 'the study of formal',
|
|
94
|
-
},
|
|
95
|
-
]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('parseSearchResults', () => {
|
|
99
|
-
it('normalizes Wikipedia REST JSON and strips markup', () => {
|
|
100
|
-
const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
|
|
101
|
-
limit: 10,
|
|
102
|
-
});
|
|
103
|
-
expect(results).toHaveLength(2);
|
|
104
|
-
expect(results[0]).toEqual({
|
|
105
|
-
rank: 1,
|
|
106
|
-
title: 'Formal methods',
|
|
107
|
-
url: 'https://en.wikipedia.org/wiki/Formal_methods',
|
|
108
|
-
snippet: 'the study of formal',
|
|
109
|
-
});
|
|
110
|
-
expect(results[1].url).toBe(
|
|
111
|
-
'https://en.wikipedia.org/wiki/Formal_system'
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('respects the limit', () => {
|
|
116
|
-
const { results } = parseSearchResults('wikipedia', WIKI_JSON, {
|
|
117
|
-
limit: 1,
|
|
118
|
-
});
|
|
119
|
-
expect(results).toHaveLength(1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('decodes DuckDuckGo redirect hrefs and entities', () => {
|
|
123
|
-
const { results } = parseSearchResults('duckduckgo', DDG_HTML, {
|
|
124
|
-
limit: 10,
|
|
125
|
-
});
|
|
126
|
-
expect(results).toHaveLength(2);
|
|
127
|
-
expect(results[0]).toEqual({
|
|
128
|
-
rank: 1,
|
|
129
|
-
title: 'First & Best',
|
|
130
|
-
url: 'https://example.com/a',
|
|
131
|
-
snippet: 'Snippet about the first result',
|
|
132
|
-
});
|
|
133
|
-
expect(results[1].url).toBe('https://example.org/b');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('parses Bing algorithmic results', () => {
|
|
137
|
-
const { results } = parseSearchResults('bing', BING_HTML, { limit: 10 });
|
|
138
|
-
expect(results).toEqual([
|
|
139
|
-
{
|
|
140
|
-
rank: 1,
|
|
141
|
-
title: 'Bing One',
|
|
142
|
-
url: 'https://bing-result.example/1',
|
|
143
|
-
snippet: 'Bing snippet one',
|
|
144
|
-
},
|
|
145
|
-
]);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('returns empty results for malformed JSON', () => {
|
|
149
|
-
const { results } = parseSearchResults('wikipedia', 'not json', {
|
|
150
|
-
limit: 10,
|
|
151
|
-
});
|
|
152
|
-
expect(results).toEqual([]);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('flags CAPTCHA pages', () => {
|
|
156
|
-
const { blockedByCaptcha } = parseSearchResults(
|
|
157
|
-
'google',
|
|
158
|
-
'<html>Our systems have detected unusual traffic</html>',
|
|
159
|
-
{ limit: 10 }
|
|
160
|
-
);
|
|
161
|
-
expect(blockedByCaptcha).toBe(true);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe('looksLikeCaptcha', () => {
|
|
166
|
-
it('detects common bot-block phrases', () => {
|
|
167
|
-
expect(looksLikeCaptcha('Please solve the CAPTCHA')).toBe(true);
|
|
168
|
-
expect(looksLikeCaptcha('normal results page')).toBe(false);
|
|
169
|
-
expect(looksLikeCaptcha('')).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe('search()', () => {
|
|
174
|
-
const fixedNow = () => '2026-05-30T00:00:00.000Z';
|
|
175
|
-
|
|
176
|
-
it('returns the normalized contract for a provider', async () => {
|
|
177
|
-
const fetchImpl = async (url) => {
|
|
178
|
-
expect(url).toContain('en.wikipedia.org');
|
|
179
|
-
return { status: 200, text: async () => WIKI_JSON };
|
|
180
|
-
};
|
|
181
|
-
const result = await search({
|
|
182
|
-
query: 'formal-ai',
|
|
183
|
-
provider: 'wikipedia',
|
|
184
|
-
limit: 2,
|
|
185
|
-
fetchImpl,
|
|
186
|
-
now: fixedNow,
|
|
187
|
-
});
|
|
188
|
-
expect(result).toMatchObject({
|
|
189
|
-
query: 'formal-ai',
|
|
190
|
-
provider: 'wikipedia',
|
|
191
|
-
captureMode: 'fetch',
|
|
192
|
-
capturedAt: '2026-05-30T00:00:00.000Z',
|
|
193
|
-
});
|
|
194
|
-
expect(result.results).toHaveLength(2);
|
|
195
|
-
expect(result.diagnostics).toEqual({
|
|
196
|
-
status: 200,
|
|
197
|
-
blockedByCors: false,
|
|
198
|
-
blockedByCaptcha: false,
|
|
199
|
-
sourceUrl:
|
|
200
|
-
'https://en.wikipedia.org/w/rest.php/v1/search/page?q=formal-ai&limit=2',
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('defaults to the Wikipedia provider', async () => {
|
|
205
|
-
const fetchImpl = async () => ({
|
|
206
|
-
status: 200,
|
|
207
|
-
text: async () => WIKI_JSON,
|
|
208
|
-
});
|
|
209
|
-
const result = await search({ query: 'x', fetchImpl, now: fixedNow });
|
|
210
|
-
expect(result.provider).toBe(DEFAULT_PROVIDER);
|
|
211
|
-
expect(DEFAULT_PROVIDER).toBe('wikipedia');
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('records transport failures in diagnostics without throwing', async () => {
|
|
215
|
-
const fetchImpl = async () => {
|
|
216
|
-
throw new Error('network down');
|
|
217
|
-
};
|
|
218
|
-
const result = await search({
|
|
219
|
-
query: 'x',
|
|
220
|
-
provider: 'google',
|
|
221
|
-
fetchImpl,
|
|
222
|
-
now: fixedNow,
|
|
223
|
-
});
|
|
224
|
-
expect(result.results).toEqual([]);
|
|
225
|
-
expect(result.diagnostics.error).toBe('network down');
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('returns the source receipt and forwards caller cancellation', async () => {
|
|
229
|
-
const body = Buffer.from(WIKI_JSON);
|
|
230
|
-
const controller = new globalThis.AbortController();
|
|
231
|
-
const transport = jest.fn(async ({ url, signal }) => ({
|
|
232
|
-
body,
|
|
233
|
-
finalUrl: `${url}&redirected=true`,
|
|
234
|
-
status: 200,
|
|
235
|
-
headers: { 'content-type': 'application/json', etag: 'search-v1' },
|
|
236
|
-
diagnostics: { outcome: 'response' },
|
|
237
|
-
signal,
|
|
238
|
-
}));
|
|
239
|
-
|
|
240
|
-
const result = await search({
|
|
241
|
-
query: 'formal-ai',
|
|
242
|
-
transport,
|
|
243
|
-
signal: controller.signal,
|
|
244
|
-
now: fixedNow,
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
expect(Buffer.from(result.receipt.body)).toEqual(body);
|
|
248
|
-
expect(result.receipt.finalUrl).toContain('redirected=true');
|
|
249
|
-
expect(result.receipt.headers.etag).toBe('search-v1');
|
|
250
|
-
expect(transport).toHaveBeenCalledWith(
|
|
251
|
-
expect.objectContaining({ signal: controller.signal })
|
|
252
|
-
);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('classifies AbortSignal cancellation in structured diagnostics', async () => {
|
|
256
|
-
const controller = new globalThis.AbortController();
|
|
257
|
-
controller.abort();
|
|
258
|
-
const transport = async ({ signal }) => {
|
|
259
|
-
expect(signal).toBe(controller.signal);
|
|
260
|
-
const error = new Error('cancelled by caller');
|
|
261
|
-
error.name = 'AbortError';
|
|
262
|
-
throw error;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const result = await search({
|
|
266
|
-
query: 'formal-ai',
|
|
267
|
-
transport,
|
|
268
|
-
signal: controller.signal,
|
|
269
|
-
now: fixedNow,
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
expect(result.diagnostics).toMatchObject({
|
|
273
|
-
status: 0,
|
|
274
|
-
errorKind: 'cancelled',
|
|
275
|
-
error: 'cancelled by caller',
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('rejects an empty query', async () => {
|
|
280
|
-
await expect(search({ query: ' ' })).rejects.toThrow(/Missing .query/);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('rejects an unknown provider', async () => {
|
|
284
|
-
await expect(search({ query: 'x', provider: 'yahoo' })).rejects.toThrow(
|
|
285
|
-
/Unknown search provider/
|
|
286
|
-
);
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
describe('formatSearchAsMarkdown', () => {
|
|
291
|
-
it('renders a markdown document with ranked links', () => {
|
|
292
|
-
const md = formatSearchAsMarkdown({
|
|
293
|
-
query: 'formal-ai',
|
|
294
|
-
provider: 'wikipedia',
|
|
295
|
-
captureMode: 'fetch',
|
|
296
|
-
capturedAt: '2026-05-30T00:00:00.000Z',
|
|
297
|
-
results: [
|
|
298
|
-
{
|
|
299
|
-
rank: 1,
|
|
300
|
-
title: 'Formal methods',
|
|
301
|
-
url: 'https://en.wikipedia.org/wiki/Formal_methods',
|
|
302
|
-
snippet: 'study of formal',
|
|
303
|
-
},
|
|
304
|
-
],
|
|
305
|
-
diagnostics: {
|
|
306
|
-
sourceUrl: 'https://example.com',
|
|
307
|
-
blockedByCaptcha: false,
|
|
308
|
-
},
|
|
309
|
-
});
|
|
310
|
-
expect(md).toContain('# Search results for "formal-ai"');
|
|
311
|
-
expect(md).toContain(
|
|
312
|
-
'1. [Formal methods](https://en.wikipedia.org/wiki/Formal_methods)'
|
|
313
|
-
);
|
|
314
|
-
expect(md).toContain('study of formal');
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('reports no results', () => {
|
|
318
|
-
const md = formatSearchAsMarkdown({
|
|
319
|
-
query: 'nothing',
|
|
320
|
-
provider: 'bing',
|
|
321
|
-
captureMode: 'fetch',
|
|
322
|
-
capturedAt: 't',
|
|
323
|
-
results: [],
|
|
324
|
-
diagnostics: { sourceUrl: 'u', blockedByCaptcha: false },
|
|
325
|
-
});
|
|
326
|
-
expect(md).toContain('_No results._');
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('exposes the documented provider list', () => {
|
|
331
|
-
expect(SEARCH_PROVIDERS).toEqual([
|
|
332
|
-
'wikipedia',
|
|
333
|
-
'duckduckgo',
|
|
334
|
-
'google',
|
|
335
|
-
'bing',
|
|
336
|
-
'brave',
|
|
337
|
-
]);
|
|
338
|
-
});
|
|
339
|
-
});
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, resolve } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
|
|
5
|
-
import { jest } from '@jest/globals';
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
captureSharedDialog,
|
|
9
|
-
formatSharedDialogAsDemoMemory,
|
|
10
|
-
formatSharedDialogAsMarkdown,
|
|
11
|
-
formatSharedDialogAsMetaLanguage,
|
|
12
|
-
parseSharedDialog,
|
|
13
|
-
} from '../../src/shared-dialog.js';
|
|
14
|
-
|
|
15
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
-
const __dirname = dirname(__filename);
|
|
17
|
-
const repoRoot = resolve(__dirname, '../../..');
|
|
18
|
-
|
|
19
|
-
const CHATGPT_SHARE_URL =
|
|
20
|
-
'https://chatgpt.com/share/6a3825b9-8de4-83ee-9c24-52fd1eb38d24';
|
|
21
|
-
const GOOGLE_AI_MODE_URL = 'https://share.google/aimode/VG0HhpnAXrBkC0QgP';
|
|
22
|
-
|
|
23
|
-
function readCaseStudyFixture(name) {
|
|
24
|
-
return readFileSync(
|
|
25
|
-
resolve(repoRoot, 'docs/case-studies/issue-141/raw-data', name),
|
|
26
|
-
'utf-8'
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe('shared-dialog module (#141)', () => {
|
|
31
|
-
it('extracts the four visible ChatGPT shared-dialog turns', () => {
|
|
32
|
-
const capture = parseSharedDialog(
|
|
33
|
-
readCaseStudyFixture('chatgpt-share-6a3825b9.html'),
|
|
34
|
-
{
|
|
35
|
-
sourceUrl: CHATGPT_SHARE_URL,
|
|
36
|
-
captureMethod: 'static_http',
|
|
37
|
-
}
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
expect(capture).toMatchObject({
|
|
41
|
-
provider: 'chatgpt',
|
|
42
|
-
sourceUrl: CHATGPT_SHARE_URL,
|
|
43
|
-
captureMethod: 'static_http',
|
|
44
|
-
status: 'ok',
|
|
45
|
-
conversationId: '6a3825b9-8de4-83ee-9c24-52fd1eb38d24',
|
|
46
|
-
title: 'Infinite loop script',
|
|
47
|
-
});
|
|
48
|
-
expect(capture.turns).toHaveLength(4);
|
|
49
|
-
expect(capture.turns.map((turn) => turn.role)).toEqual([
|
|
50
|
-
'user',
|
|
51
|
-
'assistant',
|
|
52
|
-
'user',
|
|
53
|
-
'assistant',
|
|
54
|
-
]);
|
|
55
|
-
expect(capture.turns[0]).toMatchObject({
|
|
56
|
-
visibility: 'visible',
|
|
57
|
-
sourceEvidence: [
|
|
58
|
-
{
|
|
59
|
-
sourceUrl: CHATGPT_SHARE_URL,
|
|
60
|
-
kind: 'chatgpt_linear_conversation',
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
|
-
});
|
|
64
|
-
expect(capture.turns[0].content).toContain('make a loop of that');
|
|
65
|
-
expect(capture.turns[1].content).toContain(
|
|
66
|
-
'while true; do sleep 30m && hive-cleanup -f; done'
|
|
67
|
-
);
|
|
68
|
-
expect(capture.turns[3].content).toContain(
|
|
69
|
-
'screen -dmS auto-cleanup bash -c'
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('formats ChatGPT captures as demo_memory', () => {
|
|
74
|
-
const capture = parseSharedDialog(
|
|
75
|
-
readCaseStudyFixture('chatgpt-share-6a3825b9.html'),
|
|
76
|
-
{
|
|
77
|
-
sourceUrl: CHATGPT_SHARE_URL,
|
|
78
|
-
captureMethod: 'static_http',
|
|
79
|
-
}
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const memory = formatSharedDialogAsDemoMemory(capture, {
|
|
83
|
-
demoLabel: 'issue-552-chatgpt-share',
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
expect(memory).toContain('demo_memory');
|
|
87
|
-
expect(memory.match(/\n {2}event "/g)).toHaveLength(4);
|
|
88
|
-
expect(memory).toContain('role "user"');
|
|
89
|
-
expect(memory).toContain('role "assistant"');
|
|
90
|
-
expect(memory).toContain(
|
|
91
|
-
'conversationId "6a3825b9-8de4-83ee-9c24-52fd1eb38d24"'
|
|
92
|
-
);
|
|
93
|
-
expect(memory).toContain('conversationTitle "Infinite loop script"');
|
|
94
|
-
expect(memory).toContain(`evidence "${CHATGPT_SHARE_URL}"`);
|
|
95
|
-
expect(memory).toContain(
|
|
96
|
-
"screen -dmS auto-cleanup bash -c 'while true; do sleep 30m && hive-cleanup -f; done'"
|
|
97
|
-
);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('formats captures as shared-dialog meta-language and Markdown', () => {
|
|
101
|
-
const capture = parseSharedDialog(
|
|
102
|
-
readCaseStudyFixture('chatgpt-share-6a3825b9.html'),
|
|
103
|
-
{
|
|
104
|
-
sourceUrl: CHATGPT_SHARE_URL,
|
|
105
|
-
captureMethod: 'static_http',
|
|
106
|
-
}
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const metaLanguage = formatSharedDialogAsMetaLanguage(capture);
|
|
110
|
-
expect(metaLanguage).toContain('shared_dialog_capture');
|
|
111
|
-
expect(metaLanguage).toContain('provider "chatgpt"');
|
|
112
|
-
expect(metaLanguage).toContain(
|
|
113
|
-
'turn "0c9f0151-b5a1-402f-afc3-6bd34a0d01d2"'
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
const markdown = formatSharedDialogAsMarkdown(capture);
|
|
117
|
-
expect(markdown).toContain('# Infinite loop script');
|
|
118
|
-
expect(markdown).toContain('**User**');
|
|
119
|
-
expect(markdown).toContain('**Assistant**');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('returns a structured Google AI Mode interstitial diagnostic', () => {
|
|
123
|
-
const capture = parseSharedDialog(
|
|
124
|
-
readCaseStudyFixture('google-ai-mode-VG0HhpnAXrBkC0QgP.html'),
|
|
125
|
-
{
|
|
126
|
-
sourceUrl: GOOGLE_AI_MODE_URL,
|
|
127
|
-
captureMethod: 'static_http',
|
|
128
|
-
}
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
expect(capture).toMatchObject({
|
|
132
|
-
provider: 'google_ai_mode',
|
|
133
|
-
sourceUrl: GOOGLE_AI_MODE_URL,
|
|
134
|
-
captureMethod: 'static_http',
|
|
135
|
-
status: 'unsupported',
|
|
136
|
-
turns: [],
|
|
137
|
-
diagnostics: {
|
|
138
|
-
unsupportedReason: 'provider_challenge_interstitial',
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('uses browser-rendered DOM when static Google AI Mode capture is blocked', async () => {
|
|
144
|
-
const googleHtml = readCaseStudyFixture(
|
|
145
|
-
'google-ai-mode-VG0HhpnAXrBkC0QgP.html'
|
|
146
|
-
);
|
|
147
|
-
const renderHtml = jest.fn(async () => googleHtml);
|
|
148
|
-
const capture = await captureSharedDialog({
|
|
149
|
-
url: GOOGLE_AI_MODE_URL,
|
|
150
|
-
capture: 'browser',
|
|
151
|
-
fetchImpl: async () => ({
|
|
152
|
-
ok: true,
|
|
153
|
-
status: 200,
|
|
154
|
-
text: async () => googleHtml,
|
|
155
|
-
}),
|
|
156
|
-
renderHtml,
|
|
157
|
-
now: () => '2026-06-25T00:00:00.000Z',
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
expect(renderHtml).toHaveBeenCalledWith(GOOGLE_AI_MODE_URL, {
|
|
161
|
-
engine: 'puppeteer',
|
|
162
|
-
});
|
|
163
|
-
expect(capture).toMatchObject({
|
|
164
|
-
provider: 'google_ai_mode',
|
|
165
|
-
captureMethod: 'browser',
|
|
166
|
-
status: 'unsupported',
|
|
167
|
-
capturedAt: '2026-06-25T00:00:00.000Z',
|
|
168
|
-
diagnostics: {
|
|
169
|
-
unsupportedReason: 'provider_challenge_interstitial',
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
});
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
isStackOverflowQuestionUrl,
|
|
3
|
-
stackPrinterUrlForQuestion,
|
|
4
|
-
} from '../../src/lib.js';
|
|
5
|
-
|
|
6
|
-
describe('Stack Overflow URL helpers', () => {
|
|
7
|
-
it('recognizes Stack Overflow question URLs', () => {
|
|
8
|
-
expect(
|
|
9
|
-
isStackOverflowQuestionUrl(
|
|
10
|
-
'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'
|
|
11
|
-
)
|
|
12
|
-
).toBe(true);
|
|
13
|
-
expect(
|
|
14
|
-
isStackOverflowQuestionUrl(
|
|
15
|
-
'https://stackoverflow.com/questions/tagged/git'
|
|
16
|
-
)
|
|
17
|
-
).toBe(false);
|
|
18
|
-
expect(
|
|
19
|
-
isStackOverflowQuestionUrl('https://serverfault.com/questions/927358')
|
|
20
|
-
).toBe(false);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('builds a StackPrinter URL for direct captures', () => {
|
|
24
|
-
const stackPrinterUrl = stackPrinterUrlForQuestion(
|
|
25
|
-
'https://stackoverflow.com/questions/927358/how-do-i-undo-the-most-recent-local-commits-in-git'
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
expect(stackPrinterUrl).toBe(
|
|
29
|
-
'https://stackprinter.appspot.com/export?question=927358&service=stackoverflow&language=en&hideAnswers=false&showAll=true&width=640'
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import http from 'http';
|
|
3
|
-
import request from 'supertest';
|
|
4
|
-
import { streamHandler } from '../../src/stream.js';
|
|
5
|
-
|
|
6
|
-
describe('streamHandler', () => {
|
|
7
|
-
it('streams upstream content with proxy-safe framing', async () => {
|
|
8
|
-
const body = '<!DOCTYPE html><html><body>Example Domain</body></html>';
|
|
9
|
-
let upstreamHeaders;
|
|
10
|
-
const { server, url } = await startUpstreamServer((req, res) => {
|
|
11
|
-
upstreamHeaders = req.headers;
|
|
12
|
-
res.writeHead(200, {
|
|
13
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
14
|
-
'Content-Length': Buffer.byteLength(body),
|
|
15
|
-
});
|
|
16
|
-
res.end(body);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
const res = await streamFrom(url);
|
|
21
|
-
|
|
22
|
-
expect(upstreamHeaders['accept-encoding']).toBe('identity');
|
|
23
|
-
expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
|
|
24
|
-
expect(res.headers['content-length']).toBe(
|
|
25
|
-
String(Buffer.byteLength(body))
|
|
26
|
-
);
|
|
27
|
-
expect(res.text).toContain('Example Domain');
|
|
28
|
-
} finally {
|
|
29
|
-
await closeServer(server);
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('ends the downstream response when upstream closes after body bytes', async () => {
|
|
34
|
-
const body = '<!DOCTYPE html><html><body>Example Domain</body></html>';
|
|
35
|
-
const { server, url } = await startUpstreamServer((req, res) => {
|
|
36
|
-
res.writeHead(200, {
|
|
37
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
38
|
-
});
|
|
39
|
-
// Wait until the body reaches the socket before deliberately truncating it.
|
|
40
|
-
res.write(body, () => res.socket.end());
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const res = await streamFrom(url);
|
|
45
|
-
|
|
46
|
-
expect(res.text).toContain('Example Domain');
|
|
47
|
-
} finally {
|
|
48
|
-
await closeServer(server);
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
function testApp() {
|
|
54
|
-
const app = express();
|
|
55
|
-
app.get('/stream', streamHandler);
|
|
56
|
-
return app;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function startUpstreamServer(handler) {
|
|
60
|
-
const server = http.createServer(handler);
|
|
61
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
62
|
-
const { port } = server.address();
|
|
63
|
-
return {
|
|
64
|
-
server,
|
|
65
|
-
url: `http://127.0.0.1:${port}`,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function closeServer(server) {
|
|
70
|
-
await new Promise((resolve, reject) => {
|
|
71
|
-
server.close((error) => {
|
|
72
|
-
if (error) {
|
|
73
|
-
reject(error);
|
|
74
|
-
} else {
|
|
75
|
-
resolve();
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function streamFrom(url) {
|
|
82
|
-
return request(testApp()).get('/stream').query({ url }).expect(200);
|
|
83
|
-
}
|