@link-assistant/web-capture 1.10.9 → 1.10.11
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 +12 -0
- package/README.md +16 -0
- package/bin/web-capture.js +57 -35
- package/docker-compose.yml +2 -0
- package/package.json +2 -2
- package/src/archive.js +3 -3
- package/src/fetch.js +2 -15
- package/src/gdocs.js +7 -5
- package/src/github.js +1 -0
- package/src/index.js +12 -0
- package/src/lib.js +11 -2
- package/src/markdown.js +2 -2
- package/src/proxy-headers.js +60 -0
- package/src/shared-dialog-cli.js +25 -0
- package/src/shared-dialog.js +950 -0
- package/src/stream.js +33 -26
- package/tests/e2e/docker.test.js +2 -3
- package/tests/e2e/process.test.js +95 -22
- package/tests/unit/cli.test.js +45 -1
- package/tests/unit/fetch-html.test.js +24 -0
- package/tests/unit/gdocs-export-headers.test.js +28 -0
- package/tests/unit/github.test.js +59 -0
- package/tests/unit/package-runtime-dependencies.test.js +26 -0
- package/tests/unit/proxy-headers.test.js +100 -0
- package/tests/unit/shared-dialog.test.js +173 -0
- package/tests/unit/stream.test.js +85 -0
package/src/stream.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
|
-
import { pipeline } from 'stream';
|
|
3
2
|
import { convertGoogleDriveUrl } from './lib.js';
|
|
3
|
+
import { copyProxyResponseHeaders } from './proxy-headers.js';
|
|
4
|
+
|
|
5
|
+
const STREAM_FETCH_HEADERS = {
|
|
6
|
+
'accept-encoding': 'identity',
|
|
7
|
+
};
|
|
4
8
|
|
|
5
9
|
export async function streamHandler(req, res) {
|
|
6
10
|
const url = req.query.url;
|
|
@@ -8,36 +12,18 @@ export async function streamHandler(req, res) {
|
|
|
8
12
|
return res.status(400).send('Missing `url` parameter');
|
|
9
13
|
}
|
|
10
14
|
try {
|
|
11
|
-
const response = await fetch(convertGoogleDriveUrl(url)
|
|
15
|
+
const response = await fetch(convertGoogleDriveUrl(url), {
|
|
16
|
+
headers: STREAM_FETCH_HEADERS,
|
|
17
|
+
});
|
|
12
18
|
// Copy status and headers
|
|
13
19
|
res.status(response.status);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
res.setHeader('Content-Type', contentType);
|
|
18
|
-
|
|
19
|
-
// Copy other headers
|
|
20
|
-
for (const [key, value] of response.headers.entries()) {
|
|
21
|
-
if (
|
|
22
|
-
key.toLowerCase() !== 'transfer-encoding' &&
|
|
23
|
-
key.toLowerCase() !== 'content-encoding' &&
|
|
24
|
-
key.toLowerCase() !== 'content-length'
|
|
25
|
-
) {
|
|
26
|
-
res.setHeader(key, value);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
20
|
+
copyProxyResponseHeaders(response.headers, res, {
|
|
21
|
+
preserveContentLength: true,
|
|
22
|
+
});
|
|
29
23
|
|
|
30
24
|
// Stream the response body
|
|
31
25
|
if (response.body) {
|
|
32
|
-
|
|
33
|
-
if (err) {
|
|
34
|
-
console.error('Pipeline error in /stream:', err);
|
|
35
|
-
if (!res.headersSent) {
|
|
36
|
-
res.status(500);
|
|
37
|
-
res.end('Error proxying content');
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
});
|
|
26
|
+
await writeStreamResponse(response.body, res);
|
|
41
27
|
} else {
|
|
42
28
|
res.end();
|
|
43
29
|
}
|
|
@@ -49,3 +35,24 @@ export async function streamHandler(req, res) {
|
|
|
49
35
|
}
|
|
50
36
|
}
|
|
51
37
|
}
|
|
38
|
+
|
|
39
|
+
async function writeStreamResponse(body, res) {
|
|
40
|
+
try {
|
|
41
|
+
for await (const chunk of body) {
|
|
42
|
+
if (!res.write(chunk)) {
|
|
43
|
+
await new Promise((resolve) => res.once('drain', resolve));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!res.writableEnded) {
|
|
47
|
+
res.end();
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('Upstream stream failed in /stream:', err);
|
|
51
|
+
if (!res.headersSent) {
|
|
52
|
+
res.status(500);
|
|
53
|
+
res.end('Error proxying content');
|
|
54
|
+
} else if (!res.writableEnded) {
|
|
55
|
+
res.end();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/tests/e2e/docker.test.js
CHANGED
|
@@ -40,9 +40,8 @@ beforeAll(async () => {
|
|
|
40
40
|
await new Promise((resolve) =>
|
|
41
41
|
mockServer.listen(mockPort, '0.0.0.0', resolve)
|
|
42
42
|
);
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
mockUrl = `http://172.17.0.1:${mockPort}`;
|
|
43
|
+
// docker-compose.yml maps this hostname to the host gateway on Linux CI.
|
|
44
|
+
mockUrl = `http://host.docker.internal:${mockPort}`;
|
|
46
45
|
|
|
47
46
|
console.log('Checking if Docker service is already running...');
|
|
48
47
|
const alreadyRunning = await isServiceRunning();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import http from 'http';
|
|
3
2
|
import fetch from 'node-fetch';
|
|
4
3
|
import getPort from 'get-port';
|
|
5
4
|
import path from 'path';
|
|
6
5
|
|
|
7
6
|
const WAIT_FOR_READY = 5000; // ms
|
|
8
7
|
let serverProcess;
|
|
8
|
+
let serverOutput = '';
|
|
9
9
|
let baseUrl;
|
|
10
|
-
let
|
|
10
|
+
let mockProcess;
|
|
11
11
|
let mockUrl;
|
|
12
12
|
|
|
13
13
|
const MOCK_HTML = `<!DOCTYPE html>
|
|
@@ -17,17 +17,13 @@ const MOCK_HTML = `<!DOCTYPE html>
|
|
|
17
17
|
</html>`;
|
|
18
18
|
|
|
19
19
|
beforeAll(async () => {
|
|
20
|
-
// Start a local mock server to avoid depending on external network
|
|
21
20
|
const mockPort = await getPort();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
26
|
-
await new Promise((resolve) => mockServer.listen(mockPort, resolve));
|
|
27
|
-
mockUrl = `http://localhost:${mockPort}`;
|
|
21
|
+
mockProcess = startMockServer(mockPort);
|
|
22
|
+
await waitForProcessOutput(mockProcess, 'mock fixture server listening');
|
|
23
|
+
mockUrl = `http://127.0.0.1:${mockPort}`;
|
|
28
24
|
|
|
29
25
|
const port = await getPort();
|
|
30
|
-
baseUrl = `http://
|
|
26
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
31
27
|
|
|
32
28
|
serverProcess = spawn(
|
|
33
29
|
'node',
|
|
@@ -37,6 +33,9 @@ beforeAll(async () => {
|
|
|
37
33
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
34
|
}
|
|
39
35
|
);
|
|
36
|
+
collectProcessOutput(serverProcess, (chunk) => {
|
|
37
|
+
serverOutput += chunk;
|
|
38
|
+
});
|
|
40
39
|
|
|
41
40
|
// Wait for the server to be ready (simple delay or poll)
|
|
42
41
|
await new Promise((resolve, reject) => {
|
|
@@ -79,8 +78,8 @@ afterAll(() => {
|
|
|
79
78
|
if (serverProcess) {
|
|
80
79
|
serverProcess.kill();
|
|
81
80
|
}
|
|
82
|
-
if (
|
|
83
|
-
|
|
81
|
+
if (mockProcess) {
|
|
82
|
+
mockProcess.kill();
|
|
84
83
|
}
|
|
85
84
|
});
|
|
86
85
|
|
|
@@ -89,8 +88,7 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
89
88
|
const res = await fetch(
|
|
90
89
|
`${baseUrl}/html?url=${encodeURIComponent(mockUrl)}`
|
|
91
90
|
);
|
|
92
|
-
|
|
93
|
-
const text = await res.text();
|
|
91
|
+
const text = await expectTextResponse(res);
|
|
94
92
|
expect(text).toMatch(/<html/i);
|
|
95
93
|
});
|
|
96
94
|
|
|
@@ -98,8 +96,7 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
98
96
|
const res = await fetch(
|
|
99
97
|
`${baseUrl}/markdown?url=${encodeURIComponent(mockUrl)}`
|
|
100
98
|
);
|
|
101
|
-
|
|
102
|
-
const text = await res.text();
|
|
99
|
+
const text = await expectTextResponse(res);
|
|
103
100
|
expect(text).toMatch(/example/i);
|
|
104
101
|
});
|
|
105
102
|
|
|
@@ -107,7 +104,7 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
107
104
|
const res = await fetch(
|
|
108
105
|
`${baseUrl}/image?url=${encodeURIComponent(mockUrl)}`
|
|
109
106
|
);
|
|
110
|
-
|
|
107
|
+
await expectStatusOk(res);
|
|
111
108
|
expect(res.headers.get('content-type')).toMatch(/^image\/png/);
|
|
112
109
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
113
110
|
// PNG signature check
|
|
@@ -122,9 +119,7 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
122
119
|
const res = await fetch(
|
|
123
120
|
`${baseUrl}/stream?url=${encodeURIComponent(mockUrl)}`
|
|
124
121
|
);
|
|
125
|
-
|
|
126
|
-
// Get the response as text
|
|
127
|
-
const text = await res.text();
|
|
122
|
+
const text = await expectTextResponse(res);
|
|
128
123
|
expect(text).toMatch(/<html/i);
|
|
129
124
|
expect(text).toMatch(/Example Domain/i);
|
|
130
125
|
}, 20000);
|
|
@@ -133,9 +128,87 @@ describe('E2E: Web Capture Microservice', () => {
|
|
|
133
128
|
const res = await fetch(
|
|
134
129
|
`${baseUrl}/fetch?url=${encodeURIComponent(mockUrl)}`
|
|
135
130
|
);
|
|
136
|
-
|
|
137
|
-
const text = await res.text();
|
|
131
|
+
const text = await expectTextResponse(res);
|
|
138
132
|
expect(text).toMatch(/<html/i);
|
|
139
133
|
expect(text).toMatch(/Example Domain/i);
|
|
140
134
|
}, 20000);
|
|
141
135
|
});
|
|
136
|
+
|
|
137
|
+
function startMockServer(port) {
|
|
138
|
+
const script = `
|
|
139
|
+
const http = require('http');
|
|
140
|
+
const html = ${JSON.stringify(MOCK_HTML)};
|
|
141
|
+
const server = http.createServer((req, res) => {
|
|
142
|
+
res.writeHead(200, {
|
|
143
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
144
|
+
'Content-Length': Buffer.byteLength(html),
|
|
145
|
+
});
|
|
146
|
+
res.end(html);
|
|
147
|
+
});
|
|
148
|
+
server.listen(${port}, '127.0.0.1', () => {
|
|
149
|
+
console.log('mock fixture server listening');
|
|
150
|
+
});
|
|
151
|
+
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
|
152
|
+
process.on('SIGINT', () => server.close(() => process.exit(0)));
|
|
153
|
+
`;
|
|
154
|
+
return spawn('node', ['-e', script], {
|
|
155
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function collectProcessOutput(child, onChunk) {
|
|
160
|
+
child.stdout.on('data', (data) => onChunk(data.toString()));
|
|
161
|
+
child.stderr.on('data', (data) => onChunk(data.toString()));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function waitForProcessOutput(child, expected) {
|
|
165
|
+
let output = '';
|
|
166
|
+
collectProcessOutput(child, (chunk) => {
|
|
167
|
+
output += chunk;
|
|
168
|
+
});
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
const timeout = setTimeout(() => {
|
|
171
|
+
reject(
|
|
172
|
+
new Error(`Timed out waiting for "${expected}". Output:\n${output}`)
|
|
173
|
+
);
|
|
174
|
+
}, WAIT_FOR_READY);
|
|
175
|
+
child.stdout.on('data', (data) => {
|
|
176
|
+
if (data.toString().includes(expected)) {
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
resolve();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
child.on('exit', (code, signal) => {
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
reject(new Error(`Process exited with code ${code}, signal ${signal}`));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function expectTextResponse(res) {
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
text = await res.text();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`${error.message}\nServer output:\n${serverOutput || '(empty)'}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
expectResponseStatus(res.status, text);
|
|
198
|
+
return text;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function expectStatusOk(res) {
|
|
202
|
+
if (res.status === 200) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
expectResponseStatus(res.status, await res.text());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function expectResponseStatus(status, body) {
|
|
209
|
+
if (status !== 200) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Expected HTTP 200, received ${status}\nBody:\n${body}\nServer output:\n${serverOutput}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
package/tests/unit/cli.test.js
CHANGED
|
@@ -43,7 +43,10 @@ function startFixtureServer({
|
|
|
43
43
|
} = {}) {
|
|
44
44
|
return new Promise((resolve, reject) => {
|
|
45
45
|
const server = http.createServer((req, res) => {
|
|
46
|
-
res.writeHead(200, {
|
|
46
|
+
res.writeHead(200, {
|
|
47
|
+
'Content-Type': contentType,
|
|
48
|
+
'Content-Length': Buffer.byteLength(body),
|
|
49
|
+
});
|
|
47
50
|
res.end(body);
|
|
48
51
|
});
|
|
49
52
|
|
|
@@ -351,5 +354,46 @@ describe('CLI', () => {
|
|
|
351
354
|
});
|
|
352
355
|
expect(body.diagnostics.sourceUrl).toContain('en.wikipedia.org');
|
|
353
356
|
}, 20000);
|
|
357
|
+
|
|
358
|
+
test('emits shared-dialog demo_memory from the shared-dialog subcommand', async () => {
|
|
359
|
+
const fixturePath = resolve(
|
|
360
|
+
__dirname,
|
|
361
|
+
'../../../docs/case-studies/issue-141/raw-data/chatgpt-share-6a3825b9.html'
|
|
362
|
+
);
|
|
363
|
+
const body = readFileSync(fixturePath, 'utf-8');
|
|
364
|
+
const { server, url } = await startFixtureServer({ body });
|
|
365
|
+
const shareUrl = url.replace(
|
|
366
|
+
'/article',
|
|
367
|
+
'/share/6a3825b9-8de4-83ee-9c24-52fd1eb38d24'
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const result = await runCli(
|
|
372
|
+
[
|
|
373
|
+
'shared-dialog',
|
|
374
|
+
shareUrl,
|
|
375
|
+
'--format',
|
|
376
|
+
'demo-memory',
|
|
377
|
+
'--output',
|
|
378
|
+
'-',
|
|
379
|
+
],
|
|
380
|
+
{
|
|
381
|
+
cwd: resolve(__dirname, '../..'),
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
expect(result.code).toBe(0);
|
|
386
|
+
expect(result.stdout.startsWith('demo_memory')).toBe(true);
|
|
387
|
+
expect(result.stdout).toContain('demo_memory');
|
|
388
|
+
expect(result.stdout.match(/\n {2}event "/g)).toHaveLength(4);
|
|
389
|
+
expect(result.stdout).toContain('role "user"');
|
|
390
|
+
expect(result.stdout).toContain('role "assistant"');
|
|
391
|
+
expect(result.stdout).toContain(
|
|
392
|
+
'conversationTitle "Infinite loop script"'
|
|
393
|
+
);
|
|
394
|
+
} finally {
|
|
395
|
+
await stopFixtureServer(server);
|
|
396
|
+
}
|
|
397
|
+
}, 20000);
|
|
354
398
|
});
|
|
355
399
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import nock from 'nock';
|
|
2
|
+
import { fetchHtml } from '../../src/lib.js';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
nock.cleanAll();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('fetchHtml', () => {
|
|
9
|
+
it('requests identity encoding for document HTML fetches', async () => {
|
|
10
|
+
nock('https://example.com', {
|
|
11
|
+
reqheaders: {
|
|
12
|
+
'accept-encoding': 'identity',
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
.get('/article')
|
|
16
|
+
.reply(200, '<html><body>ok</body></html>', {
|
|
17
|
+
'content-type': 'text/html; charset=utf-8',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await expect(fetchHtml('https://example.com/article')).resolves.toContain(
|
|
21
|
+
'ok'
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import nock from 'nock';
|
|
2
|
+
import { fetchGoogleDocAsMarkdown } from '../../src/gdocs.js';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
nock.cleanAll();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('Google Docs public export headers', () => {
|
|
9
|
+
it('requests identity encoding for public export fetches', async () => {
|
|
10
|
+
nock('https://docs.google.com', {
|
|
11
|
+
reqheaders: {
|
|
12
|
+
'accept-encoding': 'identity',
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
.get('/document/d/export-doc/export')
|
|
16
|
+
.query({ format: 'html' })
|
|
17
|
+
.reply(200, '<html><body><h1>Export Doc</h1></body></html>', {
|
|
18
|
+
'content-type': 'text/html; charset=utf-8',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const result = await fetchGoogleDocAsMarkdown(
|
|
22
|
+
'https://docs.google.com/document/d/export-doc/edit'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(result.markdown).toContain('Export Doc');
|
|
26
|
+
expect(nock.isDone()).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -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);
|
|
@@ -10,6 +10,25 @@ function readJson(relativePath) {
|
|
|
10
10
|
return JSON.parse(readFileSync(resolve(jsRoot, relativePath), 'utf8'));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function collectProductionDependencies(packageLock, packageName) {
|
|
14
|
+
const visited = new Set();
|
|
15
|
+
|
|
16
|
+
function visit(name) {
|
|
17
|
+
if (visited.has(name)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
visited.add(name);
|
|
21
|
+
|
|
22
|
+
const entry = packageLock.packages[`node_modules/${name}`];
|
|
23
|
+
for (const dependency of Object.keys(entry?.dependencies || {})) {
|
|
24
|
+
visit(dependency);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
visit(packageName);
|
|
29
|
+
return visited;
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
const runtimeImports = [
|
|
14
33
|
{
|
|
15
34
|
packageName: 'turndown-plugin-gfm',
|
|
@@ -18,6 +37,13 @@ const runtimeImports = [
|
|
|
18
37
|
];
|
|
19
38
|
|
|
20
39
|
describe('runtime package dependencies', () => {
|
|
40
|
+
test('archiver production dependencies do not include deprecated glob', () => {
|
|
41
|
+
const packageLock = readJson('package-lock.json');
|
|
42
|
+
const dependencies = collectProductionDependencies(packageLock, 'archiver');
|
|
43
|
+
|
|
44
|
+
expect([...dependencies]).not.toContain('glob');
|
|
45
|
+
});
|
|
46
|
+
|
|
21
47
|
test.each(runtimeImports)(
|
|
22
48
|
'$packageName is declared as a production dependency',
|
|
23
49
|
({ packageName, sourceFile }) => {
|
|
@@ -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
|
+
}
|