@link-assistant/web-capture 1.11.2 → 2.0.0
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/.changeset/config.json +1 -1
- package/.dockerignore +6 -0
- package/.jscpd.json +4 -5
- package/CHANGELOG.md +12 -0
- package/Dockerfile +13 -11
- package/README.md +23 -4
- package/bin/web-capture.js +3 -2
- package/examples/js/capture_cache.js +12 -0
- package/examples/js/playwright_example.js +1 -1
- package/examples/python/capture_client.py +20 -0
- package/examples/python/html_download.py +2 -14
- package/examples/python/image_download.py +3 -7
- package/examples/python/markdown_download.py +2 -14
- package/examples/python/playwright_example.py +6 -6
- package/jest.config.mjs +1 -4
- package/package.json +54 -39
- package/src/archive.js +1 -1
- package/src/binary-download.js +14 -0
- package/src/cache.js +313 -0
- package/src/docx.js +1 -1
- package/src/fetch.js +1 -1
- package/src/figures.js +2 -12
- package/src/gdocs-model-wait.js +2 -4
- package/src/gdocs.js +1 -1
- package/src/index.js +7 -0
- package/src/lib.js +2 -2
- package/src/localize-images.js +2 -12
- package/src/search.js +1 -1
- package/src/shared-dialog.js +1 -1
- package/src/transport.js +2 -2
- package/tests/ci/dependency-freshness.test.js +74 -0
- package/tests/ci/detect-code-changes.test.js +36 -23
- package/tests/ci/docker-install.test.js +31 -0
- package/tests/ci/workflow-policy.test.js +18 -2
- package/tests/e2e/process.test.js +2 -1
- package/tests/helpers/nock.js +10 -0
- package/tests/helpers/screenshot.js +10 -0
- package/tests/helpers/search-fixture.cjs +1 -1
- package/tests/helpers/temporary-directory.js +12 -0
- package/tests/integration/api-endpoints.test.js +1 -1
- package/tests/integration/browser-engines.test.js +4 -52
- package/tests/integration/browser-packaged-path.test.js +45 -0
- package/tests/integration/deepwiki-rendered-page.mock.test.js +1 -1
- package/tests/integration/formalai-contract.test.js +1 -1
- package/tests/integration/github-readme.test.js +2 -6
- package/tests/integration/google-drive-image.test.js +1 -1
- package/tests/integration/search-endpoint.test.js +1 -1
- package/tests/integration/wikipedia-download.test.js +3 -11
- package/tests/mock/index.test.js +1 -1
- package/tests/unit/browser-launch-options.test.js +42 -0
- package/tests/unit/cache.test.js +182 -0
- package/tests/unit/fetch-html.test.js +1 -1
- package/tests/unit/gdocs-export-headers.test.js +1 -1
- package/tests/unit/gdocs.test.js +1 -1
- package/tests/unit/github.test.js +1 -1
- package/tests/unit/publish-verification.test.js +13 -16
- package/tests/unit/stream.test.js +8 -10
- package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +1 -1
- package/tests/xpaste/xpaste.e2e.test.js +1 -1
- package/tests/xpaste/xpaste.mock.test.js +1 -1
- package/babel.config.cjs +0 -13
package/src/cache.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
readFile,
|
|
5
|
+
writeFile,
|
|
6
|
+
rename,
|
|
7
|
+
unlink,
|
|
8
|
+
readdir,
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { captureResponse } from './transport.js';
|
|
12
|
+
|
|
13
|
+
const VERSION = 1;
|
|
14
|
+
const queues = new Map();
|
|
15
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
|
|
17
|
+
export class CaptureCacheError extends Error {
|
|
18
|
+
constructor(message, kind = 'cache', sourceUrl = '') {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CaptureCacheError';
|
|
21
|
+
this.kind = kind;
|
|
22
|
+
this.sourceUrl = sourceUrl;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function captureRequestKey({ url, method = 'GET', headers = {} }) {
|
|
27
|
+
if (
|
|
28
|
+
typeof url !== 'string' ||
|
|
29
|
+
typeof method !== 'string' ||
|
|
30
|
+
!headers ||
|
|
31
|
+
typeof headers !== 'object' ||
|
|
32
|
+
Array.isArray(headers) ||
|
|
33
|
+
Object.values(headers).some((value) => typeof value !== 'string')
|
|
34
|
+
) {
|
|
35
|
+
throw new CaptureCacheError('Invalid capture request');
|
|
36
|
+
}
|
|
37
|
+
return digest(
|
|
38
|
+
JSON.stringify([
|
|
39
|
+
VERSION,
|
|
40
|
+
url,
|
|
41
|
+
method,
|
|
42
|
+
Object.entries(headers).sort(([a], [b]) =>
|
|
43
|
+
Buffer.compare(Buffer.from(a), Buffer.from(b))
|
|
44
|
+
),
|
|
45
|
+
])
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validate(record) {
|
|
50
|
+
if (
|
|
51
|
+
!record?.request ||
|
|
52
|
+
record.version !== VERSION ||
|
|
53
|
+
!/^[a-f0-9]{64}$/.test(record.sha256) ||
|
|
54
|
+
record.requestKey !== captureRequestKey(record.request) ||
|
|
55
|
+
!Number.isSafeInteger(record.fetchedAt) ||
|
|
56
|
+
record.fetchedAt < 0 ||
|
|
57
|
+
!record.receipt?.finalUrl ||
|
|
58
|
+
!record.receipt?.diagnostics
|
|
59
|
+
) {
|
|
60
|
+
throw new CaptureCacheError('Unsupported or invalid capture record');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function capture(record, cached = true) {
|
|
65
|
+
return {
|
|
66
|
+
receipt: { ...record.receipt, body: Buffer.from(record.receipt.body) },
|
|
67
|
+
requestKey: record.requestKey,
|
|
68
|
+
fetchedAt: record.fetchedAt,
|
|
69
|
+
sha256: record.sha256,
|
|
70
|
+
cached,
|
|
71
|
+
stale: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shared disk format; operations are serialized within this Node process. */
|
|
76
|
+
export class CaptureStore {
|
|
77
|
+
constructor(directory) {
|
|
78
|
+
this.directory = path.resolve(directory);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async locked(operation) {
|
|
82
|
+
const previous = queues.get(this.directory) || Promise.resolve();
|
|
83
|
+
const next = previous.catch(() => {}).then(operation);
|
|
84
|
+
queues.set(this.directory, next);
|
|
85
|
+
try {
|
|
86
|
+
return await next;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof CaptureCacheError) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
throw new CaptureCacheError(error.message);
|
|
92
|
+
} finally {
|
|
93
|
+
if (queues.get(this.directory) === next) {
|
|
94
|
+
queues.delete(this.directory);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
recordPath(key) {
|
|
99
|
+
return path.join(this.directory, 'requests', `${key}.json`);
|
|
100
|
+
}
|
|
101
|
+
bodyPath(sha256) {
|
|
102
|
+
return path.join(this.directory, 'bodies', sha256);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async read(request) {
|
|
106
|
+
let record;
|
|
107
|
+
try {
|
|
108
|
+
record = JSON.parse(
|
|
109
|
+
await readFile(this.recordPath(captureRequestKey(request)), 'utf8')
|
|
110
|
+
);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.code === 'ENOENT') {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
validate(record);
|
|
118
|
+
if (record.requestKey !== captureRequestKey(request)) {
|
|
119
|
+
throw new CaptureCacheError('Request does not match capture');
|
|
120
|
+
}
|
|
121
|
+
record.receipt.body = await readFile(this.bodyPath(record.sha256));
|
|
122
|
+
if (digest(record.receipt.body) !== record.sha256) {
|
|
123
|
+
throw new CaptureCacheError('Capture SHA-256 mismatch');
|
|
124
|
+
}
|
|
125
|
+
return record;
|
|
126
|
+
}
|
|
127
|
+
load(request) {
|
|
128
|
+
return this.locked(async () => {
|
|
129
|
+
const record = await this.read(request);
|
|
130
|
+
return record ? capture(record) : null;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async atomicWrite(target, bytes) {
|
|
134
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
135
|
+
const temporary = path.join(path.dirname(target), `.tmp-${randomUUID()}`);
|
|
136
|
+
try {
|
|
137
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
138
|
+
await rename(temporary, target);
|
|
139
|
+
} finally {
|
|
140
|
+
await unlink(temporary).catch((error) => {
|
|
141
|
+
if (error.code !== 'ENOENT') {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
store(request, receipt, fetchedAt) {
|
|
148
|
+
return this.locked(async () => {
|
|
149
|
+
const record = {
|
|
150
|
+
version: VERSION,
|
|
151
|
+
requestKey: captureRequestKey(request),
|
|
152
|
+
request: {
|
|
153
|
+
url: request.url,
|
|
154
|
+
method: request.method ?? 'GET',
|
|
155
|
+
headers: { ...request.headers },
|
|
156
|
+
},
|
|
157
|
+
fetchedAt,
|
|
158
|
+
sha256: digest(receipt.body),
|
|
159
|
+
receipt: { ...receipt, body: [] },
|
|
160
|
+
};
|
|
161
|
+
validate(record);
|
|
162
|
+
await this.atomicWrite(this.bodyPath(record.sha256), receipt.body);
|
|
163
|
+
await this.atomicWrite(
|
|
164
|
+
this.recordPath(record.requestKey),
|
|
165
|
+
JSON.stringify(record, null, 2)
|
|
166
|
+
);
|
|
167
|
+
record.receipt.body = receipt.body;
|
|
168
|
+
return capture(record, false);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export(request) {
|
|
172
|
+
return this.locked(async () => {
|
|
173
|
+
const record = await this.read(request);
|
|
174
|
+
if (!record) {
|
|
175
|
+
throw new CaptureCacheError('No capture to export');
|
|
176
|
+
}
|
|
177
|
+
record.receipt.body = [...record.receipt.body];
|
|
178
|
+
return JSON.stringify(record, null, 2);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async import(replay) {
|
|
182
|
+
let record;
|
|
183
|
+
try {
|
|
184
|
+
record = JSON.parse(replay);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new CaptureCacheError(`Invalid replay JSON: ${error.message}`);
|
|
187
|
+
}
|
|
188
|
+
validate(record);
|
|
189
|
+
if (
|
|
190
|
+
!Array.isArray(record.receipt.body) ||
|
|
191
|
+
record.receipt.body.some(
|
|
192
|
+
(byte) => !Number.isInteger(byte) || byte < 0 || byte > 255
|
|
193
|
+
)
|
|
194
|
+
) {
|
|
195
|
+
throw new CaptureCacheError('Invalid replay bytes');
|
|
196
|
+
}
|
|
197
|
+
const body = Buffer.from(record.receipt.body);
|
|
198
|
+
if (digest(body) !== record.sha256) {
|
|
199
|
+
throw new CaptureCacheError('Replay SHA-256 mismatch');
|
|
200
|
+
}
|
|
201
|
+
return await this.store(
|
|
202
|
+
record.request,
|
|
203
|
+
{ ...record.receipt, body },
|
|
204
|
+
record.fetchedAt
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
async records() {
|
|
208
|
+
let names;
|
|
209
|
+
try {
|
|
210
|
+
names = await readdir(path.join(this.directory, 'requests'));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code === 'ENOENT') {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
const result = [];
|
|
218
|
+
for (const name of names.filter((name) => name.endsWith('.json')).sort()) {
|
|
219
|
+
const record = JSON.parse(
|
|
220
|
+
await readFile(path.join(this.directory, 'requests', name), 'utf8')
|
|
221
|
+
);
|
|
222
|
+
validate(record);
|
|
223
|
+
if (name !== `${record.requestKey}.json`) {
|
|
224
|
+
throw new CaptureCacheError('Capture filename mismatch');
|
|
225
|
+
}
|
|
226
|
+
result.push(record);
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
list() {
|
|
231
|
+
return this.locked(() => this.records());
|
|
232
|
+
}
|
|
233
|
+
prune(now, ttl) {
|
|
234
|
+
return this.locked(async () => {
|
|
235
|
+
const records = await this.records();
|
|
236
|
+
const live = new Set();
|
|
237
|
+
let removed = 0;
|
|
238
|
+
for (const record of records) {
|
|
239
|
+
if (Math.max(0, now - record.fetchedAt) >= ttl) {
|
|
240
|
+
await unlink(this.recordPath(record.requestKey));
|
|
241
|
+
removed++;
|
|
242
|
+
} else {
|
|
243
|
+
live.add(record.sha256);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let bodies = [];
|
|
247
|
+
try {
|
|
248
|
+
bodies = await readdir(path.join(this.directory, 'bodies'));
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error.code !== 'ENOENT') {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const body of bodies) {
|
|
255
|
+
if (!live.has(body)) {
|
|
256
|
+
await unlink(this.bodyPath(body));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return removed;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Decorate any injectable transport; `execute` is safe to pass as a callback. */
|
|
265
|
+
export class CachedTransport {
|
|
266
|
+
constructor(
|
|
267
|
+
inner,
|
|
268
|
+
store,
|
|
269
|
+
{ ttl = 60 * 24 * 60 * 60 * 1000, offline = false, now = Date.now } = {}
|
|
270
|
+
) {
|
|
271
|
+
if (!Number.isFinite(ttl) || ttl < 0) {
|
|
272
|
+
throw new TypeError('TTL must be non-negative milliseconds');
|
|
273
|
+
}
|
|
274
|
+
this.inner = inner;
|
|
275
|
+
this.store = store;
|
|
276
|
+
this.ttl = ttl;
|
|
277
|
+
this.offline = offline;
|
|
278
|
+
this.now = now;
|
|
279
|
+
this.execute = this.execute.bind(this);
|
|
280
|
+
}
|
|
281
|
+
async capture(request) {
|
|
282
|
+
request.signal?.throwIfAborted();
|
|
283
|
+
const identity = {
|
|
284
|
+
url: request.url,
|
|
285
|
+
method: request.method ?? 'GET',
|
|
286
|
+
headers: { ...request.headers },
|
|
287
|
+
};
|
|
288
|
+
const saved = await this.store.load(identity);
|
|
289
|
+
if (saved) {
|
|
290
|
+
saved.stale = Math.max(0, this.now() - saved.fetchedAt) >= this.ttl;
|
|
291
|
+
if (!saved.stale || this.offline) {
|
|
292
|
+
return saved;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (this.offline) {
|
|
296
|
+
throw new CaptureCacheError(
|
|
297
|
+
'No cached capture in offline mode',
|
|
298
|
+
'offline_cache_miss',
|
|
299
|
+
identity.url
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const receipt = await captureResponse(identity.url, {
|
|
303
|
+
...identity,
|
|
304
|
+
headers: { ...identity.headers },
|
|
305
|
+
signal: request.signal,
|
|
306
|
+
transport: this.inner,
|
|
307
|
+
});
|
|
308
|
+
return this.store.store(identity, receipt, this.now());
|
|
309
|
+
}
|
|
310
|
+
async execute(request) {
|
|
311
|
+
return (await this.capture(request)).receipt;
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/docx.js
CHANGED
|
@@ -75,7 +75,7 @@ export async function docxHandler(req, res) {
|
|
|
75
75
|
const imgUrl = new URL(src, absoluteUrl).href;
|
|
76
76
|
const imgResp = await fetch(imgUrl);
|
|
77
77
|
if (imgResp.ok) {
|
|
78
|
-
const buffer = await imgResp.
|
|
78
|
+
const buffer = Buffer.from(await imgResp.arrayBuffer());
|
|
79
79
|
const contentType = imgResp.headers.get('content-type') || '';
|
|
80
80
|
children.push(
|
|
81
81
|
new Paragraph({
|
package/src/fetch.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function fetchHandler(req, res) {
|
|
|
14
14
|
copyProxyResponseHeaders(response.headers, res);
|
|
15
15
|
|
|
16
16
|
// Get the response body as buffer and send it
|
|
17
|
-
const buffer = await response.
|
|
17
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
18
18
|
res.send(buffer);
|
|
19
19
|
} catch (err) {
|
|
20
20
|
console.error('Fetch error:', err);
|
package/src/figures.js
CHANGED
|
@@ -10,10 +10,9 @@
|
|
|
10
10
|
* @module figures
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import fetch from 'node-fetch';
|
|
14
13
|
import * as cheerio from 'cheerio';
|
|
15
14
|
import { URL } from 'url';
|
|
16
|
-
import {
|
|
15
|
+
import { downloadBinary } from './binary-download.js';
|
|
17
16
|
import { createBrowser, getBrowserEngine } from './browser.js';
|
|
18
17
|
import { dismissPopups, scrollToLoadContent } from './popups.js';
|
|
19
18
|
|
|
@@ -86,16 +85,7 @@ export async function downloadFigures(figures, options = {}) {
|
|
|
86
85
|
const filename = `figure-${figure.figureNum}.${ext}`;
|
|
87
86
|
|
|
88
87
|
try {
|
|
89
|
-
const
|
|
90
|
-
retries: 3,
|
|
91
|
-
baseDelay: 1000,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
if (!resp.ok) {
|
|
95
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const buffer = await resp.buffer();
|
|
88
|
+
const buffer = await downloadBinary(figure.src);
|
|
99
89
|
|
|
100
90
|
if (options.onProgress) {
|
|
101
91
|
options.onProgress(figure.figureNum, 'downloaded');
|
package/src/gdocs-model-wait.js
CHANGED
|
@@ -12,7 +12,6 @@ export async function waitForGoogleDocsModelQuiescence(
|
|
|
12
12
|
const started = Date.now();
|
|
13
13
|
let lastFingerprint = null;
|
|
14
14
|
let stableSince = null;
|
|
15
|
-
let lastModelData = null;
|
|
16
15
|
let lastStableForMs = 0;
|
|
17
16
|
let pollCount = 0;
|
|
18
17
|
|
|
@@ -20,7 +19,6 @@ export async function waitForGoogleDocsModelQuiescence(
|
|
|
20
19
|
const modelData = await readGoogleDocsModelData(page);
|
|
21
20
|
pollCount += 1;
|
|
22
21
|
const fingerprint = googleDocsModelFingerprint(modelData);
|
|
23
|
-
lastModelData = modelData;
|
|
24
22
|
|
|
25
23
|
if (
|
|
26
24
|
fingerprint.chunks > 0 &&
|
|
@@ -56,8 +54,8 @@ export async function waitForGoogleDocsModelQuiescence(
|
|
|
56
54
|
|
|
57
55
|
const elapsedMs = Date.now() - started;
|
|
58
56
|
if (elapsedMs >= maxWaitMs) {
|
|
59
|
-
const lastChunks =
|
|
60
|
-
const lastCidUrls = Object.keys(
|
|
57
|
+
const lastChunks = modelData?.chunks?.length || 0;
|
|
58
|
+
const lastCidUrls = Object.keys(modelData?.cidUrlMap || {}).length;
|
|
61
59
|
throw googleDocsBrowserModelUnavailableError(
|
|
62
60
|
`Google Docs DOCS_modelChunk stream did not quiesce within ${maxWaitMs} ms for document ${documentId} (last chunks=${lastChunks}, payload_bytes=${fingerprint.payloadBytes}, cid_urls=${lastCidUrls}, poll_count=${pollCount}, stable_for_ms=${lastStableForMs})`
|
|
63
61
|
);
|
package/src/gdocs.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Browser-model capture loads /edit and extracts DOCS_modelChunk data.
|
|
9
9
|
|
|
10
10
|
import fetch from 'node-fetch';
|
|
11
|
-
import he from 'he';
|
|
11
|
+
import * as he from 'he';
|
|
12
12
|
import * as cheerio from 'cheerio';
|
|
13
13
|
import { convertHtmlToMarkdown } from './lib.js';
|
|
14
14
|
import { createBrowser as defaultCreateBrowser } from './browser.js';
|
package/src/index.js
CHANGED
package/src/lib.js
CHANGED
|
@@ -6,7 +6,7 @@ import TurndownService from 'turndown';
|
|
|
6
6
|
import iconv from 'iconv-lite';
|
|
7
7
|
import { URL, URLSearchParams } from 'url';
|
|
8
8
|
import turndownPluginGfm from 'turndown-plugin-gfm';
|
|
9
|
-
import he from 'he';
|
|
9
|
+
import * as he from 'he';
|
|
10
10
|
import { isFormulaImage, isMathElement, extractFormula } from './latex.js';
|
|
11
11
|
import { extractMetadata } from './metadata.js';
|
|
12
12
|
import { postProcessMarkdown } from './postprocess.js';
|
|
@@ -151,7 +151,7 @@ export async function fetchGoogleDriveImage(url) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
return {
|
|
154
|
-
buffer: await response.
|
|
154
|
+
buffer: Buffer.from(await response.arrayBuffer()),
|
|
155
155
|
contentType,
|
|
156
156
|
downloadUrl,
|
|
157
157
|
filename: `google-drive-${fileId}.${imageExtensionForContentType(contentType)}`,
|
package/src/localize-images.js
CHANGED
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
* @module localize-images
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import fetch from 'node-fetch';
|
|
17
16
|
import { URL } from 'url';
|
|
18
|
-
import {
|
|
17
|
+
import { downloadBinary } from './binary-download.js';
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Extract image references from markdown text.
|
|
@@ -165,16 +164,7 @@ export async function localizeImages(markdownText, options = {}) {
|
|
|
165
164
|
}
|
|
166
165
|
|
|
167
166
|
try {
|
|
168
|
-
const
|
|
169
|
-
retries: 3,
|
|
170
|
-
baseDelay: 1000,
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
if (!resp.ok) {
|
|
174
|
-
throw new Error(`HTTP ${resp.status}`);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const buffer = await resp.buffer();
|
|
167
|
+
const buffer = await downloadBinary(image.url);
|
|
178
168
|
downloadedCount++;
|
|
179
169
|
|
|
180
170
|
replacements.push({
|
package/src/search.js
CHANGED
package/src/shared-dialog.js
CHANGED
package/src/transport.js
CHANGED
|
@@ -63,12 +63,12 @@ export function fetchTransport({ url, method = 'GET', headers, signal }) {
|
|
|
63
63
|
*/
|
|
64
64
|
export async function captureResponse(
|
|
65
65
|
url,
|
|
66
|
-
{ transport = fetchTransport, signal, headers = {} } = {}
|
|
66
|
+
{ transport = fetchTransport, signal, headers = {}, method = 'GET' } = {}
|
|
67
67
|
) {
|
|
68
68
|
if (!url) {
|
|
69
69
|
throw new Error('Missing URL parameter');
|
|
70
70
|
}
|
|
71
|
-
const request = { url, method
|
|
71
|
+
const request = { url, method, headers, signal };
|
|
72
72
|
try {
|
|
73
73
|
const response = await transport(request);
|
|
74
74
|
if (
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import { checkVersions } from '../../../scripts/check-dependency-freshness.mjs';
|
|
3
|
+
|
|
4
|
+
const latest = async () => '2.0.0';
|
|
5
|
+
const dependencies = [{ name: 'example', current: '1.0.0' }];
|
|
6
|
+
const blockers = {
|
|
7
|
+
example: {
|
|
8
|
+
issue: 'https://github.com/example/repo/issues/1',
|
|
9
|
+
reason: 'Upstream API breaks packaged consumers',
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test('freshness rejects old releases, missing packages and undocumented exceptions', async () => {
|
|
14
|
+
for (const current of ['1.0.0', '(missing)']) {
|
|
15
|
+
await expect(
|
|
16
|
+
checkVersions([{ name: 'example', current }], {}, latest, jest.fn())
|
|
17
|
+
).rejects.toThrow('behind 2.0.0');
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
test('freshness permits only documented open issues', async () => {
|
|
21
|
+
await expect(
|
|
22
|
+
checkVersions(dependencies, blockers, latest, async () => ({
|
|
23
|
+
state: 'open',
|
|
24
|
+
}))
|
|
25
|
+
).resolves.toBeUndefined();
|
|
26
|
+
await expect(
|
|
27
|
+
checkVersions(dependencies, blockers, latest, async () => ({
|
|
28
|
+
state: 'closed',
|
|
29
|
+
}))
|
|
30
|
+
).rejects.toThrow('is closed');
|
|
31
|
+
await expect(
|
|
32
|
+
checkVersions(
|
|
33
|
+
dependencies,
|
|
34
|
+
{ example: { ...blockers.example, reason: '' } },
|
|
35
|
+
latest,
|
|
36
|
+
jest.fn()
|
|
37
|
+
)
|
|
38
|
+
).rejects.toThrow('document an open issue');
|
|
39
|
+
await expect(
|
|
40
|
+
checkVersions(
|
|
41
|
+
dependencies,
|
|
42
|
+
{ example: { ...blockers.example, reason: ' ' } },
|
|
43
|
+
latest,
|
|
44
|
+
jest.fn()
|
|
45
|
+
)
|
|
46
|
+
).rejects.toThrow('document an open issue');
|
|
47
|
+
});
|
|
48
|
+
test('registry and issue failures fail the check rather than treating dependencies as fresh', async () => {
|
|
49
|
+
await expect(
|
|
50
|
+
checkVersions(
|
|
51
|
+
dependencies,
|
|
52
|
+
{},
|
|
53
|
+
async () => {
|
|
54
|
+
throw new Error('registry down');
|
|
55
|
+
},
|
|
56
|
+
jest.fn()
|
|
57
|
+
)
|
|
58
|
+
).rejects.toThrow('registry down');
|
|
59
|
+
await expect(
|
|
60
|
+
checkVersions(dependencies, blockers, latest, async () => {
|
|
61
|
+
throw new Error('issue unavailable');
|
|
62
|
+
})
|
|
63
|
+
).rejects.toThrow('issue unavailable');
|
|
64
|
+
});
|
|
65
|
+
test('current releases need no exception', async () => {
|
|
66
|
+
const getIssue = jest.fn();
|
|
67
|
+
await checkVersions(
|
|
68
|
+
[{ name: 'example', current: '2.0.0' }],
|
|
69
|
+
{},
|
|
70
|
+
latest,
|
|
71
|
+
getIssue
|
|
72
|
+
);
|
|
73
|
+
expect(getIssue).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
@@ -39,7 +39,10 @@ function commitAll(repoPath, message) {
|
|
|
39
39
|
git(repoPath, ['commit', '-m', message]);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function createMergedFeatureRepo(
|
|
42
|
+
function createMergedFeatureRepo({
|
|
43
|
+
featureCode = true,
|
|
44
|
+
baseCode = false,
|
|
45
|
+
} = {}) {
|
|
43
46
|
const repoPath = mkdtempSync(join(tmpdir(), 'web-capture-detect-'));
|
|
44
47
|
|
|
45
48
|
initializeRepo(repoPath);
|
|
@@ -48,13 +51,19 @@ function createMergedFeatureRepo() {
|
|
|
48
51
|
commitAll(repoPath, 'initial commit');
|
|
49
52
|
|
|
50
53
|
git(repoPath, ['checkout', '-b', 'feature']);
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
if (featureCode) {
|
|
55
|
+
writeTrackedFile(repoPath, 'rust/src/lib.rs', 'pub fn capture() {}\n');
|
|
56
|
+
commitAll(repoPath, 'add rust code');
|
|
57
|
+
}
|
|
53
58
|
|
|
54
59
|
writeTrackedFile(repoPath, 'docs/notes.md', '# docs only tail commit\n');
|
|
55
60
|
commitAll(repoPath, 'document rust change');
|
|
56
61
|
|
|
57
62
|
git(repoPath, ['checkout', 'main']);
|
|
63
|
+
if (baseCode) {
|
|
64
|
+
writeTrackedFile(repoPath, 'rust/src/lib.rs', 'pub fn base_capture() {}\n');
|
|
65
|
+
commitAll(repoPath, 'base branch code change');
|
|
66
|
+
}
|
|
58
67
|
git(repoPath, ['merge', '--no-ff', 'feature', '-m', 'merge feature']);
|
|
59
68
|
|
|
60
69
|
return repoPath;
|
|
@@ -123,28 +132,32 @@ describe('detect-code-changes', () => {
|
|
|
123
132
|
}
|
|
124
133
|
});
|
|
125
134
|
|
|
126
|
-
test('
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
test.each(['push', 'pull_request'])(
|
|
136
|
+
'detects code before a docs-only final commit for %s',
|
|
137
|
+
(eventName) => {
|
|
138
|
+
repoPath = createMergedFeatureRepo();
|
|
139
|
+
|
|
140
|
+
expect(runDetector(repoPath, eventName)).toMatchObject({
|
|
141
|
+
'rust-code-changed': 'true',
|
|
142
|
+
'rust-changed': 'true',
|
|
143
|
+
'any-rust-code-changed': 'true',
|
|
144
|
+
'any-code-changed': 'true',
|
|
145
|
+
'docs-changed': 'true',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
);
|
|
140
149
|
|
|
141
|
-
|
|
150
|
+
test.each([
|
|
151
|
+
['docs-only', { featureCode: false }],
|
|
152
|
+
['base branch code only', { featureCode: false, baseCode: true }],
|
|
153
|
+
])('keeps code checks disabled for %s PRs', (_name, options) => {
|
|
154
|
+
repoPath = createMergedFeatureRepo(options);
|
|
142
155
|
|
|
143
|
-
expect(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
expect(runDetector(repoPath, 'pull_request')).toMatchObject({
|
|
157
|
+
'rust-code-changed': 'false',
|
|
158
|
+
'any-code-changed': 'false',
|
|
159
|
+
'docs-changed': 'true',
|
|
160
|
+
});
|
|
148
161
|
});
|
|
149
162
|
|
|
150
163
|
test('detects files when a push only has one commit', () => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
test.each(['js', 'rust'])(
|
|
5
|
+
'%s Docker build fails when every browser package installation attempt fails',
|
|
6
|
+
(implementation) => {
|
|
7
|
+
const dockerfile = readFileSync(
|
|
8
|
+
new globalThis.URL(
|
|
9
|
+
`../../../${implementation}/Dockerfile`,
|
|
10
|
+
import.meta.url
|
|
11
|
+
),
|
|
12
|
+
'utf8'
|
|
13
|
+
);
|
|
14
|
+
const loop = dockerfile.match(/for i in 1 2 3; do[\s\S]*?done/)[0];
|
|
15
|
+
// Execute only the finite retry loop; stub installation and waiting.
|
|
16
|
+
const result = spawnSync(
|
|
17
|
+
'bash',
|
|
18
|
+
[
|
|
19
|
+
'-c',
|
|
20
|
+
`
|
|
21
|
+
apt-get() { printf '%s\\n' "$1"; [ "$1" = update ]; }
|
|
22
|
+
sleep() { return 0; }
|
|
23
|
+
${loop}
|
|
24
|
+
`,
|
|
25
|
+
],
|
|
26
|
+
{ encoding: 'utf8' }
|
|
27
|
+
);
|
|
28
|
+
expect(result.stdout.match(/^install$/gm)).toHaveLength(3);
|
|
29
|
+
expect(result.status).toBe(1);
|
|
30
|
+
}
|
|
31
|
+
);
|