@link-assistant/web-capture 1.11.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +1 -1
- package/.dockerignore +6 -0
- package/.jscpd.json +4 -5
- package/CHANGELOG.md +12 -0
- package/Dockerfile +13 -11
- package/README.md +23 -4
- package/bin/web-capture.js +3 -2
- package/examples/js/capture_cache.js +12 -0
- package/examples/js/playwright_example.js +1 -1
- package/examples/python/capture_client.py +20 -0
- package/examples/python/html_download.py +2 -14
- package/examples/python/image_download.py +3 -7
- package/examples/python/markdown_download.py +2 -14
- package/examples/python/playwright_example.py +6 -6
- package/jest.config.mjs +1 -4
- package/package.json +54 -39
- package/src/archive.js +1 -1
- package/src/binary-download.js +14 -0
- package/src/cache.js +313 -0
- package/src/docx.js +1 -1
- package/src/fetch.js +1 -1
- package/src/figures.js +2 -12
- package/src/gdocs-model-wait.js +2 -4
- package/src/gdocs.js +1 -1
- package/src/index.js +7 -0
- package/src/lib.js +2 -2
- package/src/localize-images.js +2 -12
- package/src/search.js +1 -1
- package/src/shared-dialog.js +1 -1
- package/src/transport.js +2 -2
- package/tests/ci/dependency-freshness.test.js +74 -0
- package/tests/ci/detect-code-changes.test.js +36 -23
- package/tests/ci/docker-install.test.js +31 -0
- package/tests/ci/workflow-policy.test.js +18 -2
- package/tests/e2e/process.test.js +2 -1
- package/tests/helpers/nock.js +10 -0
- package/tests/helpers/screenshot.js +10 -0
- package/tests/helpers/search-fixture.cjs +1 -1
- package/tests/helpers/temporary-directory.js +12 -0
- package/tests/integration/api-endpoints.test.js +1 -1
- package/tests/integration/browser-engines.test.js +4 -52
- package/tests/integration/browser-packaged-path.test.js +45 -0
- package/tests/integration/deepwiki-rendered-page.mock.test.js +1 -1
- package/tests/integration/formalai-contract.test.js +1 -1
- package/tests/integration/github-readme.test.js +2 -6
- package/tests/integration/google-drive-image.test.js +1 -1
- package/tests/integration/search-endpoint.test.js +1 -1
- package/tests/integration/wikipedia-download.test.js +3 -11
- package/tests/mock/index.test.js +1 -1
- package/tests/unit/browser-launch-options.test.js +42 -0
- package/tests/unit/cache.test.js +182 -0
- package/tests/unit/fetch-html.test.js +1 -1
- package/tests/unit/gdocs-export-headers.test.js +1 -1
- package/tests/unit/gdocs.test.js +1 -1
- package/tests/unit/github.test.js +1 -1
- package/tests/unit/publish-verification.test.js +13 -16
- package/tests/unit/stream.test.js +8 -10
- package/tests/xpaste/xpaste-screenshot-markdown.mock.test.js +1 -1
- package/tests/xpaste/xpaste.e2e.test.js +1 -1
- package/tests/xpaste/xpaste.mock.test.js +1 -1
- package/babel.config.cjs +0 -13
|
@@ -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@
|
|
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@
|
|
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 { 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
|
+
}
|
|
@@ -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('
|
|
5
|
+
describe.each(['puppeteer', 'playwright'])('%s Engine', (engine) => {
|
|
5
6
|
let browser;
|
|
6
7
|
|
|
7
8
|
beforeEach(async () => {
|
|
8
|
-
browser = await createBrowser(
|
|
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
|
-
|
|
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
|
+
);
|
|
@@ -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
|
-
|
|
132
|
-
expect(screenshot.length).toBeGreaterThan(1000);
|
|
133
|
-
expect(screenshot.slice(0, 8)).toEqual(PNG_SIGNATURE);
|
|
129
|
+
expectPngScreenshot(screenshot);
|
|
134
130
|
});
|
|
135
131
|
});
|
|
136
132
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
139
|
-
expect(screenshot.length).toBeGreaterThan(1000);
|
|
131
|
+
expectPngScreenshot(screenshot);
|
|
140
132
|
}
|
|
141
133
|
}, 180000);
|
|
142
134
|
});
|
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
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import { jest } from '@jest/globals';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
28
|
+
exitCode: 1,
|
|
32
29
|
stdout: '',
|
|
33
30
|
stderr: 'npm error code E404',
|
|
34
31
|
})
|
|
35
32
|
.mockResolvedValueOnce({
|
|
36
|
-
|
|
33
|
+
exitCode: 1,
|
|
37
34
|
stdout: '',
|
|
38
35
|
stderr: 'npm error code E404',
|
|
39
36
|
})
|
|
40
37
|
.mockResolvedValueOnce({
|
|
41
|
-
|
|
38
|
+
exitCode: 0,
|
|
42
39
|
stdout: '1.7.1\n',
|
|
43
40
|
stderr: '',
|
|
44
41
|
});
|