@link-assistant/web-capture 1.11.1 → 1.12.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 (38) hide show
  1. package/.dockerignore +6 -0
  2. package/.jscpd.json +4 -5
  3. package/CHANGELOG.md +12 -0
  4. package/Dockerfile +7 -5
  5. package/README.md +22 -4
  6. package/examples/js/capture_cache.js +12 -0
  7. package/package.json +49 -37
  8. package/src/cache.js +313 -0
  9. package/src/gdocs-model-wait.js +2 -4
  10. package/src/gdocs.js +1 -1
  11. package/src/index.js +7 -0
  12. package/src/lib.js +1 -1
  13. package/src/search.js +1 -1
  14. package/src/shared-dialog.js +1 -1
  15. package/src/transport.js +2 -2
  16. package/tests/ci/dependency-freshness.test.js +74 -0
  17. package/tests/ci/docker-install.test.js +31 -0
  18. package/tests/ci/workflow-policy.test.js +16 -0
  19. package/tests/e2e/process.test.js +2 -1
  20. package/tests/helpers/nock.js +10 -0
  21. package/tests/helpers/search-fixture.cjs +1 -1
  22. package/tests/helpers/temporary-directory.js +12 -0
  23. package/tests/integration/api-endpoints.test.js +1 -1
  24. package/tests/integration/browser-packaged-path.test.js +45 -0
  25. package/tests/integration/deepwiki-rendered-page.mock.test.js +1 -1
  26. package/tests/integration/formalai-contract.test.js +1 -1
  27. package/tests/integration/google-drive-image.test.js +1 -1
  28. package/tests/integration/search-endpoint.test.js +1 -1
  29. package/tests/mock/index.test.js +1 -1
  30. package/tests/unit/browser-launch-options.test.js +42 -0
  31. package/tests/unit/cache.test.js +182 -0
  32. package/tests/unit/fetch-html.test.js +1 -1
  33. package/tests/unit/gdocs-export-headers.test.js +1 -1
  34. package/tests/unit/gdocs.test.js +1 -1
  35. package/tests/unit/github.test.js +1 -1
  36. package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +1 -1
  37. package/tests/xpaste/xpaste.e2e.test.js +1 -1
  38. package/tests/xpaste/xpaste.mock.test.js +1 -1
