@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
@@ -40,7 +40,7 @@ describe('workflow policy', () => {
40
40
  (workflowFile) => {
41
41
  const workflow = readWorkflow(workflowFile);
42
42
 
43
- expect(workflow).toContain('uses: actions/checkout@v6');
43
+ expect(workflow).toContain('uses: actions/checkout@v7.0.1');
44
44
  expect(workflow).not.toContain('uses: actions/checkout@v4');
45
45
  }
46
46
  );
@@ -48,7 +48,7 @@ describe('workflow policy', () => {
48
48
  test('Rust workflow uses the current cache action', () => {
49
49
  const workflow = readWorkflow('.github/workflows/rust.yml');
50
50
 
51
- expect(workflow).toContain('uses: actions/cache@v5');
51
+ expect(workflow).toContain('uses: actions/cache@v6.1.0');
52
52
  expect(workflow).not.toContain('uses: actions/cache@v4');
53
53
  });
54
54
 
@@ -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;
@@ -0,0 +1,10 @@
1
+ import { expect } from '@jest/globals';
2
+
3
+ // PNG magic number (89 50 4E 47 0D 0A 1A 0A).
4
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5
+
6
+ export function expectPngScreenshot(screenshot, minimumLength = 1000) {
7
+ expect(screenshot).toBeInstanceOf(Uint8Array);
8
+ expect(screenshot.length).toBeGreaterThan(minimumLength);
9
+ expect(Buffer.from(screenshot).subarray(0, 8)).toEqual(PNG_SIGNATURE);
10
+ }
@@ -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';
@@ -1,11 +1,12 @@
1
1
  import { createBrowser } from '../../src/browser.js';
2
+ import { expectPngScreenshot } from '../helpers/screenshot.js';
2
3
 
3
4
  describe('Browser Engine Integration Tests', () => {
4
- describe('Puppeteer Engine', () => {
5
+ describe.each(['puppeteer', 'playwright'])('%s Engine', (engine) => {
5
6
  let browser;
6
7
 
7
8
  beforeEach(async () => {
8
- browser = await createBrowser('puppeteer');
9
+ browser = await createBrowser(engine);
9
10
  });
10
11
 
11
12
  afterEach(async () => {
@@ -32,56 +33,7 @@ describe('Browser Engine Integration Tests', () => {
32
33
  timeout: 30000,
33
34
  });
34
35
  const screenshot = await page.screenshot({ type: 'png' });
35
- expect(screenshot).toBeInstanceOf(Buffer);
36
- expect(screenshot.length).toBeGreaterThan(100);
37
- }, 60000);
38
-
39
- it('can set custom headers and user agent', async () => {
40
- const page = await browser.newPage();
41
- await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US' });
42
- await page.setUserAgent('Test User Agent');
43
- await page.goto('https://example.com', {
44
- waitUntil: 'networkidle0',
45
- timeout: 30000,
46
- });
47
- const content = await page.content();
48
- expect(content).toBeTruthy();
49
- }, 60000);
50
- });
51
-
52
- describe('Playwright Engine', () => {
53
- let browser;
54
-
55
- beforeEach(async () => {
56
- browser = await createBrowser('playwright');
57
- });
58
-
59
- afterEach(async () => {
60
- if (browser) {
61
- await browser.close();
62
- }
63
- });
64
-
65
- it('can navigate to a page and get content', async () => {
66
- const page = await browser.newPage();
67
- await page.goto('https://example.com', {
68
- waitUntil: 'networkidle0',
69
- timeout: 30000,
70
- });
71
- const content = await page.content();
72
- expect(content).toContain('Example Domain');
73
- }, 60000);
74
-
75
- it('can take a screenshot', async () => {
76
- const page = await browser.newPage();
77
- await page.setViewport({ width: 1280, height: 800 });
78
- await page.goto('https://example.com', {
79
- waitUntil: 'networkidle0',
80
- timeout: 30000,
81
- });
82
- const screenshot = await page.screenshot({ type: 'png' });
83
- expect(screenshot).toBeInstanceOf(Buffer);
84
- expect(screenshot.length).toBeGreaterThan(100);
36
+ expectPngScreenshot(screenshot, 100);
85
37
  }, 60000);
86
38
 
87
39
  it('can set custom headers and user agent', async () => {
@@ -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
 
@@ -12,6 +12,7 @@ import { jest } from '@jest/globals';
12
12
  import { createBrowser } from '../../src/browser.js';
13
13
  import { fetchHtml } from '../../src/lib.js';
14
14
  import { retry } from '../../src/retry.js';
15
+ import { expectPngScreenshot } from '../helpers/screenshot.js';
15
16
  import {
16
17
  fetchGithubRepositorySnapshot,
17
18
  formatGithubRepositoryMarkdown,
@@ -22,9 +23,6 @@ import {
22
23
  const GITHUB_REPOSITORY_URL =
23
24
  process.env.GITHUB_REPOSITORY_URL ||
24
25
  'https://github.com/link-assistant/web-capture';
25
- const PNG_SIGNATURE = Buffer.from([
26
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
27
- ]);
28
26
 
29
27
  const SKIP_LIVE =
30
28
  !process.env.GITHUB_REPOSITORY_INTEGRATION ||
@@ -128,9 +126,7 @@ describe.each(['puppeteer', 'playwright'])(
128
126
 
129
127
  const screenshot = await page.screenshot({ type: 'png' });
130
128
 
131
- expect(screenshot).toBeInstanceOf(Buffer);
132
- expect(screenshot.length).toBeGreaterThan(1000);
133
- expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
129
+ expectPngScreenshot(screenshot);
134
130
  });
135
131
  });
136
132
  }
@@ -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 = {
@@ -19,14 +19,10 @@ import { jest } from '@jest/globals';
19
19
  import { createBrowser } from '../../src/browser.js';
20
20
  import { convertHtmlToMarkdown } from '../../src/lib.js';
21
21
  import { retry } from '../../src/retry.js';
22
+ import { expectPngScreenshot } from '../helpers/screenshot.js';
22
23
 
23
24
  const WIKIPEDIA_URL = 'https://en.wikipedia.org/wiki/Wikipedia';
24
25
 
25
- // PNG magic number (89 50 4E 47 0D 0A 1A 0A).
26
- const PNG_SIGNATURE = Buffer.from([
27
- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
28
- ]);
29
-
30
26
  const SKIP_LIVE =
31
27
  !process.env.WIKIPEDIA_INTEGRATION ||
32
28
  process.env.WIKIPEDIA_INTEGRATION === 'false';
@@ -99,10 +95,7 @@ describe.each(['puppeteer', 'playwright'])(
99
95
  it('downloads the Wikipedia page as an image (PNG screenshot)', async () => {
100
96
  const screenshot = await page.screenshot({ type: 'png' });
101
97
 
102
- expect(screenshot).toBeInstanceOf(Buffer);
103
- expect(screenshot.length).toBeGreaterThan(1000);
104
- // Verify the PNG signature so we know it is a real image.
105
- expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
98
+ expectPngScreenshot(screenshot);
106
99
  });
107
100
  });
108
101
  }
@@ -135,8 +128,7 @@ describeIfLive('Wikipedia Page Download (engine parity)', () => {
135
128
  expect(markdown).toContain('Wikipedia');
136
129
  expect(markdown.length).toBeGreaterThan(500);
137
130
  // Image captured from both engines.
138
- expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
139
- expect(screenshot.length).toBeGreaterThan(1000);
131
+ expectPngScreenshot(screenshot);
140
132
  }
141
133
  }, 180000);
142
134
  });
@@ -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,
@@ -1,23 +1,20 @@
1
1
  import { jest } from '@jest/globals';
2
2
 
3
- const originalFetch = global.fetch;
3
+ jest.unstable_mockModule('execa', () => ({
4
+ $: () => async () => ({ exitCode: 0, stdout: '', stderr: '' }),
5
+ }));
6
+ jest.unstable_mockModule('lino-arguments', () => ({
7
+ makeConfig: () => ({ shouldPull: false, jsRoot: 'js' }),
8
+ }));
4
9
 
5
10
  beforeAll(() => {
6
- global.fetch = jest.fn().mockResolvedValue({
7
- text: async () => `
8
- ({
9
- use: async (name) => {
10
- if (name === 'command-stream') return { $: () => ({ run: async () => ({ code: 0, stdout: '', stderr: '' }) }) };
11
- if (name === 'lino-arguments') return { makeConfig: () => ({ shouldPull: false, jsRoot: 'js' }) };
12
- throw new Error('Unexpected dynamic import: ' + name);
13
- }
14
- })
15
- `,
16
- });
11
+ jest
12
+ .spyOn(global, 'fetch')
13
+ .mockRejectedValue(new Error('unexpected remote code download'));
17
14
  });
18
15
 
19
16
  afterAll(() => {
20
- global.fetch = originalFetch;
17
+ global.fetch.mockRestore();
21
18
  });
22
19
 
23
20
  describe('publish verification', () => {
@@ -28,17 +25,17 @@ describe('publish verification', () => {
28
25
  const runVerify = jest
29
26
  .fn()
30
27
  .mockResolvedValueOnce({
31
- code: 1,
28
+ exitCode: 1,
32
29
  stdout: '',
33
30
  stderr: 'npm error code E404',
34
31
  })
35
32
  .mockResolvedValueOnce({
36
- code: 1,
33
+ exitCode: 1,
37
34
  stdout: '',
38
35
  stderr: 'npm error code E404',
39
36
  })
40
37
  .mockResolvedValueOnce({
41
- code: 0,
38
+ exitCode: 0,
42
39
  stdout: '1.7.1\n',
43
40
  stderr: '',
44
41
  });