@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.
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
- // Set default content type if not present
16
- const contentType = response.headers.get('content-type') || 'text/plain';
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
- pipeline(response.body, res, (err) => {
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
+ }
@@ -0,0 +1,174 @@
1
+ import { execFileSync } from 'child_process';
2
+ import {
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'fs';
9
+ import { tmpdir } from 'os';
10
+ import { dirname, join, resolve } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const repoRoot = resolve(__dirname, '../../..');
16
+ const detectorPath = resolve(repoRoot, 'scripts/detect-code-changes.mjs');
17
+
18
+ function run(command, args, cwd, options = {}) {
19
+ return execFileSync(command, args, {
20
+ cwd,
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ ...options,
24
+ });
25
+ }
26
+
27
+ function git(repoPath, args) {
28
+ return run('git', args, repoPath);
29
+ }
30
+
31
+ function writeTrackedFile(repoPath, relativePath, contents) {
32
+ const filePath = join(repoPath, relativePath);
33
+ mkdirSync(dirname(filePath), { recursive: true });
34
+ writeFileSync(filePath, contents);
35
+ }
36
+
37
+ function commitAll(repoPath, message) {
38
+ git(repoPath, ['add', '.']);
39
+ git(repoPath, ['commit', '-m', message]);
40
+ }
41
+
42
+ function createMergedFeatureRepo() {
43
+ const repoPath = mkdtempSync(join(tmpdir(), 'web-capture-detect-'));
44
+
45
+ initializeRepo(repoPath);
46
+
47
+ writeTrackedFile(repoPath, 'README.md', '# fixture\n');
48
+ commitAll(repoPath, 'initial commit');
49
+
50
+ git(repoPath, ['checkout', '-b', 'feature']);
51
+ writeTrackedFile(repoPath, 'rust/src/lib.rs', 'pub fn capture() {}\n');
52
+ commitAll(repoPath, 'add rust code');
53
+
54
+ writeTrackedFile(repoPath, 'docs/notes.md', '# docs only tail commit\n');
55
+ commitAll(repoPath, 'document rust change');
56
+
57
+ git(repoPath, ['checkout', 'main']);
58
+ git(repoPath, ['merge', '--no-ff', 'feature', '-m', 'merge feature']);
59
+
60
+ return repoPath;
61
+ }
62
+
63
+ function createSingleCommitRepo() {
64
+ return createSingleCommitRepoWithFiles({
65
+ 'js/src/index.js': 'export const ok = true;\n',
66
+ });
67
+ }
68
+
69
+ function createSingleCommitRepoWithFiles(files) {
70
+ const repoPath = mkdtempSync(join(tmpdir(), 'web-capture-detect-'));
71
+
72
+ initializeRepo(repoPath);
73
+ for (const [relativePath, contents] of Object.entries(files)) {
74
+ writeTrackedFile(repoPath, relativePath, contents);
75
+ }
76
+ commitAll(repoPath, 'initial commit');
77
+
78
+ return repoPath;
79
+ }
80
+
81
+ function initializeRepo(repoPath) {
82
+ git(repoPath, ['init']);
83
+ git(repoPath, ['config', 'user.email', 'ci@example.test']);
84
+ git(repoPath, ['config', 'user.name', 'CI Test']);
85
+ git(repoPath, ['checkout', '-b', 'main']);
86
+ }
87
+
88
+ function parseOutputs(outputFile) {
89
+ return Object.fromEntries(
90
+ readFileSync(outputFile, 'utf8')
91
+ .trim()
92
+ .split('\n')
93
+ .filter(Boolean)
94
+ .map((line) => {
95
+ const separatorIndex = line.indexOf('=');
96
+ return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
97
+ })
98
+ );
99
+ }
100
+
101
+ function runDetector(repoPath, eventName) {
102
+ const outputFile = join(repoPath, `github-output-${eventName}.txt`);
103
+ const env = {
104
+ ...process.env,
105
+ GITHUB_EVENT_NAME: eventName,
106
+ GITHUB_OUTPUT: outputFile,
107
+ };
108
+ delete env.GITHUB_BASE_SHA;
109
+ delete env.GITHUB_HEAD_SHA;
110
+
111
+ run(process.execPath, [detectorPath], repoPath, { env });
112
+
113
+ return parseOutputs(outputFile);
114
+ }
115
+
116
+ describe('detect-code-changes', () => {
117
+ let repoPath;
118
+
119
+ afterEach(() => {
120
+ if (repoPath) {
121
+ rmSync(repoPath, { recursive: true, force: true });
122
+ repoPath = undefined;
123
+ }
124
+ });
125
+
126
+ test('detects code introduced by a real merge commit pushed to main', () => {
127
+ repoPath = createMergedFeatureRepo();
128
+
129
+ const outputs = runDetector(repoPath, 'push');
130
+
131
+ expect(outputs['rust-code-changed']).toBe('true');
132
+ expect(outputs['rust-changed']).toBe('true');
133
+ expect(outputs['any-rust-code-changed']).toBe('true');
134
+ expect(outputs['any-code-changed']).toBe('true');
135
+ expect(outputs['docs-changed']).toBe('true');
136
+ });
137
+
138
+ test('keeps pull request merge commits scoped to the PR head commit', () => {
139
+ repoPath = createMergedFeatureRepo();
140
+
141
+ const outputs = runDetector(repoPath, 'pull_request');
142
+
143
+ expect(outputs['rust-code-changed']).toBe('false');
144
+ expect(outputs['rust-changed']).toBe('false');
145
+ expect(outputs['any-rust-code-changed']).toBe('false');
146
+ expect(outputs['any-code-changed']).toBe('false');
147
+ expect(outputs['docs-changed']).toBe('true');
148
+ });
149
+
150
+ test('detects files when a push only has one commit', () => {
151
+ repoPath = createSingleCommitRepo();
152
+
153
+ const outputs = runDetector(repoPath, 'push');
154
+
155
+ expect(outputs['js-code-changed']).toBe('true');
156
+ expect(outputs['js-changed']).toBe('true');
157
+ expect(outputs['any-js-code-changed']).toBe('true');
158
+ expect(outputs['any-code-changed']).toBe('true');
159
+ });
160
+
161
+ test('runs JS checks for CI tests without requiring a changeset', () => {
162
+ repoPath = createSingleCommitRepoWithFiles({
163
+ 'js/jest.config.mjs': 'export default {};\n',
164
+ 'js/tests/ci/workflow-policy.test.js': 'test("ok", () => {});\n',
165
+ });
166
+
167
+ const outputs = runDetector(repoPath, 'push');
168
+
169
+ expect(outputs['js-code-changed']).toBe('false');
170
+ expect(outputs['js-changed']).toBe('true');
171
+ expect(outputs['any-js-code-changed']).toBe('true');
172
+ expect(outputs['any-code-changed']).toBe('true');
173
+ });
174
+ });
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const repoRoot = resolve(__dirname, '../../..');
8
+
9
+ function parseVersion(version) {
10
+ return version.split('.').map((part) => Number(part));
11
+ }
12
+
13
+ function compareVersions(left, right) {
14
+ const leftParts = parseVersion(left);
15
+ const rightParts = parseVersion(right);
16
+ const maxLength = Math.max(leftParts.length, rightParts.length);
17
+
18
+ for (let index = 0; index < maxLength; index += 1) {
19
+ const leftPart = leftParts[index] || 0;
20
+ const rightPart = rightParts[index] || 0;
21
+
22
+ if (leftPart !== rightPart) {
23
+ return leftPart - rightPart;
24
+ }
25
+ }
26
+
27
+ return 0;
28
+ }
29
+
30
+ describe('Rust Dockerfile', () => {
31
+ test('uses a Rust builder image that satisfies Cargo.toml rust-version', () => {
32
+ const cargoToml = readFileSync(
33
+ resolve(repoRoot, 'rust/Cargo.toml'),
34
+ 'utf8'
35
+ );
36
+ const dockerfile = readFileSync(
37
+ resolve(repoRoot, 'rust/Dockerfile'),
38
+ 'utf8'
39
+ );
40
+
41
+ const requiredRustVersion = cargoToml.match(
42
+ /^rust-version = "([^"]+)"/m
43
+ )?.[1];
44
+ const dockerRustVersion = dockerfile.match(
45
+ /^FROM rust:(\d+(?:\.\d+){1,2})[-\s]/m
46
+ )?.[1];
47
+
48
+ expect(requiredRustVersion).toBeDefined();
49
+ expect(dockerRustVersion).toBeDefined();
50
+ expect(
51
+ compareVersions(dockerRustVersion, requiredRustVersion)
52
+ ).toBeGreaterThanOrEqual(0);
53
+ });
54
+ });
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const repoRoot = resolve(__dirname, '../../..');
8
+
9
+ const workflowFiles = [
10
+ '.github/workflows/js.yml',
11
+ '.github/workflows/rust.yml',
12
+ '.github/workflows/parity.yml',
13
+ ];
14
+
15
+ function readRootFile(relativePath) {
16
+ return readFileSync(resolve(repoRoot, relativePath), 'utf8');
17
+ }
18
+
19
+ function readWorkflow(relativePath) {
20
+ return readRootFile(relativePath);
21
+ }
22
+
23
+ describe('workflow policy', () => {
24
+ test.each(workflowFiles)(
25
+ '%s uses release-safe concurrency cancellation',
26
+ (workflowFile) => {
27
+ const workflow = readWorkflow(workflowFile);
28
+
29
+ expect(workflow).toContain(
30
+ "cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}"
31
+ );
32
+ expect(workflow).not.toContain(
33
+ "cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}"
34
+ );
35
+ }
36
+ );
37
+
38
+ test.each(workflowFiles)(
39
+ '%s uses the current checkout action',
40
+ (workflowFile) => {
41
+ const workflow = readWorkflow(workflowFile);
42
+
43
+ expect(workflow).toContain('uses: actions/checkout@v6');
44
+ expect(workflow).not.toContain('uses: actions/checkout@v4');
45
+ }
46
+ );
47
+
48
+ test('Rust workflow uses the current cache action', () => {
49
+ const workflow = readWorkflow('.github/workflows/rust.yml');
50
+
51
+ expect(workflow).toContain('uses: actions/cache@v5');
52
+ expect(workflow).not.toContain('uses: actions/cache@v4');
53
+ });
54
+
55
+ test('parity check ignores repository-level CI tests', () => {
56
+ const parityScript = readRootFile('scripts/check-js-rust-parity.mjs');
57
+
58
+ expect(parityScript).toContain('"js/tests/ci/"');
59
+ expect(parityScript).toContain(
60
+ 'matchingFiles(files, JS_PARITY_PATHS, JS_PARITY_IGNORE_PATHS)'
61
+ );
62
+ });
63
+ });
@@ -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
- // Use host.docker.internal or the host IP to make it reachable from Docker
44
- // On Linux (CI), Docker containers can reach the host via 172.17.0.1
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 mockServer;
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
- mockServer = http.createServer((req, res) => {
23
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
24
- res.end(MOCK_HTML);
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://localhost:${port}`;
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 (mockServer) {
83
- mockServer.close();
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
- expect(res.status).toBe(200);
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
- expect(res.status).toBe(200);
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
- expect(res.status).toBe(200);
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
- expect(res.status).toBe(200);
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
- expect(res.status).toBe(200);
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
+ }
@@ -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, { 'Content-Type': contentType });
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
+ });