package/.dockerignore ADDED
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ reports
3
+ coverage
4
+ *.log
5
+ .captures
6
+ capture-replay.json
package/.jscpd.json CHANGED
@@ -1,8 +1,6 @@
1
1
  {
2
- "threshold": 0,
3
2
  "minTokens": 30,
4
3
  "minLines": 5,
5
- "skipComments": true,
6
4
  "ignore": [
7
5
  "**/node_modules/**",
8
6
  "**/build/**",
@@ -12,9 +10,10 @@
12
10
  "**/.changeset/**",
13
11
  "**/package-lock.json",
14
12
  "**/pnpm-lock.yaml",
15
- "**/yarn.lock"
13
+ "**/yarn.lock",
14
+ "**/reports/**"
16
15
  ],
17
- "format": "console",
18
16
  "reporters": ["console", "html"],
19
- "output": "./reports/jscpd"
17
+ "output": "./reports/jscpd",
18
+ "mode": "weak"
20
19
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a6c74ba: Add a SHA-256 disk capture cache with TTL, offline stale replay, portable receipts, listing and pruning. Upgrade Puppeteer to 25 and forward packaged browser paths through browser-commander 0.10.0. Update other direct dependencies and require audit/freshness checks in CI. Node.js 22.22.1 or later is now required. Bundled consumers must externalize Playwright and Playwright Core.
8
+
9
+ ## 1.11.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Auto-release unreleased changes
14
+
3
15
  ## 1.11.1
4
16
 
5
17
  ### Patch Changes
package/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM node:22-bullseye
1
+ FROM node:24-bookworm
2
2
 
3
3
  WORKDIR /app
4
4
 
@@ -27,12 +27,14 @@ RUN apt-get update && \
27
27
  libxshmfence1 \
28
28
  xdg-utils \
29
29
  --no-install-recommends && break || \
30
- { echo "apt-get install attempt $i failed, retrying..."; sleep 5; }; \
30
+ { if [ "$i" -eq 3 ]; then exit 1; fi; \
31
+ echo "apt-get install attempt $i failed, retrying..."; \
32
+ apt-get update && sleep 5; }; \
31
33
  done && \
32
34
  rm -rf /var/lib/apt/lists/*
33
35
 
34
36
  # Set Puppeteer environment variables
35
- ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
37
+ ENV PUPPETEER_SKIP_DOWNLOAD=true
36
38
  ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
37
39
  ENV PUPPETEER_ARGS="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage"
38
40
 
@@ -40,8 +42,8 @@ ENV PUPPETEER_ARGS="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usag
40
42
  ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
41
43
  ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
42
44
 
43
- COPY package.json yarn.lock ./
44
- RUN yarn install --frozen-lockfile
45
+ COPY package.json package-lock.json ./
46
+ RUN npm ci
45
47
 
46
48
  COPY . .
47
49
 
package/README.md CHANGED
@@ -19,6 +19,16 @@ A CLI and microservice to fetch URLs and render them as:
19
19
 
20
20
  ## Installation
21
21
 
22
+ Node.js 22.22.1 or newer is required. CI and Docker use Node.js 24. Use `npm ci`
23
+ for a locked repository install; Docker and CI share `package-lock.json` and its
24
+ security overrides.
25
+
26
+ Bundled applications must externalize `playwright` and `playwright-core` and
27
+ ship them as production packages. Playwright does not support bundling its
28
+ internal modules. Packaged Chromium is supported through
29
+ `createBrowser(engine, { executablePath: packagedPath, channel: 'chrome' })`
30
+ for both engines. See [the upstream bundling discussion](https://github.com/microsoft/playwright/issues/33031).
31
+
22
32
  ### From npm
23
33
 
24
34
  ```bash
@@ -518,14 +528,14 @@ with environment variables:
518
528
 
519
529
  ```bash
520
530
  # Download the Wikipedia page (markdown + image) in every supported engine
521
- WIKIPEDIA_INTEGRATION=true npm test -- --testPathPattern="wikipedia-download"
531
+ WIKIPEDIA_INTEGRATION=true npm test -- --testPathPatterns="wikipedia-download"
522
532
 
523
533
  # Download a GitHub repository page as compact txt/markdown, original HTML, and screenshots
524
- GITHUB_REPOSITORY_INTEGRATION=true npm test -- --testPathPattern="github-readme"
534
+ GITHUB_REPOSITORY_INTEGRATION=true npm test -- --testPathPatterns="github-readme"
525
535
 
526
536
  # Habr articles and public Google Docs live suites
527
- HABR_INTEGRATION=true npm test -- --testPathPattern="habr-article"
528
- GDOCS_INTEGRATION=true npm test -- --testPathPattern="gdocs-public-doc"
537
+ HABR_INTEGRATION=true npm test -- --testPathPatterns="habr-article"
538
+ GDOCS_INTEGRATION=true npm test -- --testPathPatterns="gdocs-public-doc"
529
539
  ```
530
540
 
531
541
  ## Built With
@@ -598,3 +608,11 @@ JSON response does not duplicate the full provider body.
598
608
  ## License
599
609
 
600
610
  [Unlicense](../LICENSE) — This is free and unencumbered software released into the public domain. You are free to copy, modify, publish, use, compile, sell, or distribute this software for any purpose, commercial or non-commercial, and by any means. See [https://unlicense.org](https://unlicense.org) for details.
611
+
612
+ ## Capture cache
613
+
614
+ Both packages provide `CaptureStore` and `CachedTransport` over caller-owned transports. Captures keep the exact body bytes, final URL, status, response headers and diagnostics. Request keys include the exact URL, method and every request header in sorted order. Identical bodies share a SHA-256 file. The default TTL is 60 days; expiry is measured from fetch completion, in Unix milliseconds.
615
+
616
+ Online mode reuses fresh captures and refetches expired entries. Offline mode never invokes the transport: it returns cached captures with `cached: true` and a `stale` flag, or an `offline_cache_miss` error. Use `capture` to read these flags; the transport adapter returns the original receipt unchanged. Transport errors are not stored. Corrupt or unsupported records produce cache errors instead of returning unverified bytes.
617
+
618
+ `list` returns request metadata; `prune(now, ttl)` removes expired request records and unreferenced body files. `export(request)` and `import(json)` exchange version 1 JSON replay fixtures with byte arrays, shared between Rust and JavaScript. See [the cache format and examples](../docs/capture-cache.md).
@@ -0,0 +1,12 @@
1
+ import { CaptureStore, CachedTransport } from '../../src/cache.js';
2
+ import { fetchTransport } from '../../src/transport.js';
3
+
4
+ const store = new CaptureStore('.captures');
5
+ const request = { url: 'https://example.com', method: 'GET', headers: {} };
6
+ const online = new CachedTransport(fetchTransport, store);
7
+ const first = await online.capture(request);
8
+ const offline = new CachedTransport(fetchTransport, store, { offline: true });
9
+ const replay = await offline.capture(request);
10
+ console.log(replay.sha256, replay.cached, replay.stale);
11
+ console.log('Exact replay:', first.receipt.body.equals(replay.receipt.body));
12
+ console.log('Portable fixture:', await store.export(request));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -11,19 +11,19 @@
11
11
  "dev": "nodemon bin/web-capture.js -- --serve",
12
12
  "test": "node --experimental-vm-modules ./node_modules/.bin/jest",
13
13
  "test:watch": "node --experimental-vm-modules ./node_modules/.bin/jest --watch",
14
- "test:e2e:process": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e.process.test.js",
15
- "test:e2e:docker": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e.docker.test.js",
16
- "test:e2e": "yarn test:e2e:process && yarn test:e2e:docker",
17
- "test:all": "yarn build && yarn test && yarn test:e2e",
18
- "build": "docker compose down && docker compose build && yarn start",
14
+ "test:e2e:process": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e/process.test.js",
15
+ "test:e2e:docker": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e/docker.test.js",
16
+ "test:e2e": "npm run test:e2e:process && npm run test:e2e:docker",
17
+ "test:all": "npm run build && npm test && npm run test:e2e",
18
+ "build": "docker compose down && docker compose build && npm start",
19
19
  "examples:python": "python3 examples/python/html_download.py && python3 examples/python/markdown_download.py && python3 examples/python/image_download.py",
20
20
  "examples:javascript": "node examples/js/html_download.js && node examples/js/markdown_download.js && node examples/js/image_download.js",
21
- "examples": "yarn build && yarn examples:javascript && yarn examples:python",
21
+ "examples": "npm run build && npm run examples:javascript && npm run examples:python",
22
22
  "lint": "eslint .",
23
23
  "lint:fix": "eslint . --fix",
24
24
  "format": "prettier --write .",
25
25
  "format:check": "prettier --check .",
26
- "check:duplication": "jscpd .",
26
+ "check:duplication": "jscpd . --baseline-from-ref origin/main --fail-on-new-clones --fail-on-empty",
27
27
  "check": "npm run lint && npm run format:check && npm run check:duplication",
28
28
  "prepare": "husky || true",
29
29
  "changeset": "changeset",
@@ -32,42 +32,43 @@
32
32
  "changeset:status": "changeset status --since=origin/main"
33
33
  },
34
34
  "dependencies": {
35
- "@kreuzberg/html-to-markdown-node": "^3.5.5",
35
+ "@kreuzberg/html-to-markdown-node": "^3.7.2",
36
36
  "archiver": "^8.0.0",
37
- "browser-commander": "^0.8.0",
38
- "cheerio": "^1.0.0",
39
- "docx": "^9.6.1",
40
- "express": "^4.18.2",
41
- "he": "^1.2.0",
42
- "iconv-lite": "^0.6.3",
37
+ "browser-commander": "0.10.0",
38
+ "cheerio": "^1.2.0",
39
+ "docx": "^9.9.0",
40
+ "express": "^5.2.1",
41
+ "he": "^2.0.0",
42
+ "iconv-lite": "^0.7.3",
43
43
  "lino-arguments": "^0.3.0",
44
44
  "log-lazy": "^1.0.4",
45
- "node-fetch": "^2.7.0",
46
- "playwright": "^1.49.0",
47
- "puppeteer": "^24.8.2",
48
- "puppeteer-core": "^24.40.0",
49
- "turndown": "^7.1.1",
45
+ "node-fetch": "^3.3.2",
46
+ "playwright": "^1.63.0",
47
+ "puppeteer": "^25.12.0",
48
+ "puppeteer-core": "^25.12.0",
49
+ "turndown": "^7.2.4",
50
50
  "turndown-plugin-gfm": "^1.0.2"
51
51
  },
52
52
  "devDependencies": {
53
- "@babel/core": "^7.24.0",
54
- "@babel/preset-env": "^7.24.0",
55
- "@changesets/cli": "^2.29.7",
56
- "@types/archiver": "^7.0.0",
57
- "babel-jest": "^29.7.0",
58
- "eslint": "^9.38.0",
53
+ "@babel/core": "^8.0.7",
54
+ "@babel/preset-env": "^8.0.7",
55
+ "@changesets/cli": "^3.0.3",
56
+ "@types/archiver": "^8.0.0",
57
+ "babel-jest": "^30.5.2",
58
+ "eslint": "^10.12.0",
59
59
  "eslint-config-prettier": "^10.1.8",
60
- "eslint-plugin-prettier": "^5.5.4",
61
- "get-port": "^7.1.0",
60
+ "eslint-plugin-prettier": "^5.5.6",
61
+ "get-port": "^7.2.0",
62
62
  "husky": "^9.1.7",
63
- "jest": "^29.7.0",
64
- "jscpd": "^4.0.5",
65
- "lint-staged": "^16.2.6",
66
- "markdown-it": "^14.1.1",
67
- "nock": "^13.5.4",
68
- "prettier": "^3.6.2",
69
- "supertest": "^6.3.4",
70
- "unzipper": "^0.12.3"
63
+ "jest": "^30.5.2",
64
+ "jscpd": "^5.4.0",
65
+ "lint-staged": "^17.6.0",
66
+ "markdown-it": "^15.0.2",
67
+ "nock": "^15.0.1",
68
+ "prettier": "^3.9.9",
69
+ "supertest": "^7.3.1",
70
+ "unzipper": "^0.12.5",
71
+ "@eslint/js": "^10.0.1"
71
72
  },
72
73
  "type": "module",
73
74
  "keywords": [
@@ -82,7 +83,7 @@
82
83
  "url": "https://github.com/link-assistant/web-capture"
83
84
  },
84
85
  "engines": {
85
- "node": ">=22.0.0 <23.0.0"
86
+ "node": ">=22.22.1"
86
87
  },
87
88
  "lint-staged": {
88
89
  "*.{js,mjs,cjs}": [
@@ -94,5 +95,16 @@
94
95
  "prettier --write",
95
96
  "prettier --check"
96
97
  ]
98
+ },
99
+ "dependencyBlockers": {
100
+ "browser-commander": {
101
+ "issue": "https://github.com/link-assistant/web-capture/issues/160",
102
+ "reason": "Keep the first compatible packaged-browser release; later versions add native better-sqlite3 dependencies."
103
+ }
104
+ },
105
+ "overrides": {
106
+ "@istanbuljs/load-nyc-config": {
107
+ "js-yaml": "^4.1.1"
108
+ }
97
109
  }
98
110
  }
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
+ }
@@ -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';
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
+ });
@@ -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
+ );
@@ -61,3 +61,19 @@ describe('workflow policy', () => {
61
61
  );
62
62
  });
63
63
  });
64
+
65
+ test('release workflows require dependency health gates', () => {
66
+ const js = readWorkflow('.github/workflows/js.yml');
67
+ expect(js).toContain('npm audit --package-lock-only --audit-level=moderate');
68
+ expect(js).toContain('check-dependency-freshness.mjs npm');
69
+ expect(js).toContain('needs: [lint, test, dependency-health]');
70
+ const rust = readWorkflow('.github/workflows/rust.yml');
71
+ expect(rust).toContain('check-dependency-freshness.mjs cargo');
72
+ expect(rust).toContain('cargo update --dry-run');
73
+ expect(rust).toContain('needs: [lint, test, build, dependency-health]');
74
+ for (const workflow of [js, rust]) {
75
+ expect(
76
+ workflow.match(/needs\.dependency-health\.result == 'success'/g)
77
+ ).toHaveLength(2);
78
+ }
79
+ });
@@ -191,7 +191,8 @@ async function expectTextResponse(res) {
191
191
  text = await res.text();
192
192
  } catch (error) {
193
193
  throw new Error(
194
- `${error.message}\nServer output:\n${serverOutput || '(empty)'}`
194
+ `${error.message}\nServer output:\n${serverOutput || '(empty)'}`,
195
+ { cause: error }
195
196
  );
196
197
  }
197
198
  expectResponseStatus(res.status, text);
@@ -0,0 +1,10 @@
1
+ import nock from 'nock';
2
+
3
+ // Nock 15 interceptors survive Jest VM contexts unless explicitly restored.
4
+ // https://github.com/nock/nock#memory-issues-with-jest
5
+ afterAll(() => {
6
+ nock.cleanAll();
7
+ nock.restore();
8
+ });
9
+
10
+ export default nock;
@@ -1,4 +1,4 @@
1
- const nock = require('nock');
1
+ const { default: nock } = require('nock');
2
2
 
3
3
  nock('https://en.wikipedia.org')
4
4
  .persist()
@@ -0,0 +1,12 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export async function withTemporaryDirectory(prefix, operation) {
6
+ const directory = await mkdtemp(path.join(tmpdir(), prefix));
7
+ try {
8
+ return await operation(directory);
9
+ } finally {
10
+ await rm(directory, { recursive: true, force: true });
11
+ }
12
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { jest } from '@jest/globals';
10
10
  import request from 'supertest';
11
- import nock from 'nock';
11
+ import nock from '../helpers/nock.js';
12
12
  import unzipper from 'unzipper';
13
13
  import { app } from '../../src/index.js';
14
14
  import { isKreuzbergAvailable } from '../../src/kreuzberg.js';
@@ -0,0 +1,45 @@
1
+ import { mkdtemp, rm, symlink } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { chromium } from 'playwright';
5
+ import { createBrowser } from '../../src/browser.js';
6
+
7
+ // Only deterministic local HTML is used. Neither engine may select its own cache.
8
+ test.each(['puppeteer', 'playwright'])(
9
+ '%s renders using a Chromium executable at an explicit temporary path',
10
+ async (engine) => {
11
+ const directory = await mkdtemp(path.join(tmpdir(), 'packaged-browser-'));
12
+ const packagedPath = path.join(
13
+ directory,
14
+ process.platform === 'win32' ? 'chrome.exe' : 'chromium'
15
+ );
16
+ let browser;
17
+ let page;
18
+ try {
19
+ await symlink(chromium.executablePath(), packagedPath);
20
+ browser = await createBrowser(engine, {
21
+ executablePath: packagedPath,
22
+ userDataDir: path.join(directory, 'profile'),
23
+ });
24
+ page = await browser.newPage();
25
+ await page.goto(
26
+ 'data:text/html,<main id="result"></main><script>document.getElementById("result").textContent="Packaged Chromium rendered"</script>'
27
+ );
28
+ expect(await page.content()).toContain('Packaged Chromium rendered');
29
+ const executable =
30
+ engine === 'puppeteer'
31
+ ? browser.rawBrowser.process().spawnfile
32
+ : packagedPath;
33
+ expect(executable).toBe(packagedPath);
34
+ } finally {
35
+ if (page) {
36
+ await page.close();
37
+ }
38
+ if (browser) {
39
+ await browser.close();
40
+ }
41
+ await rm(directory, { recursive: true, force: true });
42
+ }
43
+ },
44
+ 30000
45
+ );
@@ -1,5 +1,5 @@
1
1
  import { jest } from '@jest/globals';
2
- import nock from 'nock';
2
+ import nock from '../helpers/nock.js';
3
3
  import request from 'supertest';
4
4
  import { URL, fileURLToPath } from 'url';
5
5
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import request from 'supertest';
9
- import nock from 'nock';
9
+ import nock from '../helpers/nock.js';
10
10
  import unzipper from 'unzipper';
11
11
  import { app, SEARCH_PROVIDERS } from '../../src/index.js';
12
12
 
@@ -1,5 +1,5 @@
1
1
  import request from 'supertest';
2
- import nock from 'nock';
2
+ import nock from '../helpers/nock.js';
3
3
  import unzipper from 'unzipper';
4
4
  import { app } from '../../src/index.js';
5
5
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import request from 'supertest';
8
- import nock from 'nock';
8
+ import nock from '../helpers/nock.js';
9
9
  import { app } from '../../src/index.js';
10
10
 
11
11
  const WIKI_JSON = {
@@ -1,5 +1,5 @@
1
1
  import request from 'supertest';
2
- import nock from 'nock';
2
+ import nock from '../helpers/nock.js';
3
3
  import { jest } from '@jest/globals';
4
4
 
5
5
  let app;
@@ -0,0 +1,42 @@
1
+ import { jest } from '@jest/globals';
2
+ import path from 'node:path';
3
+ import { withTemporaryDirectory } from '../helpers/temporary-directory.js';
4
+
5
+ const initialPage = { close: jest.fn(), bringToFront: jest.fn() };
6
+ const browser = { pages: jest.fn(() => [initialPage]), close: jest.fn() };
7
+ const launch = jest.fn(async () => browser);
8
+ const launchPersistentContext = jest.fn(async () => browser);
9
+ jest.unstable_mockModule('puppeteer', () => ({ default: { launch } }));
10
+ jest.unstable_mockModule('playwright', () => ({
11
+ chromium: { launchPersistentContext },
12
+ }));
13
+ const { createBrowser } = await import('../../src/browser.js');
14
+
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ });
18
+
19
+ test.each(['puppeteer', 'playwright'])(
20
+ '%s forwards packaged executablePath and channel to the engine',
21
+ (engine) =>
22
+ withTemporaryDirectory('launch-options-', async (directory) => {
23
+ const options = {
24
+ executablePath: path.join(directory, 'packaged-chromium'),
25
+ channel: 'chrome',
26
+ userDataDir: directory,
27
+ };
28
+ const handle = await createBrowser(engine, options);
29
+ try {
30
+ const observed =
31
+ engine === 'puppeteer'
32
+ ? launch.mock.calls[0][0]
33
+ : launchPersistentContext.mock.calls[0][1];
34
+ expect(observed).toMatchObject({
35
+ executablePath: options.executablePath,
36
+ channel: options.channel,
37
+ });
38
+ } finally {
39
+ await handle.close();
40
+ }
41
+ })
42
+ );
@@ -0,0 +1,182 @@
1
+ import { jest } from '@jest/globals';
2
+ import { mkdtemp, readFile, rm, writeFile, readdir } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { CaptureStore, CachedTransport } from '../../src/cache.js';
6
+ import { captureResponse } from '../../src/transport.js';
7
+
8
+ const request = {
9
+ url: 'https://example.test/source',
10
+ method: 'GET',
11
+ headers: { accept: 'application/octet-stream' },
12
+ };
13
+ const receipt = {
14
+ body: Buffer.from([0, 255, 65]),
15
+ finalUrl: 'https://example.test/final',
16
+ status: 206,
17
+ headers: { etag: 'v1' },
18
+ diagnostics: { outcome: 'response' },
19
+ };
20
+ let directory, store;
21
+ beforeEach(async () => {
22
+ directory = await mkdtemp(path.join(tmpdir(), 'capture-cache-'));
23
+ store = new CaptureStore(directory);
24
+ });
25
+ afterEach(async () => {
26
+ await rm(directory, { recursive: true, force: true });
27
+ });
28
+
29
+ test('exact receipts survive cache hits, offline stale replay and online refresh', async () => {
30
+ let time = 1000;
31
+ const inner = jest.fn(async () => receipt);
32
+ const options = { ttl: 100, now: () => time };
33
+ const online = new CachedTransport(inner, store, options);
34
+ const first = await online.capture(request);
35
+ expect(first).toMatchObject({ cached: false, stale: false, receipt });
36
+ expect(
37
+ await captureResponse(request.url, {
38
+ transport: online.execute,
39
+ headers: request.headers,
40
+ })
41
+ ).toEqual(receipt);
42
+ expect(inner).toHaveBeenCalledTimes(1);
43
+ const forbidden = jest.fn(() => {
44
+ throw new Error('offline network access');
45
+ });
46
+ const offline = new CachedTransport(forbidden, store, {
47
+ ...options,
48
+ offline: true,
49
+ });
50
+ expect(await offline.capture(request)).toMatchObject({
51
+ cached: true,
52
+ stale: false,
53
+ receipt,
54
+ });
55
+ time = 1100;
56
+ expect(await offline.capture(request)).toMatchObject({
57
+ cached: true,
58
+ stale: true,
59
+ receipt,
60
+ });
61
+ expect(forbidden).not.toHaveBeenCalled();
62
+ expect(await online.capture(request)).toMatchObject({
63
+ cached: false,
64
+ stale: false,
65
+ });
66
+ expect(inner).toHaveBeenCalledTimes(2);
67
+ expect(await store.list()).toHaveLength(1);
68
+ expect(await store.prune(1200, 100)).toBe(1);
69
+ await expect(offline.capture(request)).rejects.toMatchObject({
70
+ kind: 'offline_cache_miss',
71
+ });
72
+ });
73
+
74
+ test('portable fixture imports/exports, request isolation and body deduplication', async () => {
75
+ const fixture = await readFile(
76
+ new globalThis.URL(
77
+ '../../../rust/tests/fixtures/capture-v1.json',
78
+ import.meta.url
79
+ ),
80
+ 'utf8'
81
+ );
82
+ await store.import(fixture);
83
+ expect((await store.load(request)).receipt).toEqual(receipt);
84
+ expect(JSON.parse(await store.export(request))).toEqual(JSON.parse(fixture));
85
+ for (const different of [
86
+ { ...request, method: 'POST' },
87
+ { ...request, url: `${request.url}?query=1` },
88
+ { ...request, headers: { authorization: 'different' } },
89
+ ]) {
90
+ expect(await store.load(different)).toBeNull();
91
+ }
92
+ await store.store(
93
+ { ...request, url: `${request.url}/duplicate` },
94
+ receipt,
95
+ 2000
96
+ );
97
+ expect(await readdir(path.join(directory, 'bodies'))).toHaveLength(1);
98
+ expect(await store.prune(1100, 100)).toBe(1);
99
+ expect(await readdir(path.join(directory, 'bodies'))).toHaveLength(1);
100
+ await expect(
101
+ store.import(fixture.replace('255', '254'))
102
+ ).rejects.toMatchObject({ kind: 'cache' });
103
+ await expect(
104
+ store.import(fixture.replace('"version": 1', '"version": 999'))
105
+ ).rejects.toMatchObject({ kind: 'cache' });
106
+ });
107
+
108
+ test('corrupt bodies are rejected and failed transports are never cached', async () => {
109
+ const stored = await store.store(request, receipt, 1000);
110
+ await writeFile(path.join(directory, 'bodies', stored.sha256), 'corrupt');
111
+ await expect(store.load(request)).rejects.toMatchObject({ kind: 'cache' });
112
+ const failure = new Error('transport failed');
113
+ const cache = new CachedTransport(async () => {
114
+ throw failure;
115
+ }, store);
116
+ await expect(
117
+ cache.capture({ ...request, url: 'https://other.test' })
118
+ ).rejects.toThrow('transport failed');
119
+ expect(await store.list()).toHaveLength(1);
120
+ });
121
+
122
+ test('Unicode header ordering matches the shared Rust replay format', async () => {
123
+ const fixture = await readFile(
124
+ new globalThis.URL(
125
+ '../../../rust/tests/fixtures/capture-v1-unicode.json',
126
+ import.meta.url
127
+ ),
128
+ 'utf8'
129
+ );
130
+ const replay = JSON.parse(fixture);
131
+ await store.import(fixture);
132
+ expect((await store.load(replay.request)).receipt).toEqual(receipt);
133
+ expect(JSON.parse(await store.export(replay.request))).toEqual(replay);
134
+ });
135
+
136
+ test('failed online refresh retains the expired capture for offline replay', async () => {
137
+ await store.store(request, receipt, 800);
138
+ const cache = new CachedTransport(
139
+ async () => {
140
+ throw new Error('offline upstream');
141
+ },
142
+ store,
143
+ { ttl: 100, now: () => 1000 }
144
+ );
145
+ await expect(cache.capture(request)).rejects.toThrow('offline upstream');
146
+ const offline = new CachedTransport(null, store, {
147
+ offline: true,
148
+ ttl: 100,
149
+ now: () => 1000,
150
+ });
151
+ expect(await offline.capture(request)).toMatchObject({
152
+ stale: true,
153
+ receipt,
154
+ });
155
+ for (const replay of ['{', 'null', '{}']) {
156
+ await expect(store.import(replay)).rejects.toMatchObject({ kind: 'cache' });
157
+ }
158
+ });
159
+
160
+ test('headers are sorted without mutating the caller; TTL zero always refreshes', async () => {
161
+ const a = { ...request, headers: { z: '1', a: '2' } };
162
+ const b = { ...request, headers: { a: '2', z: '1' } };
163
+ await store.store(a, receipt, 1000);
164
+ expect((await store.load(b)).receipt).toEqual(receipt);
165
+ const inner = jest.fn(async () => receipt);
166
+ const cache = new CachedTransport(inner, store, { ttl: 0, now: () => 1000 });
167
+ await cache.capture(a);
168
+ await cache.capture(a);
169
+ expect(inner).toHaveBeenCalledTimes(2);
170
+ });
171
+
172
+ test('caller mutation during a fetch cannot change the stored request identity', async () => {
173
+ const mutable = { ...request, headers: { ...request.headers } };
174
+ const cache = new CachedTransport(async () => {
175
+ mutable.url = 'https://mutated.test';
176
+ mutable.headers.accept = 'text/plain';
177
+ return receipt;
178
+ }, store);
179
+ await cache.capture(mutable);
180
+ expect((await store.load(request)).receipt).toEqual(receipt);
181
+ expect(await store.load(mutable)).toBeNull();
182
+ });
@@ -1,4 +1,4 @@
1
- import nock from 'nock';
1
+ import nock from '../helpers/nock.js';
2
2
  import { jest } from '@jest/globals';
3
3
  import { fetchHtml, fetchHtmlReceipt } from '../../src/lib.js';
4
4
 
@@ -1,4 +1,4 @@
1
- import nock from 'nock';
1
+ import nock from '../helpers/nock.js';
2
2
  import { fetchGoogleDocAsMarkdown } from '../../src/gdocs.js';
3
3
 
4
4
  afterEach(() => {
@@ -28,7 +28,7 @@ import fs from 'fs';
28
28
  import os from 'os';
29
29
  import path from 'path';
30
30
  import { fileURLToPath } from 'url';
31
- import nock from 'nock';
31
+ import nock from '../helpers/nock.js';
32
32
 
33
33
  jest.setTimeout(30000);
34
34
 
@@ -1,4 +1,4 @@
1
- import nock from 'nock';
1
+ import nock from '../helpers/nock.js';
2
2
  import {
3
3
  fetchGithubRepositorySnapshot,
4
4
  formatGithubRepositoryMarkdown,
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import request from 'supertest';
9
- import nock from 'nock';
9
+ import nock from '../helpers/nock.js';
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
12
  import { fileURLToPath } from 'url';
@@ -1,5 +1,5 @@
1
1
  import getPort from 'get-port';
2
- import nock from 'nock';
2
+ import nock from '../helpers/nock.js';
3
3
  import { app } from '../../src/index.js';
4
4
 
5
5
  describe('xpaste.pro HTTP e2e', () => {
@@ -1,5 +1,5 @@
1
1
  import request from 'supertest';
2
- import nock from 'nock';
2
+ import nock from '../helpers/nock.js';
3
3
  import { jest } from '@jest/globals';
4
4
  import unzipper from 'unzipper';
5
5