@link-assistant/web-capture 1.11.2 → 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.
- package/.dockerignore +6 -0
- package/.jscpd.json +4 -5
- package/CHANGELOG.md +6 -0
- package/Dockerfile +7 -5
- package/README.md +22 -4
- package/examples/js/capture_cache.js +12 -0
- package/package.json +49 -37
- package/src/cache.js +313 -0
- package/src/gdocs-model-wait.js +2 -4
- package/src/gdocs.js +1 -1
- package/src/index.js +7 -0
- package/src/lib.js +1 -1
- 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/docker-install.test.js +31 -0
- package/tests/ci/workflow-policy.test.js +16 -0
- package/tests/e2e/process.test.js +2 -1
- package/tests/helpers/nock.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-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/google-drive-image.test.js +1 -1
- package/tests/integration/search-endpoint.test.js +1 -1
- 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/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/.dockerignore
ADDED
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,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 1.11.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/Dockerfile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
FROM node:
|
|
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
|
-
{
|
|
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
|
|
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
|
|
44
|
-
RUN
|
|
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 -- --
|
|
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 -- --
|
|
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 -- --
|
|
528
|
-
GDOCS_INTEGRATION=true npm test -- --
|
|
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.
|
|
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
|
|
15
|
-
"test:e2e:docker": "node --experimental-vm-modules ./node_modules/.bin/jest tests/e2e
|
|
16
|
-
"test:e2e": "
|
|
17
|
-
"test:all": "
|
|
18
|
-
"build": "docker compose down && docker compose build &&
|
|
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": "
|
|
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.
|
|
35
|
+
"@kreuzberg/html-to-markdown-node": "^3.7.2",
|
|
36
36
|
"archiver": "^8.0.0",
|
|
37
|
-
"browser-commander": "
|
|
38
|
-
"cheerio": "^1.
|
|
39
|
-
"docx": "^9.
|
|
40
|
-
"express": "^
|
|
41
|
-
"he": "^
|
|
42
|
-
"iconv-lite": "^0.
|
|
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": "^
|
|
46
|
-
"playwright": "^1.
|
|
47
|
-
"puppeteer": "^
|
|
48
|
-
"puppeteer-core": "^
|
|
49
|
-
"turndown": "^7.
|
|
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": "^
|
|
54
|
-
"@babel/preset-env": "^
|
|
55
|
-
"@changesets/cli": "^
|
|
56
|
-
"@types/archiver": "^
|
|
57
|
-
"babel-jest": "^
|
|
58
|
-
"eslint": "^
|
|
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.
|
|
61
|
-
"get-port": "^7.
|
|
60
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
61
|
+
"get-port": "^7.2.0",
|
|
62
62
|
"husky": "^9.1.7",
|
|
63
|
-
"jest": "^
|
|
64
|
-
"jscpd": "^4.0
|
|
65
|
-
"lint-staged": "^
|
|
66
|
-
"markdown-it": "^
|
|
67
|
-
"nock": "^
|
|
68
|
-
"prettier": "^3.
|
|
69
|
-
"supertest": "^
|
|
70
|
-
"unzipper": "^0.12.
|
|
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.
|
|
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
|
+
}
|
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';
|
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
|
+
});
|
|
@@ -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,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
|
+
);
|
package/tests/mock/index.test.js
CHANGED
|
@@ -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
|
+
});
|
package/tests/unit/gdocs.test.js
CHANGED