@link-assistant/web-capture 1.10.8 → 1.10.10

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.
@@ -1,4 +1,6 @@
1
+ import nock from 'nock';
1
2
  import {
3
+ fetchGithubRepositorySnapshot,
2
4
  formatGithubRepositoryMarkdown,
3
5
  formatGithubRepositoryText,
4
6
  getGithubRepositoryTextFilename,
@@ -42,6 +44,10 @@ const SNAPSHOT = {
42
44
  },
43
45
  };
44
46
 
47
+ afterEach(() => {
48
+ nock.cleanAll();
49
+ });
50
+
45
51
  describe('GitHub repository URLs', () => {
46
52
  it('detects plain GitHub repository pages', () => {
47
53
  expect(
@@ -70,6 +76,59 @@ describe('GitHub repository URLs', () => {
70
76
  });
71
77
  });
72
78
 
79
+ describe('GitHub repository snapshot fetching', () => {
80
+ it('requests identity encoding from GitHub JSON and raw README fetches', async () => {
81
+ const owner = 'octocat';
82
+ const repo = 'Hello-World';
83
+ const readme = '# Hello World\n\nRaw README content.';
84
+
85
+ const githubApi = nock('https://api.github.com', {
86
+ reqheaders: {
87
+ 'accept-encoding': 'identity',
88
+ },
89
+ })
90
+ .get(`/repos/${owner}/${repo}`)
91
+ .reply(200, {
92
+ full_name: `${owner}/${repo}`,
93
+ html_url: `https://github.com/${owner}/${repo}`,
94
+ default_branch: 'master',
95
+ })
96
+ .get(`/repos/${owner}/${repo}/readme`)
97
+ .query({ ref: 'master' })
98
+ .reply(200, {
99
+ name: 'README.md',
100
+ path: 'README.md',
101
+ download_url: `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
102
+ })
103
+ .get(`/repos/${owner}/${repo}/contents`)
104
+ .query({ ref: 'master' })
105
+ .reply(200, [
106
+ {
107
+ name: 'README.md',
108
+ path: 'README.md',
109
+ type: 'file',
110
+ size: readme.length,
111
+ },
112
+ ]);
113
+
114
+ const rawGithub = nock('https://raw.githubusercontent.com', {
115
+ reqheaders: {
116
+ 'accept-encoding': 'identity',
117
+ },
118
+ })
119
+ .get(`/${owner}/${repo}/master/README.md`)
120
+ .reply(200, readme);
121
+
122
+ const snapshot = await fetchGithubRepositorySnapshot(
123
+ `https://github.com/${owner}/${repo}`
124
+ );
125
+
126
+ expect(snapshot.readme.content).toBe(readme);
127
+ expect(githubApi.isDone()).toBe(true);
128
+ expect(rawGithub.isDone()).toBe(true);
129
+ });
130
+ });
131
+
73
132
  describe('GitHub repository snapshot formatting', () => {
74
133
  it('formats a compact markdown snapshot with metadata, file tree, and README', () => {
75
134
  const markdown = formatGithubRepositoryMarkdown(SNAPSHOT);
@@ -0,0 +1,100 @@
1
+ import {
2
+ copyProxyResponseHeaders,
3
+ getProxyHeaderEntries,
4
+ } from '../../src/proxy-headers.js';
5
+
6
+ describe('proxy header helpers', () => {
7
+ it('removes hop-by-hop headers and headers named by Connection', () => {
8
+ const entries = getProxyHeaderEntries(
9
+ headers({
10
+ 'Content-Type': 'text/html',
11
+ Connection: 'keep-alive, X-Internal-Hop',
12
+ 'Keep-Alive': 'timeout=5',
13
+ TE: 'trailers',
14
+ Trailer: 'Expires',
15
+ Upgrade: 'websocket',
16
+ 'Proxy-Authenticate': 'Basic',
17
+ 'Proxy-Authorization': 'Basic token',
18
+ 'Transfer-Encoding': 'chunked',
19
+ 'X-Internal-Hop': 'remove me',
20
+ 'X-End-To-End': 'keep me',
21
+ })
22
+ );
23
+
24
+ expect(Object.fromEntries(entries)).toEqual({
25
+ 'content-type': 'text/html',
26
+ 'x-end-to-end': 'keep me',
27
+ });
28
+ });
29
+
30
+ it('preserves content length only for unencoded streaming responses', () => {
31
+ expect(
32
+ Object.fromEntries(
33
+ getProxyHeaderEntries(
34
+ headers({
35
+ 'Content-Type': 'text/plain',
36
+ 'Content-Length': '12',
37
+ }),
38
+ { preserveContentLength: true }
39
+ )
40
+ )
41
+ ).toEqual({
42
+ 'content-type': 'text/plain',
43
+ 'content-length': '12',
44
+ });
45
+
46
+ expect(
47
+ Object.fromEntries(
48
+ getProxyHeaderEntries(
49
+ headers({
50
+ 'Content-Type': 'text/plain',
51
+ 'Content-Length': '12',
52
+ 'Content-Encoding': 'gzip',
53
+ }),
54
+ { preserveContentLength: true }
55
+ )
56
+ )
57
+ ).toEqual({
58
+ 'content-type': 'text/plain',
59
+ });
60
+ });
61
+
62
+ it('sets a default content type while copying safe headers', () => {
63
+ const res = responseRecorder();
64
+
65
+ copyProxyResponseHeaders(
66
+ headers({
67
+ ETag: '"abc"',
68
+ }),
69
+ res
70
+ );
71
+
72
+ expect(res.headers).toEqual({
73
+ 'content-type': 'text/plain',
74
+ etag: '"abc"',
75
+ });
76
+ });
77
+ });
78
+
79
+ function headers(values) {
80
+ const map = new Map(
81
+ Object.entries(values).map(([key, value]) => [key.toLowerCase(), value])
82
+ );
83
+ return {
84
+ get(key) {
85
+ return map.get(key.toLowerCase()) || null;
86
+ },
87
+ entries() {
88
+ return map.entries();
89
+ },
90
+ };
91
+ }
92
+
93
+ function responseRecorder() {
94
+ return {
95
+ headers: {},
96
+ setHeader(key, value) {
97
+ this.headers[key.toLowerCase()] = value;
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,173 @@
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
+ });
@@ -0,0 +1,85 @@
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 request(testApp())
21
+ .get('/stream')
22
+ .query({ url })
23
+ .expect(200);
24
+
25
+ expect(upstreamHeaders['accept-encoding']).toBe('identity');
26
+ expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
27
+ expect(res.headers['content-length']).toBe(
28
+ String(Buffer.byteLength(body))
29
+ );
30
+ expect(res.text).toContain('Example Domain');
31
+ } finally {
32
+ await closeServer(server);
33
+ }
34
+ });
35
+
36
+ it('ends the downstream response when upstream closes after body bytes', async () => {
37
+ const body = '<!DOCTYPE html><html><body>Example Domain</body></html>';
38
+ const { server, url } = await startUpstreamServer((req, res) => {
39
+ res.writeHead(200, {
40
+ 'Content-Type': 'text/html; charset=utf-8',
41
+ });
42
+ res.write(body);
43
+ res.socket.end();
44
+ });
45
+
46
+ try {
47
+ const res = await request(testApp())
48
+ .get('/stream')
49
+ .query({ url })
50
+ .expect(200);
51
+
52
+ expect(res.text).toContain('Example Domain');
53
+ } finally {
54
+ await closeServer(server);
55
+ }
56
+ });
57
+ });
58
+
59
+ function testApp() {
60
+ const app = express();
61
+ app.get('/stream', streamHandler);
62
+ return app;
63
+ }
64
+
65
+ async function startUpstreamServer(handler) {
66
+ const server = http.createServer(handler);
67
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
68
+ const { port } = server.address();
69
+ return {
70
+ server,
71
+ url: `http://127.0.0.1:${port}`,
72
+ };
73
+ }
74
+
75
+ async function closeServer(server) {
76
+ await new Promise((resolve, reject) => {
77
+ server.close((error) => {
78
+ if (error) {
79
+ reject(error);
80
+ } else {
81
+ resolve();
82
+ }
83
+ });
84
+ });
85
+ }