@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.
Files changed (61) hide show
  1. package/.changeset/config.json +1 -1
  2. package/.dockerignore +6 -0
  3. package/.jscpd.json +4 -5
  4. package/CHANGELOG.md +12 -0
  5. package/Dockerfile +13 -11
  6. package/README.md +23 -4
  7. package/bin/web-capture.js +3 -2
  8. package/examples/js/capture_cache.js +12 -0
  9. package/examples/js/playwright_example.js +1 -1
  10. package/examples/python/capture_client.py +20 -0
  11. package/examples/python/html_download.py +2 -14
  12. package/examples/python/image_download.py +3 -7
  13. package/examples/python/markdown_download.py +2 -14
  14. package/examples/python/playwright_example.py +6 -6
  15. package/jest.config.mjs +1 -4
  16. package/package.json +54 -39
  17. package/src/archive.js +1 -1
  18. package/src/binary-download.js +14 -0
  19. package/src/cache.js +313 -0
  20. package/src/docx.js +1 -1
  21. package/src/fetch.js +1 -1
  22. package/src/figures.js +2 -12
  23. package/src/gdocs-model-wait.js +2 -4
  24. package/src/gdocs.js +1 -1
  25. package/src/index.js +7 -0
  26. package/src/lib.js +2 -2
  27. package/src/localize-images.js +2 -12
  28. package/src/search.js +1 -1
  29. package/src/shared-dialog.js +1 -1
  30. package/src/transport.js +2 -2
  31. package/tests/ci/dependency-freshness.test.js +74 -0
  32. package/tests/ci/detect-code-changes.test.js +36 -23
  33. package/tests/ci/docker-install.test.js +31 -0
  34. package/tests/ci/workflow-policy.test.js +18 -2
  35. package/tests/e2e/process.test.js +2 -1
  36. package/tests/helpers/nock.js +10 -0
  37. package/tests/helpers/screenshot.js +10 -0
  38. package/tests/helpers/search-fixture.cjs +1 -1
  39. package/tests/helpers/temporary-directory.js +12 -0
  40. package/tests/integration/api-endpoints.test.js +1 -1
  41. package/tests/integration/browser-engines.test.js +4 -52
  42. package/tests/integration/browser-packaged-path.test.js +45 -0
  43. package/tests/integration/deepwiki-rendered-page.mock.test.js +1 -1
  44. package/tests/integration/formalai-contract.test.js +1 -1
  45. package/tests/integration/github-readme.test.js +2 -6
  46. package/tests/integration/google-drive-image.test.js +1 -1
  47. package/tests/integration/search-endpoint.test.js +1 -1
  48. package/tests/integration/wikipedia-download.test.js +3 -11
  49. package/tests/mock/index.test.js +1 -1
  50. package/tests/unit/browser-launch-options.test.js +42 -0
  51. package/tests/unit/cache.test.js +182 -0
  52. package/tests/unit/fetch-html.test.js +1 -1
  53. package/tests/unit/gdocs-export-headers.test.js +1 -1
  54. package/tests/unit/gdocs.test.js +1 -1
  55. package/tests/unit/github.test.js +1 -1
  56. package/tests/unit/publish-verification.test.js +13 -16
  57. package/tests/unit/stream.test.js +8 -10
  58. package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +1 -1
  59. package/tests/xpaste/xpaste.e2e.test.js +1 -1
  60. package/tests/xpaste/xpaste.mock.test.js +1 -1
  61. 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.buffer();
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.buffer();
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 { retry } from './retry.js';
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 resp = await retry(() => fetch(figure.src), {
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');
@@ -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 = lastModelData?.chunks?.length || 0;
60
- const lastCidUrls = Object.keys(lastModelData?.cidUrlMap || {}).length;
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
@@ -97,3 +97,10 @@ export {
97
97
  parseSharedDialog,
98
98
  sharedDialogHandler,
99
99
  } from './shared-dialog.js';
100
+
101
+ export {
102
+ CaptureStore,
103
+ CachedTransport,
104
+ CaptureCacheError,
105
+ captureRequestKey,
106
+ } from './cache.js';
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.buffer(),
154
+ buffer: Buffer.from(await response.arrayBuffer()),
155
155
  contentType,
156
156
  downloadUrl,
157
157
  filename: `google-drive-${fileId}.${imageExtensionForContentType(contentType)}`,
@@ -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 { retry } from './retry.js';
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 resp = await retry(() => fetch(image.url), {
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
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import * as cheerio from 'cheerio';
23
- import he from 'he';
23
+ import * as he from 'he';
24
24
  import { URL } from 'url';
25
25
  import { captureResponse, fetchTransport } from './transport.js';
26
26
 
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import fetch from 'node-fetch';
12
- import he from 'he';
12
+ import * as he from 'he';
13
13
  import { URL } from 'node:url';
14
14
 
15
15
  import { createBrowser as defaultCreateBrowser } from './browser.js';
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: 'GET', headers, signal };
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
- writeTrackedFile(repoPath, 'rust/src/lib.rs', 'pub fn capture() {}\n');
52
- commitAll(repoPath, 'add rust code');
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('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();
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
- const outputs = runDetector(repoPath, 'pull_request');
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(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');
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
+ );