diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml
new file mode 100644
index 00000000..116b11d3
--- /dev/null
+++ b/.qlty/qlty.toml
@@ -0,0 +1,10 @@
+config_version = "0"
+
+exclude_patterns = [
+ "test/**",
+]
+
+[coverage]
+ignores = [
+ "benchmark/**",
+]
diff --git a/MIGRATION.md b/MIGRATION.md
index da131331..000813ec 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -14,7 +14,67 @@ Minimal supported Node.js version is 22.12.0.
* `http2-wrapper` and `cacheable-lookup` were replaced with built-in implementations (`agent.http2`, `dnsCache` options changed)
* credentials are stripped when a hook redirects request to a different origin
-`response.body` passed to `afterResponse` action is still a `Buffer` (with default `responseType: 'buffer'`), but `response.rawBody` is now a `Uint8Array`.
+#### Streaming resources to storage
+Version 7 stops storing resource content in memory ([#386](https://github.com/website-scraper/node-website-scraper/issues/386)). Resources which need no modification (images, fonts, media, scripts - everything except html and css) are streamed directly from the network to storage, and html/css content is freed as soon as the resource is saved. Memory usage no longer grows with the size of the scraped website.
+
+#### saveResource action
+
+Resource content is now exposed as a Readable stream via `resource.getContentStream()` instead of `resource.getText()`. For streamed resources the stream can be consumed only once - consume or destroy it within the action. `getContentStream()` hands such a stream out only once, a second call throws.
+
+```javascript
+// before
+registerAction('saveResource', async ({resource}) => {
+ await saveItSomewhere(resource.getFilename(), resource.getText());
+});
+
+// after
+import { buffer } from 'stream/consumers';
+registerAction('saveResource', async ({resource}) => {
+ await saveItSomewhere(resource.getFilename(), await buffer(resource.getContentStream()));
+});
+// or pipe resource.getContentStream() to your storage to avoid buffering
+```
+
+`saveResource` actions are no longer called one resource at a time: streamed resources are saved as their responses arrive, concurrently. If your action writes to a shared sink which is not concurrency-safe (an archive, a single file, a serial connection) - make it concurrency-safe or set `requestConcurrency: 1`.
+
+Note: when more than one `saveResource` action is registered, resource content is buffered in memory once so that every action can read it - memory usage is then again bounded by resource size for those resources.
+
+#### afterResponse action
+
+The action is called when response headers arrive, before the body is downloaded. It receives `{response}` where response is `{url, statusCode, headers, getBody()}` instead of the full got response. Returning a string is not supported anymore - return an object or null. Return an object without `body` to keep the resource streaming (no buffering).
+
+Other contract changes:
+
+* When a returned string `body` has no `encoding`, it is decoded with the encoding derived from the response `content-type` header (`utf8` if it contains utf-8, case-insensitive, `binary` otherwise). Previously `binary` was always the default - pass `encoding: 'binary'` explicitly to keep the old behavior.
+
+```javascript
+// before
+registerAction('afterResponse', ({response}) => {
+ if (response.statusCode === 404) return null;
+ return { body: response.body, metadata: { headers: response.headers } };
+});
+
+// after
+registerAction('afterResponse', ({response}) => {
+ if (response.statusCode === 404) return null;
+ return { metadata: { headers: response.headers } };
+});
+// call `await response.getBody()` only if you need to inspect or replace the body
+```
+
+#### scrape() result
+
+Resource content is not retained after save: `resource.getText()` returns `null` in the result and in `onResourceSaved`. Read saved files from your storage instead.
+
+#### generateFilename action
+
+`responseData` no longer contains `body` - filenames are generated from headers (`url`, `statusCode`, `mimeType`, `encoding`, `metadata`) before the body is consumed.
+
+#### Other behavior changes
+
+* Binary files are written byte-for-byte from the network (previously the content was round-tripped through a latin1 string). Output for correctly-served files is unchanged; edge-case encodings may differ.
+* Transient request errors are retried as before. For streamed resources (everything except html and css) retries happen only until response headers are received - a connection which dies mid-body is not retried, since the body is already being written to storage.
+* `responseType` in the `request` option is ignored (responses are streamed).
## From version 4 to 5
diff --git a/README.md b/README.md
index 65578d5f..71b8ed17 100644
--- a/README.md
+++ b/README.md
@@ -119,7 +119,7 @@ The difference between [maxRecursiveDepth](#maxRecursiveDepth) and [maxDepth](#m
only html resources with depth 2 will be filtered out, last image will be downloaded
#### request
-Object, custom options for http module [got](https://github.com/sindresorhus/got#options) which is used inside website-scraper. Allows to set retries, cookies, userAgent, encoding, etc.
+Object, custom options for http module [got](https://github.com/sindresorhus/got#options) which is used inside website-scraper. Allows to set retries, cookies, userAgent, encoding, etc. Note that `responseType` is controlled by the scraper (responses are streamed) and is ignored if passed here.
```javascript
// use same request options for all resources
scrape({
@@ -244,7 +244,7 @@ class MyPlugin {
registerAction('afterFinish', async () => {});
registerAction('error', async ({error}) => {console.error(error)});
registerAction('beforeRequest', async ({resource, requestOptions}) => ({requestOptions}));
- registerAction('afterResponse', async ({response}) => response.body);
+ registerAction('afterResponse', async ({response}) => ({metadata: {headers: response.headers}}));
registerAction('onResourceSaved', ({resource}) => {});
registerAction('onResourceError', ({resource, error}) => {});
registerAction('saveResource', async ({resource}) => {});
@@ -315,15 +315,19 @@ registerAction('beforeRequest', async ({resource, requestOptions}) => {
```
##### afterResponse
-Action afterResponse is called after each response, it allows to customize resource or reject its saving.
+Action afterResponse is called after response headers were received, before the body is consumed. It allows to customize resource or reject its saving.
Parameters - object which includes:
-* response - response object from http module [got](https://github.com/sindresorhus/got#response)
+* response - object which includes:
+ * `url` (string) - final url of the response (after redirects)
+ * `statusCode` (number)
+ * `headers` (object)
+ * `getBody()` - async function which buffers the response body and resolves with a `Buffer`. Note that calling it loads the whole body into memory - don't call it if you only need headers or status code, so the resource keeps streaming to storage
Return resolved `Promise` with:
- * object if the resource should be saved, object should contain next properties:
- * `body` (string, required)
- * `encoding` (`binary` or `utf8`) is used to save the file, binary is used by default.
+ * object if the resource should be saved, object may contain next properties:
+ * `body` (string or Buffer, optional) - replaces resource content; if omitted, the original body is used without buffering it in memory. `null` body skips the resource, same as returning `null`
+ * `encoding` (`binary` or `utf8`) is used to decode a string `body`; defaults to the encoding derived from the response `content-type` header (`utf8` if it contains utf-8, case-insensitive, `binary` otherwise)
* `metadata` (object) - everything you want to save for this resource (like headers, original text, timestamps, etc.), scraper will not use this field at all, it is only for the result
* or null if the resource should be skipped
@@ -335,8 +339,6 @@ registerAction('afterResponse', ({response}) => {
return null;
} else {
return {
- body: response.body,
- encoding: 'utf8',
metadata: {
headers: response.headers,
someOtherData: [ 1, 2, 3 ]
@@ -344,6 +346,15 @@ registerAction('afterResponse', ({response}) => {
}
}
});
+
+// Modify html body before it is processed
+registerAction('afterResponse', async ({response}) => {
+ if (response.headers['content-type']?.includes('text/html')) {
+ const body = await response.getBody();
+ return { body: transformHtml(body.toString()), encoding: 'utf8' };
+ }
+ return {}; // everything else keeps streaming to storage
+});
```
##### onResourceSaved
@@ -374,7 +385,7 @@ Action generateFilename is called to determine path in file system where the res
Parameters - object which includes:
* resource - [Resource](https://github.com/website-scraper/node-website-scraper/blob/master/lib/resource.js) object
-* responseData - object returned from afterResponse action, contains `url`, `mimeType`, `body`, `metadata` properties
+* responseData - object which contains `url`, `statusCode`, `mimeType`, `encoding`, `metadata` properties. Note that it does not contain the response body - filenames are generated before the body is consumed
Should return object which includes:
* filename - String, relative to `directory` path for specified resource
@@ -419,12 +430,23 @@ Action saveResource is called to save file to some storage. Use it to save files
Parameters - object which includes:
* resource - [Resource](https://github.com/website-scraper/node-website-scraper/blob/master/lib/resource.js) object
-If multiple actions `saveResource` added - resource will be saved to multiple storages.
+Resource content is exposed as a Readable stream returned by `resource.getContentStream()`. For resources which are streamed directly from the network (all types except html and css) the stream can be consumed only once - consume it (or destroy it) within the action. `getContentStream()` hands such a stream out only once, a second call throws - buffer the content yourself if you need it more than once.
+
+If multiple actions `saveResource` added - resource will be saved to multiple storages (the scraper buffers streamed content in that case so every action can read it).
```javascript
+import { pipeline } from 'stream/promises';
+
registerAction('saveResource', async ({resource}) => {
const filename = resource.getFilename();
- const text = resource.getText();
- await saveItSomewhere(filename, text);
+ await pipeline(resource.getContentStream(), createMyStorageWriteStream(filename));
+});
+
+// or, if your storage needs the whole content at once
+import { buffer } from 'stream/consumers';
+
+registerAction('saveResource', async ({resource}) => {
+ const content = await buffer(resource.getContentStream());
+ await saveItSomewhere(resource.getFilename(), content);
});
```
@@ -434,6 +456,8 @@ Array of [Resource](https://github.com/website-scraper/node-website-scraper/blob
- `filename`: filename where page was saved (relative to `directory`)
- `children`: array of children Resources
+Note that resource content is not retained in memory after the resource was saved - read the saved files from your storage if you need the content.
+
## Log and debug
This module uses [debug](https://github.com/visionmedia/debug) to log events. To enable logs you should use environment variable `DEBUG`.
The next command will log everything from website-scraper
diff --git a/benchmark/memory.mjs b/benchmark/memory.mjs
new file mode 100644
index 00000000..2c7616e3
--- /dev/null
+++ b/benchmark/memory.mjs
@@ -0,0 +1,91 @@
+/*
+ * Memory benchmark for issue #386.
+ *
+ * Starts a local http server serving an html page referencing FILES_COUNT
+ * binary files of FILE_SIZE_MB each (generated chunk-wise on the fly, so the
+ * server itself stays flat), scrapes it and reports peak memory usage.
+ *
+ * Usage:
+ * npm run benchmark:memory
+ * FILES_COUNT=50 FILE_SIZE_MB=10 REQUEST_CONCURRENCY=8 npm run benchmark:memory
+ */
+
+import http from 'http';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import scrape from 'website-scraper';
+
+const FILES_COUNT = parseInt(process.env.FILES_COUNT || '100', 10);
+const FILE_SIZE_MB = parseInt(process.env.FILE_SIZE_MB || '20', 10);
+const REQUEST_CONCURRENCY = parseInt(process.env.REQUEST_CONCURRENCY || '8', 10);
+
+const CHUNK = Buffer.alloc(64 * 1024, 0xab);
+const CHUNKS_PER_FILE = Math.ceil(FILE_SIZE_MB * 1024 * 1024 / CHUNK.length);
+
+function startServer () {
+ const server = http.createServer((req, res) => {
+ if (req.url === '/') {
+ const imgs = Array.from({length: FILES_COUNT}, (_, i) => ``).join('\n');
+ res.writeHead(200, {'content-type': 'text/html'});
+ res.end(`
', {'content-type': 'text/html'});
+ nock('http://example.com/').get('/a.png').reply(200, pngBody, {'content-type': 'image/png'});
+
+ await scrape({
+ urls: ['http://example.com/'],
+ directory: testDirname
+ });
+
+ const savedBytes = fs.readFileSync(testDirname + '/images/a.png');
+ savedBytes.equals(pngBody).should.be.eql(true);
+ });
+
+ it('should free resource content from memory after save', async function () {
+ nock('http://example.com/').get('/').reply(200, '
', {'content-type': 'text/html'});
+ nock('http://example.com/').get('/a.png').reply(200, 'image content', {'content-type': 'image/png'});
+
+ const result = await scrape({
+ urls: ['http://example.com/'],
+ directory: testDirname
+ });
+
+ (result[0].getText() === null).should.be.true;
+ should.not.exist(result[0].getContentStream());
+
+ const child = result[0].children.find((c) => c.url === 'http://example.com/a.png');
+ (child.getText() === null).should.be.true;
+ should.not.exist(child.getContentStream());
+
+ // content is on disk instead
+ fs.readFileSync(testDirname + '/images/a.png').toString().should.be.eql('image content');
+ });
+
+ it('should save body returned from afterResponse (buffered path)', async function () {
+ nock('http://example.com/').get('/a.png').reply(200, 'original content', {'content-type': 'image/png'});
+
+ class ReplaceBodyPlugin {
+ apply (add) {
+ add('afterResponse', async ({response}) => {
+ const body = await response.getBody();
+ return { body: Buffer.concat([body, Buffer.from(' + replaced')]) };
+ });
+ }
+ }
+
+ await scrape({
+ urls: [{url: 'http://example.com/a.png', filename: 'a.png'}],
+ directory: testDirname,
+ plugins: [new ReplaceBodyPlugin()]
+ });
+
+ fs.readFileSync(testDirname + '/images/a.png').toString().should.be.eql('original content + replaced');
+ });
+
+ it('should keep resource streaming and attach metadata when afterResponse returns no body', async function () {
+ const pngBody = generateBigBinary(1024 * 1024);
+ nock('http://example.com/').get('/a.png').reply(200, pngBody, {'content-type': 'image/png'});
+
+ class MetadataPlugin {
+ apply (add) {
+ add('afterResponse', ({response}) => {
+ return { metadata: { statusCode: response.statusCode } };
+ });
+ }
+ }
+
+ const result = await scrape({
+ urls: [{url: 'http://example.com/a.png', filename: 'a.png'}],
+ directory: testDirname,
+ plugins: [new MetadataPlugin()]
+ });
+
+ result[0].metadata.should.be.eql({statusCode: 200});
+ fs.readFileSync(testDirname + '/images/a.png').equals(pngBody).should.be.eql(true);
+ });
+
+ it('should provide full content to multiple saveResource actions', async function () {
+ const pngBody = generateBigBinary(1024 * 1024);
+ nock('http://example.com/').get('/a.png').reply(200, pngBody, {'content-type': 'image/png'});
+
+ const saved = [];
+ class MultiStoragePlugin {
+ apply (add) {
+ add('saveResource', async ({resource}) => {
+ saved.push(await readStreamBuffer(resource.getContentStream()));
+ });
+ add('saveResource', async ({resource}) => {
+ saved.push(await readStreamBuffer(resource.getContentStream()));
+ });
+ }
+ }
+
+ await scrape({
+ urls: [{url: 'http://example.com/a.png', filename: 'a.png'}],
+ directory: testDirname,
+ plugins: [new MultiStoragePlugin()]
+ });
+
+ saved.should.have.length(2);
+ saved[0].equals(pngBody).should.be.eql(true);
+ saved[1].equals(pngBody).should.be.eql(true);
+ });
+
+ it('should complete scraping when saveResource action does not consume the stream', async function () {
+ nock('http://example.com/').get('/a.png').reply(200, generateBigBinary(1024 * 1024), {'content-type': 'image/png'});
+
+ const seenResources = [];
+ class IgnoreContentPlugin {
+ apply (add) {
+ add('saveResource', async ({resource}) => {
+ seenResources.push(resource.url);
+ });
+ }
+ }
+
+ await scrape({
+ urls: [{url: 'http://example.com/a.png', filename: 'a.png'}],
+ directory: testDirname,
+ plugins: [new IgnoreContentPlugin()]
+ });
+
+ seenResources.should.be.eql(['http://example.com/a.png']);
+ });
+
+ it('should reject the scrape when saving a streamed child resource fails and ignoreErrors is false', async function () {
+ nock('http://example.com/').get('/').reply(200, '
', {'content-type': 'text/html'});
+ nock('http://example.com/').get('/a.png').reply(200, 'image content', {'content-type': 'image/png'});
+
+ const errors = [];
+ class FailingStoragePlugin {
+ apply (add) {
+ add('saveResource', async ({resource}) => {
+ if (resource.getFilename().endsWith('.png')) {
+ throw new Error('SAVE FAILED');
+ }
+ });
+ add('onResourceError', ({error}) => errors.push(error.message));
+ }
+ }
+
+ try {
+ await scrape({
+ urls: ['http://example.com/'],
+ directory: testDirname,
+ ignoreErrors: false,
+ plugins: [new FailingStoragePlugin()]
+ });
+ throw new Error('expected scrape to reject');
+ } catch (err) {
+ err.message.should.be.eql('SAVE FAILED');
+ }
+ errors.should.be.eql(['SAVE FAILED']); // handled exactly once
+ });
+
+ // nock cannot simulate a connection dying mid-body, so these tests use a real local server
+ describe('mid-stream failures', function () {
+ let server, baseUrl;
+
+ beforeEach(function (done) {
+ nock.enableNetConnect(/127\.0\.0\.1|localhost/);
+ server = http.createServer((req, res) => {
+ switch (req.url) {
+ case '/':
+ res.writeHead(200, {'content-type': 'text/html'});
+ res.end('
');
+ break;
+ case '/fail.png':
+ res.writeHead(200, {'content-type': 'image/png'});
+ res.write('partial data');
+ setTimeout(() => res.destroy(), 10);
+ break;
+ default:
+ res.writeHead(200, {'content-type': 'image/png'});
+ res.end('fine content');
+ }
+ });
+ server.listen(0, '127.0.0.1', () => {
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
+ done();
+ });
+ });
+
+ afterEach(function (done) {
+ server.close(done);
+ });
+
+ it('should continue scraping and leave no partial file when child fails mid-stream with ignoreErrors', async function () {
+ await scrape({
+ urls: [baseUrl + '/'],
+ directory: testDirname,
+ request: {retry: {limit: 0}},
+ ignoreErrors: true
+ });
+
+ fs.existsSync(testDirname + '/index.html').should.be.eql(true);
+ fs.existsSync(testDirname + '/images/fail.png').should.be.eql(false); // failed, partial file removed
+ fs.readFileSync(testDirname + '/images/fine.png').toString().should.be.eql('fine content');
+ });
+
+ it('should continue scraping when a child fails mid-stream without ignoreErrors (same as a failed child request)', async function () {
+ const errors = [];
+ class ErrorCollectorPlugin {
+ apply (add) {
+ add('onResourceError', ({resource, error}) => errors.push({url: resource.getUrl(), message: error.message}));
+ }
+ }
+
+ const result = await scrape({
+ urls: [baseUrl + '/'],
+ directory: testDirname,
+ request: {retry: {limit: 0}},
+ ignoreErrors: false,
+ plugins: [new ErrorCollectorPlugin()]
+ });
+
+ result.should.have.length(1);
+ fs.existsSync(testDirname + '/index.html').should.be.eql(true);
+ fs.existsSync(testDirname + '/images/fail.png').should.be.eql(false); // failed, partial file removed
+ fs.readFileSync(testDirname + '/images/fine.png').toString().should.be.eql('fine content');
+ errors.should.have.length(1); // handled exactly once
+ errors[0].url.should.be.eql(baseUrl + '/fail.png');
+ });
+
+ it('should fail the save instead of hanging when a saveResource action does not propagate stream errors', async function () {
+ const errors = [];
+ // replaces the default fs saver, so it is the only saveResource action and receives the live stream
+ class PipeWithoutErrorHandlingPlugin {
+ apply (add) {
+ add('beforeStart', () => fs.mkdirSync(testDirname, {recursive: true}));
+ add('saveResource', ({resource}) => new Promise((resolve) => {
+ const ws = fs.createWriteStream(path.join(testDirname, path.basename(resource.getFilename())));
+ // .pipe() does not forward source errors - 'finish' never fires if the body dies
+ resource.getContentStream().pipe(ws).on('finish', resolve);
+ }));
+ add('onResourceError', ({resource, error}) => errors.push({url: resource.getUrl(), message: error.message}));
+ }
+ }
+
+ const result = await scrape({
+ urls: [baseUrl + '/'],
+ request: {retry: {limit: 0}},
+ ignoreErrors: true,
+ plugins: [new PipeWithoutErrorHandlingPlugin()]
+ });
+
+ result.should.have.length(1);
+ fs.readFileSync(testDirname + '/fine.png').toString().should.be.eql('fine content');
+ errors.should.have.length(1);
+ errors[0].url.should.be.eql(baseUrl + '/fail.png');
+ });
+
+ it('should reject and remove directory when a root resource fails mid-stream without ignoreErrors', async function () {
+ try {
+ await scrape({
+ urls: [
+ {url: baseUrl + '/fine.png', filename: 'fine.png'},
+ {url: baseUrl + '/fail.png', filename: 'fail.png'}
+ ],
+ directory: testDirname,
+ request: {retry: {limit: 0}},
+ ignoreErrors: false
+ });
+ throw new Error('expected scrape to reject');
+ } catch (err) {
+ err.message.should.not.be.eql('expected scrape to reject');
+ }
+
+ fs.existsSync(testDirname).should.be.eql(false);
+ });
+ });
+
+ describe('mid-body retries', function () {
+ let server, baseUrl, attempts;
+
+ beforeEach(function (done) {
+ nock.enableNetConnect(/127\.0\.0\.1|localhost/);
+ attempts = {};
+ server = http.createServer((req, res) => {
+ attempts[req.url] = (attempts[req.url] || 0) + 1;
+ const isHtml = req.url === '/';
+ res.writeHead(200, {'content-type': isHtml ? 'text/html' : 'image/png'});
+ if (attempts[req.url] === 1) {
+ res.write(isHtml ? '' : 'partial');
+ setTimeout(() => res.destroy(), 10);
+ } else {
+ res.end(isHtml ? '
' : 'image content');
+ }
+ });
+ server.listen(0, '127.0.0.1', () => {
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
+ done();
+ });
+ });
+
+ afterEach(function (done) {
+ server.close(done);
+ });
+
+ it('should retry html which fails mid-body, but not a streamed resource', async function () {
+ const errors = [];
+ class ErrorCollectorPlugin {
+ apply (add) {
+ add('onResourceError', ({resource}) => errors.push(resource.getUrl()));
+ }
+ }
+
+ await scrape({
+ urls: [baseUrl + '/'],
+ directory: testDirname,
+ request: {retry: {limit: 1}},
+ ignoreErrors: false,
+ plugins: [new ErrorCollectorPlugin()]
+ });
+
+ attempts['/'].should.be.eql(2); // html body retried and buffered from the 2nd attempt
+ fs.readFileSync(testDirname + '/index.html').toString().should.contain('
');
+ attempts['/a.png'].should.be.eql(1); // streamed to storage - no mid-body retry
+ fs.existsSync(testDirname + '/images/a.png').should.be.eql(false);
+ errors.should.be.eql([baseUrl + '/a.png']);
+ });
+ });
+
+ describe('stream cleanup when an action fails', function () {
+ let server, baseUrl, pngSocket;
+
+ beforeEach(function (done) {
+ nock.enableNetConnect(/127\.0\.0\.1|localhost/);
+ server = http.createServer((req, res) => {
+ if (req.url === '/a.png') {
+ // never ends the body - the socket stays open unless the client destroys it
+ pngSocket = req.socket;
+ res.writeHead(200, {'content-type': 'image/png'});
+ res.write('partial data');
+ } else {
+ res.writeHead(200, {'content-type': 'text/html'});
+ res.end('
');
+ }
+ });
+ server.listen(0, '127.0.0.1', () => {
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
+ done();
+ });
+ });
+
+ afterEach(function (done) {
+ if (pngSocket && !pngSocket.destroyed) {
+ pngSocket.destroy();
+ }
+ server.close(done);
+ });
+
+ it('should destroy the response stream when generateFilename throws', async function () {
+ class FailingFilenamePlugin {
+ apply (add) {
+ add('generateFilename', ({resource}) => {
+ if (resource.getUrl().endsWith('.png')) {
+ throw new Error('FILENAME FAILED');
+ }
+ return {filename: 'index.html'};
+ });
+ }
+ }
+
+ await scrape({
+ urls: [baseUrl + '/'],
+ directory: testDirname,
+ ignoreErrors: true,
+ plugins: [new FailingFilenamePlugin()]
+ });
+
+ // the failed resource's socket must be released, otherwise the process never exits
+ await new Promise((resolve, reject) => {
+ const startedAt = Date.now();
+ (function checkSocketClosed () {
+ if (pngSocket.destroyed) {
+ return resolve();
+ }
+ if (Date.now() - startedAt > 2000) {
+ return reject(new Error('response socket was not destroyed'));
+ }
+ setTimeout(checkSocketClosed, 20);
+ })();
+ });
+ });
+ });
+
+ it('should handle redirect to an already requested resource with requestConcurrency 1', async function () {
+ this.timeout(3000); // a deadlock in the request queue would exceed this
+
+ nock('http://example.com/').get('/').reply(200,
+ '
', {'content-type': 'text/html'});
+ nock('http://example.com/').get('/a.png').reply(200, 'a content', {'content-type': 'image/png'});
+ nock('http://example.com/').get('/b.png').reply(301, undefined, {location: 'http://example.com/a.png'});
+ nock('http://example.com/').get('/a.png').reply(200, 'a content', {'content-type': 'image/png'});
+
+ await scrape({
+ urls: ['http://example.com/'],
+ directory: testDirname,
+ requestConcurrency: 1
+ });
+
+ fs.readFileSync(testDirname + '/images/a.png').toString().should.be.eql('a content');
+ const html = fs.readFileSync(testDirname + '/index.html').toString();
+ html.should.contain('src="images/a.png"');
+ });
+});
diff --git a/test/unit/plugins.test.js b/test/unit/plugins.test.js
index e69de29b..4e550768 100644
--- a/test/unit/plugins.test.js
+++ b/test/unit/plugins.test.js
@@ -0,0 +1,158 @@
+import * as chai from 'chai';
+chai.should();
+import fs from 'fs';
+import { Readable } from 'stream';
+import SaveResourceToFileSystemPlugin from '../../lib/plugins/save-resource-to-fs-plugin.js';
+import Resource from '../../lib/resource.js';
+
+const testDirname = './test/unit/.plugins-test';
+
+function applyPlugin (plugin) {
+ const actions = {};
+ plugin.apply((name, handler) => actions[name] = handler);
+ return actions;
+}
+
+describe('SaveResourceToFileSystemPlugin', () => {
+ afterEach(() => {
+ fs.rmSync(testDirname, {recursive: true, force: true});
+ });
+
+ describe('beforeStart', () => {
+ it('should throw if directory is not a string', () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ (() => actions.beforeStart({options: {directory: null}})).should.throw('Incorrect directory');
+ (() => actions.beforeStart({options: {directory: 42}})).should.throw('Incorrect directory');
+ });
+
+ it('should throw if directory already exists', () => {
+ fs.mkdirSync(testDirname, {recursive: true});
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ (() => actions.beforeStart({options: {directory: testDirname}})).should.throw(/Directory (.*?) exists/);
+ });
+ });
+
+ describe('saveResource', () => {
+ it('should save content stream to file creating nested directories', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ const resource = new Resource('http://example.com/a.png', 'images/nested/a.png');
+ const content = Buffer.from([0xff, 0x00, 0xab, 0x10]);
+ resource.setContentStream(Readable.from(content));
+
+ await actions.saveResource({resource});
+
+ fs.readFileSync(testDirname + '/images/nested/a.png').should.be.eql(content);
+ });
+
+ it('should save buffered text resources using resource encoding', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ const resource = new Resource('http://example.com/', 'index.html');
+ resource.setEncoding('utf8');
+ resource.setText('тест');
+
+ await actions.saveResource({resource});
+
+ fs.readFileSync(testDirname + '/index.html').toString('utf8').should.be.eql('тест');
+ });
+
+ it('should remove partially written file and rethrow when stream errors', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ const resource = new Resource('http://example.com/a.png', 'a.png');
+ resource.setContentStream(new Readable({
+ read () {
+ this.push('partial data');
+ this.destroy(new Error('mid-stream failure'));
+ }
+ }));
+
+ try {
+ await actions.saveResource({resource});
+ throw new Error('expected saveResource to reject');
+ } catch (err) {
+ err.message.should.be.eql('mid-stream failure');
+ }
+ fs.existsSync(testDirname + '/a.png').should.be.eql(false);
+ });
+
+ it('should propagate the original save error when cleanup rm fails', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ // first resource occupies "about" as a directory
+ const first = new Resource('http://example.com/about/', 'about/index.html');
+ first.setContentStream(Readable.from('page'));
+ await actions.saveResource({resource: first});
+
+ // second resource wants "about" as a plain file - createWriteStream fails with EISDIR,
+ // and the cleanup rm of a directory would fail too - it must not mask the save error
+ const second = new Resource('http://example.com/about', 'about');
+ second.setContentStream(Readable.from('other'));
+
+ try {
+ await actions.saveResource({resource: second});
+ throw new Error('expected saveResource to reject');
+ } catch (err) {
+ err.code.should.be.eql('EISDIR');
+ }
+ });
+ });
+
+ describe('error', () => {
+ it('should remove directory if resources were saved', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ const resource = new Resource('http://example.com/', 'index.html');
+ resource.setContentStream(Readable.from('content'));
+ await actions.saveResource({resource});
+ fs.existsSync(testDirname).should.be.eql(true);
+
+ await actions.error();
+
+ fs.existsSync(testDirname).should.be.eql(false);
+ });
+
+ it('should not throw if nothing was saved', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ await actions.error();
+
+ fs.existsSync(testDirname).should.be.eql(false);
+ });
+
+ it('should remove directories created by a failed save even when no resource was saved', async () => {
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ actions.beforeStart({options: {directory: testDirname}});
+
+ const resource = new Resource('http://example.com/a.png', 'images/a.png');
+ resource.setContentStream(new Readable({
+ read () {
+ this.destroy(new Error('mid-stream failure'));
+ }
+ }));
+ await actions.saveResource({resource}).catch(() => {});
+ fs.existsSync(testDirname).should.be.eql(true); // directories created before the stream failed
+
+ await actions.error();
+
+ fs.existsSync(testDirname).should.be.eql(false);
+ });
+
+ it('should not remove a pre-existing directory when beforeStart failed', async () => {
+ fs.mkdirSync(testDirname, {recursive: true});
+ const actions = applyPlugin(new SaveResourceToFileSystemPlugin());
+ (() => actions.beforeStart({options: {directory: testDirname}})).should.throw(/Directory (.*?) exists/);
+
+ await actions.error();
+
+ fs.existsSync(testDirname).should.be.eql(true);
+ });
+ });
+});
diff --git a/test/unit/request-test.js b/test/unit/request-test.js
index 750a7ecb..e99ec690 100644
--- a/test/unit/request-test.js
+++ b/test/unit/request-test.js
@@ -1,7 +1,11 @@
import * as chai from 'chai';
-chai.should();
+const should = chai.should();
import sinon from 'sinon';
import nock from 'nock';
+import http from 'http';
+import { pipeline } from 'stream/promises';
+import { Writable } from 'stream';
+import { text as readStreamText, buffer as readStreamBuffer } from 'stream/consumers';
import request from '../../lib/request.js';
describe('request', () => {
@@ -47,266 +51,337 @@ describe('request', () => {
});
});
- it('should call afterResponse with correct params', () => {
- const url = 'http://example.com';
- const scope = nock(url).get('/').reply(200, 'TEST BODY');
- let handlerStub = sinon.stub().resolves('');
+ it('should return object with url, statusCode, mimeType, encoding and stream with the body', async () => {
+ const url = 'http://www.google.com';
+ nock(url).get('/').reply(200, 'Hello from Google!', {
+ 'content-type': 'text/html; charset=utf-8'
+ });
- return request.get({url, afterResponse: handlerStub}).then(() => {
- scope.isDone().should.eql(true);
- handlerStub.calledOnce.should.eql(true);
- const afterResponseArgs = handlerStub.getCall(0).args[0];
- afterResponseArgs.response.body.should.eql('TEST BODY');
- afterResponseArgs.response.headers.should.eql({});
+ const data = await request.get({url});
+ data.should.have.property('url');
+ data.should.have.property('statusCode');
+ data.should.have.property('mimeType');
+ data.should.have.property('stream');
+ data.url.should.eql('http://www.google.com/');
+ data.statusCode.should.eql(200);
+ data.mimeType.should.eql('text/html');
+ data.encoding.should.eql('utf8');
+ (data.metadata === null).should.be.true;
+
+ const body = await readStreamText(data.stream);
+ body.should.eql('Hello from Google!');
+ });
+
+ it('should return utf8 encoding when charset in content-type header is uppercase', async () => {
+ const url = 'http://www.google.com';
+ nock(url).get('/').reply(200, 'Hello from Google!', {
+ 'content-type': 'text/html; charset=UTF-8'
});
+
+ const data = await request.get({url});
+ data.encoding.should.eql('utf8');
});
- it('should pass response body to afterResponse as Buffer', () => {
- const url = 'http://example.com';
- nock(url).get('/').reply(200, 'TEST BODY');
- const handlerStub = sinon.stub().resolves('');
+ it('should return mimeType = null and binary encoding if content-type header was not found in response', async () => {
+ const url = 'http://www.google.com';
+ nock(url).get('/').reply(200, 'Hello from Google!', {});
- return request.get({url, options: {responseType: 'buffer'}, afterResponse: handlerStub}).then(() => {
- const { response } = handlerStub.getCall(0).args[0];
- Buffer.isBuffer(response.body).should.eql(true);
- response.body.toString().should.eql('TEST BODY');
- });
+ const data = await request.get({url});
+ data.url.should.eql('http://www.google.com/');
+ data.encoding.should.eql('binary');
+ data.should.have.property('mimeType', null);
});
- describe('transformResult from afterResponse', () => {
- it('should return object with body and metadata properties', () => {
- const url = 'http://example.com';
- nock(url).get('/').reply(200, 'TEST BODY');
- const handlerStub = sinon.stub().resolves({
- body: 'a',
- metadata: 'b',
- encoding: 'utf8'
- });
+ it('should retry transient network errors before response', async () => {
+ const url = 'http://retry.example.com';
+ nock(url).get('/').replyWithError(Object.assign(new Error('connection reset'), {code: 'ECONNRESET'}));
+ nock(url).get('/').reply(200, 'recovered');
- return request.get({url, afterResponse: handlerStub}).then((data) => {
- data.body.should.eql('a');
- data.metadata.should.eql('b');
- data.encoding.should.eql('utf8');
- });
- });
+ const data = await request.get({url, options: {retry: {limit: 1}}});
+ data.statusCode.should.eql(200);
+ const body = await readStreamText(data.stream);
+ body.should.eql('recovered');
+ });
- it('should return with metadata == null if metadata is not defined', () => {
- const url = 'http://example.com';
- nock(url).get('/').reply(200, 'TEST BODY');
- const handlerStub = sinon.stub().resolves({
- body: 'a'
- });
+ it('should reject when request fails without retries left', async () => {
+ const url = 'http://fail.example.com';
+ nock(url).get('/').replyWithError(Object.assign(new Error('connection reset'), {code: 'ECONNRESET'}));
+
+ try {
+ await request.get({url, options: {retry: {limit: 0}}});
+ throw new Error('expected request.get to reject');
+ } catch (err) {
+ err.code.should.eql('ECONNRESET');
+ }
+ });
- return request.get({url, afterResponse: handlerStub}).then((data) => {
- data.body.should.eql('a');
- (data.metadata === null).should.be.true;
- data.encoding.should.eql('binary');
+ // nock cannot simulate a connection dying mid-body, so these tests use a real local server
+ describe('mid-body errors', () => {
+ let server, url, attempts;
+
+ beforeEach((done) => {
+ attempts = 0;
+ server = http.createServer((req, res) => {
+ attempts++;
+ res.writeHead(200, {'content-type': 'text/html'});
+ if (attempts === 1) {
+ res.write('partial');
+ setTimeout(() => res.destroy(), 10);
+ } else {
+ res.end('full body');
+ }
+ });
+ server.listen(0, '127.0.0.1', () => {
+ url = `http://127.0.0.1:${server.address().port}/`;
+ done();
});
});
- it('should transform string result', () => {
- const url = 'http://example.com';
- nock(url).get('/').reply(200, 'TEST BODY');
- const handlerStub = sinon.stub().resolves('test body');
+ afterEach((done) => server.close(done));
- return request.get({url, afterResponse: handlerStub}).then((data) => {
- data.body.should.eql('test body');
- (data.metadata === null).should.be.true;
- });
+ it('should retry and return full body from readBody', async () => {
+ const data = await request.get({url, options: {retry: {limit: 1}}});
+ const body = await data.readBody();
+ body.toString().should.eql('full body');
+ attempts.should.eql(2);
});
- it('should be rejected if wrong result (no string nor object) returned', () => {
- const url = 'http://example.com';
- nock(url).get('/').reply(200, 'TEST BODY');
- const handlerStub = sinon.stub().resolves(['1', '2']);
-
- return request.get({url, afterResponse: handlerStub}).then(() => {
- true.should.eql(false);
- }).catch((e) => {
- e.should.be.instanceOf(Error);
- e.message.should.match(/Wrong response handler result. Expected string or object, but received/);
- });
+ it('should reject readBody with the request error when no retries are left', async () => {
+ const data = await request.get({url, options: {retry: {limit: 0}}});
+ try {
+ await data.readBody();
+ throw new Error('expected readBody to reject');
+ } catch (err) {
+ err.name.should.eql('ReadError');
+ }
+ attempts.should.eql(1);
});
- });
- it('should return object with url, body, mimeType properties', () => {
- const url = 'http://www.google.com';
- nock(url).get('/').reply(200, 'Hello from Google!', {
- 'content-type': 'text/html; charset=utf-8'
+ it('should not retry when the body is consumed as a stream', async () => {
+ const data = await request.get({url, options: {retry: {limit: 1}}});
+ try {
+ await pipeline(data.stream, new Writable({write (chunk, enc, cb) { cb(); }}));
+ throw new Error('expected pipeline to reject');
+ } catch (err) {
+ err.name.should.eql('ReadError');
+ }
+ attempts.should.eql(1);
});
- return request.get({url}).then((data) => {
- data.should.have.property('url');
- data.should.have.property('body');
- data.should.have.property('mimeType');
- data.url.should.eql('http://www.google.com/');
- data.body.should.eql('Hello from Google!');
- data.mimeType.should.eql('text/html');
- data.encoding.should.eql('utf8');
+ it('should retry inside afterResponse getBody', async () => {
+ let receivedBody;
+ const afterResponse = async ({response}) => {
+ receivedBody = await response.getBody();
+ return {};
+ };
+ const data = await request.get({url, options: {retry: {limit: 1}}, afterResponse});
+ receivedBody.toString().should.eql('full body');
+ (await readStreamText(data.stream)).should.eql('full body');
+ attempts.should.eql(2);
});
});
- it('should return mimeType = null if content-type header was not found in response', () => {
- let url = 'http://www.google.com';
- nock(url).get('/').reply(200, 'Hello from Google!', {});
+ describe('afterResponse', () => {
+ it('should call afterResponse with url, statusCode, headers and getBody', async () => {
+ const url = 'http://example.com';
+ const scope = nock(url).get('/').reply(200, 'TEST BODY', {'content-type': 'text/html'});
+ const handlerStub = sinon.stub().resolves({});
+
+ await request.get({url, afterResponse: handlerStub});
+ scope.isDone().should.eql(true);
+ handlerStub.calledOnce.should.eql(true);
- return request.get({url}).then((data) => {
- data.should.include.all.keys(['url', 'body', 'mimeType', 'encoding']);
- data.url.should.eql('http://www.google.com/');
- data.body.should.eql('Hello from Google!');
- data.encoding.should.eql('binary');
- data.should.have.property('mimeType', null);
+ const {response} = handlerStub.getCall(0).args[0];
+ response.url.should.eql('http://example.com/');
+ response.statusCode.should.eql(200);
+ response.headers.should.have.property('content-type', 'text/html');
+ response.getBody.should.be.a('function');
});
- });
-});
-describe('get encoding', () => {
- it('should return binary by default', () => {
- const result = request.getEncoding(null);
+ it('should provide body via getBody', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+
+ let receivedBody;
+ const afterResponse = async ({response}) => {
+ receivedBody = await response.getBody();
+ return {};
+ };
- result.should.eql('binary');
- });
+ const data = await request.get({url, afterResponse});
+ receivedBody.should.be.instanceOf(Buffer);
+ receivedBody.toString().should.eql('TEST BODY');
- it('should return binary when no content-type header supplies', () => {
- const result = request.getEncoding({
- headers: {}
+ // original stream was consumed by getBody - body must still be readable from result
+ const body = await readStreamText(data.stream);
+ body.should.eql('TEST BODY');
});
- result.should.eql('binary');
- });
+ it('should memoize getBody', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
- it('should return binary when content type header doesn\'t include utf-8', () => {
- const result = request.getEncoding({
- headers: {}
+ const afterResponse = async ({response}) => {
+ const first = await response.getBody();
+ const second = await response.getBody();
+ first.should.be.equal(second);
+ return {};
+ };
+
+ await request.get({url, afterResponse});
});
- result.should.eql('binary');
- });
+ it('should return full body to concurrent getBody calls', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
- it('should return binary when content type header doesn\'t include utf-8', () => {
- const result = request.getEncoding({
- headers: {
- 'content-type': 'text/html'
- }
+ const afterResponse = async ({response}) => {
+ const [first, second] = await Promise.all([response.getBody(), response.getBody()]);
+ first.toString().should.eql('TEST BODY');
+ second.should.be.equal(first);
+ return {};
+ };
+
+ const data = await request.get({url, afterResponse});
+ const body = await readStreamText(data.stream);
+ body.should.eql('TEST BODY');
});
- result.should.eql('binary');
- });
+ it('should reject getBody called after the action has returned', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
- it('should return utf8 when content type includes utf-8', () => {
- const result = request.getEncoding({
- headers: {
- 'content-type': 'text/html; charset=utf-8'
+ let savedResponse;
+ const data = await request.get({url, afterResponse: ({response}) => {
+ savedResponse = response;
+ return {};
+ }});
+
+ try {
+ await savedResponse.getBody();
+ throw new Error('expected getBody to reject');
+ } catch (err) {
+ err.message.should.contain('getBody() can only be called while the afterResponse action is running');
}
+ // the stream handed over to the caller is untouched
+ (await readStreamText(data.stream)).should.eql('TEST BODY');
});
- result.should.eql('utf8');
- });
+ it('should return null and skip resource when action returns null', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves(null);
- it('should return utf8 response object includes it', () => {
- const result = request.getEncoding({
- encoding: 'utf8'
+ const data = await request.get({url, afterResponse: handlerStub});
+ should.not.exist(data);
});
- result.should.eql('utf8');
- });
-});
+ it('should replace body when action returns object with body', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves({
+ body: 'a',
+ metadata: 'b',
+ encoding: 'utf8'
+ });
-describe('transformResult', () => {
- it('should throw with weird shaped response', () => {
- try {
- request.transformResult([1,2,3]);
+ const data = await request.get({url, afterResponse: handlerStub});
+ data.metadata.should.eql('b');
+ data.encoding.should.eql('utf8');
+ const body = await readStreamText(data.stream);
+ body.should.eql('a');
+ });
- // We shouldn't get here.
- true.should.eql(false);
- } catch (e) {
- e.should.be.instanceOf(Error);
- e.message.should.eql('Wrong response handler result. Expected string or object, but received array');
- }
- });
+ it('should return null and skip resource when action returns object with null body', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves({body: null});
- it('should pass through error', () => {
- try {
- request.transformResult(new Error('Oh no'));
+ const data = await request.get({url, afterResponse: handlerStub});
+ should.not.exist(data);
+ });
- // We shouldn't get here.
- true.should.eql(false);
- } catch (e) {
- e.should.be.instanceOf(Error);
- e.message.should.eql('Oh no');
- }
- });
+ it('should decode string body with header-derived encoding when no encoding returned', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY', {'content-type': 'text/html; charset=utf-8'});
+ const handlerStub = sinon.stub().resolves({body: 'café'});
- it('should throw with boolean input', () => {
- try {
- request.transformResult(true);
+ const data = await request.get({url, afterResponse: handlerStub});
+ data.encoding.should.eql('utf8');
+ const body = await readStreamBuffer(data.stream);
+ body.should.eql(Buffer.from('café', 'utf8'));
+ });
- // We shouldn't get here.
- true.should.eql(false);
- } catch (e) {
- e.should.be.instanceOf(Error);
- e.message.should.eql('Wrong response handler result. Expected string or object, but received boolean');
- }
- });
+ it('should support Buffer body', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const bodyBuffer = Buffer.from([0xff, 0x00, 0xab]);
+ const handlerStub = sinon.stub().resolves({body: bodyBuffer});
- it('should handle object', () => {
- const result = request.transformResult({
- body: 'SOME BODY',
- encoding: 'utf8',
- metadata: { foo: 'bar' }
+ const data = await request.get({url, afterResponse: handlerStub});
+ (data.metadata === null).should.be.true;
+ const body = await readStreamBuffer(data.stream);
+ body.should.eql(bodyBuffer);
});
- result.should.have.property('body', 'SOME BODY');
- result.should.have.property('encoding', 'utf8');
- result.should.have.property('metadata').that.eql({ foo: 'bar' });
- });
+ it('should support Uint8Array body', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves({body: new TextEncoder().encode('SOME BODY')});
- it('should handle object with empty body string', () => {
- const result = request.transformResult({
- body: '',
- encoding: 'utf8',
+ const data = await request.get({url, afterResponse: handlerStub});
+ const body = await readStreamText(data.stream);
+ body.should.eql('SOME BODY');
});
- result.should.have.property('body', '');
- result.should.have.property('encoding', 'utf8');
- result.should.have.property('metadata', null);
- });
+ it('should keep original body streaming when action returns metadata only', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves({metadata: {foo: 'bar'}});
- it('should handle object with defaults and buffer body', () => {
- const result = request.transformResult({
- body: Buffer.from('SOME BODY'),
+ const data = await request.get({url, afterResponse: handlerStub});
+ data.metadata.should.eql({foo: 'bar'});
+ const body = await readStreamText(data.stream);
+ body.should.eql('TEST BODY');
});
- result.should.have.property('body', 'SOME BODY');
- result.should.have.property('encoding', 'binary');
- result.should.have.property('metadata', null);
- });
+ it('should be rejected if wrong result (no null nor object) returned', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves(['1', '2']);
- it('should handle object with Uint8Array body', () => {
- const result = request.transformResult({
- body: new TextEncoder().encode('SOME BODY'),
+ try {
+ await request.get({url, afterResponse: handlerStub});
+ throw new Error('expected request.get to reject');
+ } catch (e) {
+ e.message.should.match(/Wrong afterResponse result. Expected null or object.*but received array/);
+ }
});
- result.should.have.property('body', 'SOME BODY');
- result.should.have.property('encoding', 'binary');
- result.should.have.property('metadata', null);
- });
-
- it('should handle raw string input', () => {
- const result = request.transformResult('SOME BODY');
+ it('should be rejected if string returned (removed in v7)', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().resolves('test body');
- result.should.have.property('body', 'SOME BODY');
- result.should.have.property('encoding', 'binary');
- result.should.have.property('metadata', null);
- });
+ try {
+ await request.get({url, afterResponse: handlerStub});
+ throw new Error('expected request.get to reject');
+ } catch (e) {
+ e.message.should.match(/Wrong afterResponse result.*but received string/);
+ }
+ });
- it('should handle null input', () => {
- const result = request.transformResult(null);
- (result === null).should.be.true;
- });
+ it('should be rejected with error thrown by action', async () => {
+ const url = 'http://example.com';
+ nock(url).get('/').reply(200, 'TEST BODY');
+ const handlerStub = sinon.stub().rejects(new Error('action failed'));
- it('should handle undefined input', () => {
- const result = request.transformResult(undefined);
- (result === null).should.be.true;
+ try {
+ await request.get({url, afterResponse: handlerStub});
+ throw new Error('expected request.get to reject');
+ } catch (e) {
+ e.message.should.eql('action failed');
+ }
+ });
});
});
diff --git a/test/unit/resource-test.js b/test/unit/resource-test.js
index 545b0f71..476c451d 100644
--- a/test/unit/resource-test.js
+++ b/test/unit/resource-test.js
@@ -1,5 +1,7 @@
import * as chai from 'chai';
-chai.should();
+import { Readable } from 'stream';
+import { text as readStreamText, buffer as readStreamBuffer } from 'stream/consumers';
+const should = chai.should();
import '../../test/utils/assertions.js';
import Resource from '../../lib/resource.js';
@@ -33,4 +35,81 @@ describe('Resource', function() {
childOfChild.depth.should.eql(2);
});
});
+
+ describe('#getContentStream', function () {
+ it('should return null when resource has no content', function () {
+ const resource = new Resource('http://example.com');
+ should.not.exist(resource.getContentStream());
+ });
+
+ it('should return stream set with setContentStream', function () {
+ const resource = new Resource('http://example.com');
+ const stream = Readable.from(Buffer.from('binary data'));
+ resource.setContentStream(stream);
+ resource.getContentStream().should.be.equal(stream);
+ });
+
+ it('should throw when a live stream is requested a second time', function () {
+ const resource = new Resource('http://example.com/a.png');
+ resource.setContentStream(Readable.from(Buffer.from('binary data')));
+ resource.getContentStream();
+
+ (() => resource.getContentStream()).should.throw(/was already taken/);
+ });
+
+ it('should hand out a newly set live stream again', function () {
+ const resource = new Resource('http://example.com/a.png');
+ resource.setContentStream(Readable.from(Buffer.from('first')));
+ resource.getContentStream();
+
+ const second = Readable.from(Buffer.from('second'));
+ resource.setContentStream(second);
+ resource.getContentStream().should.be.equal(second);
+ });
+
+ it('should return fresh stream with text content on each call', async function () {
+ const resource = new Resource('http://example.com');
+ resource.setEncoding('utf8');
+ resource.setText('some text');
+
+ const firstText = await readStreamText(resource.getContentStream());
+ const secondText = await readStreamText(resource.getContentStream());
+ firstText.should.be.eql('some text');
+ secondText.should.be.eql('some text');
+ });
+
+ it('should encode text with resource encoding', async function () {
+ const resource = new Resource('http://example.com');
+ const originalBytes = Buffer.from([0xff, 0x00, 0xab, 0x10]);
+ resource.setText(originalBytes.toString('binary')); // default encoding is binary
+
+ const streamedBytes = await readStreamBuffer(resource.getContentStream());
+ streamedBytes.should.be.eql(originalBytes);
+ });
+
+ it('should return fresh stream with content buffer on each call', async function () {
+ const resource = new Resource('http://example.com');
+ const contentBuffer = Buffer.from([0xff, 0x00, 0xab, 0x10]);
+ resource.setContentBuffer(contentBuffer);
+
+ const firstBytes = await readStreamBuffer(resource.getContentStream());
+ const secondBytes = await readStreamBuffer(resource.getContentStream());
+ firstBytes.should.be.eql(contentBuffer);
+ secondBytes.should.be.eql(contentBuffer);
+ });
+ });
+
+ describe('#clearContent', function () {
+ it('should clear text, content stream and content buffer', function () {
+ const resource = new Resource('http://example.com');
+ resource.setText('some text');
+ resource.setContentStream(Readable.from('stream'));
+ resource.setContentBuffer(Buffer.from('buffer'));
+
+ resource.clearContent();
+
+ should.not.exist(resource.getText());
+ should.not.exist(resource.getContentStream());
+ });
+ });
});
diff --git a/test/unit/scraper-init-test.js b/test/unit/scraper-init-test.js
index d74605d8..1efc89b9 100644
--- a/test/unit/scraper-init-test.js
+++ b/test/unit/scraper-init-test.js
@@ -123,7 +123,6 @@ describe('Scraper initialization', function () {
s.options.request.should.deep.include({
throwHttpErrors: false,
- responseType: 'buffer',
decompress: true,
https: {
rejectUnauthorized: false
@@ -145,7 +144,6 @@ describe('Scraper initialization', function () {
s.options.request.should.deep.include({
throwHttpErrors: true,
- responseType: 'buffer',
decompress: true,
https: {
rejectUnauthorized: false
diff --git a/test/unit/scraper-test.js b/test/unit/scraper-test.js
index 2b9477b0..62a568d0 100644
--- a/test/unit/scraper-test.js
+++ b/test/unit/scraper-test.js
@@ -1,5 +1,5 @@
import * as chai from 'chai';
-chai.should();
+const should = chai.should();
import sinon from 'sinon';
import nock from 'nock';
import fs from 'fs';
@@ -96,6 +96,7 @@ describe('Scraper', () => {
urlFilter: () => { return true; },
plugins: [ new GenerateFilenamePlugin() ]
});
+ sinon.stub(s, 'persistResource').resolves();
const r = new Resource('http://example.com/a.png');
r.getDepth = sinon.stub().returns(2);
@@ -105,7 +106,8 @@ describe('Scraper', () => {
rr.should.be.eql(r);
rr.getUrl().should.be.eql('http://example.com/a.png');
rr.getFilename().should.not.be.empty;
- rr.getText().should.not.be.empty;
+ should.exist(rr.getContentStream());
+ s.persistResource.calledOnce.should.be.eql(true);
});
it('should return null if the urlFilter returns false', async () =>{
@@ -131,6 +133,7 @@ describe('Scraper', () => {
urlFilter: () => false,
plugins: [ new GenerateFilenamePlugin() ]
});
+ sinon.stub(s, 'persistResource').resolves();
const r = new Resource('http://example.com');
r.getDepth = sinon.stub().returns(0);
@@ -140,7 +143,7 @@ describe('Scraper', () => {
rr.should.be.eql(r);
rr.getUrl().should.be.eql('http://example.com');
rr.getFilename().should.not.be.empty;
- rr.getText().should.not.be.empty;
+ should.exist(rr.getContentStream());
});
});
@@ -153,6 +156,7 @@ describe('Scraper', () => {
directory: testDirname,
plugins: [ new GenerateFilenamePlugin() ]
});
+ sinon.stub(s, 'persistResource').resolves();
const r = new Resource('http://example.com/a.png');
r.getDepth = sinon.stub().returns(212);
@@ -162,7 +166,7 @@ describe('Scraper', () => {
rr.should.be.eql(r);
rr.getUrl().should.be.eql('http://example.com/a.png');
rr.getFilename().should.not.be.empty;
- rr.getText().should.not.be.empty;
+ should.exist(rr.getContentStream());
});
it('should request the resource if maxDepth is set and resource depth is less than maxDept', async () =>{
@@ -174,6 +178,7 @@ describe('Scraper', () => {
maxDepth: 3,
plugins: [ new GenerateFilenamePlugin() ]
});
+ sinon.stub(s, 'persistResource').resolves();
const r = new Resource('http://example.com/a.png');
r.getDepth = sinon.stub().returns(2);
@@ -183,7 +188,7 @@ describe('Scraper', () => {
rr.should.be.eql(r);
rr.getUrl().should.be.eql('http://example.com/a.png');
rr.getFilename().should.not.be.empty;
- rr.getText().should.not.be.empty;
+ should.exist(rr.getContentStream());
});
it('should request the resource if maxDepth is set and resource depth is equal to maxDept', async () =>{
@@ -195,6 +200,7 @@ describe('Scraper', () => {
maxDepth: 3,
plugins: [ new GenerateFilenamePlugin() ]
});
+ sinon.stub(s, 'persistResource').resolves();
const r = new Resource('http://example.com/a.png');
r.getDepth = sinon.stub().returns(3);
@@ -203,7 +209,7 @@ describe('Scraper', () => {
rr.should.be.eql(r);
rr.getUrl().should.be.eql('http://example.com/a.png');
rr.getFilename().should.not.be.empty;
- rr.getText().should.not.be.empty;
+ should.exist(rr.getContentStream());
});
it('should return null if maxDepth is set and resource depth is greater than maxDepth', async () =>{