diff --git a/GraphQLUpload.test.mjs b/GraphQLUpload.test.mjs index 8abaff7..1c75d58 100644 --- a/GraphQLUpload.test.mjs +++ b/GraphQLUpload.test.mjs @@ -1,26 +1,26 @@ // @ts-check import { doesNotThrow, throws } from "node:assert"; +import { describe, it } from "node:test"; import { parseValue } from "graphql"; import GraphQLUpload from "./GraphQLUpload.mjs"; import Upload from "./Upload.mjs"; -/** - * Adds `GraphQLUpload` tests. - * @param {import("test-director").default} tests Test director. - */ -export default (tests) => { - tests.add("`GraphQLUpload` scalar `parseValue` with a valid value.", () => { - doesNotThrow(() => { - GraphQLUpload.parseValue(new Upload()); +describe( + "GraphQL scalar `GraphQLUpload`.", + { + concurrency: true, + }, + () => { + it("Method `parseValue`, value valid.", () => { + doesNotThrow(() => { + GraphQLUpload.parseValue(new Upload()); + }); }); - }); - tests.add( - "`GraphQLUpload` scalar `parseValue` with an invalid value.", - () => { + it("Method `parseValue`, value invalid.", () => { throws( () => { GraphQLUpload.parseValue(true); @@ -30,33 +30,33 @@ export default (tests) => { message: "Upload value invalid.", }, ); - }, - ); + }); - tests.add("`GraphQLUpload` scalar `parseLiteral`.", () => { - throws( - () => { - // The dummy value is irrelevant. - GraphQLUpload.parseLiteral(parseValue('""')); - }, - { - name: "GraphQLError", - message: "Upload literal unsupported.", - locations: [{ line: 1, column: 1 }], - }, - ); - }); + it("Method `parseLiteral`.", () => { + throws( + () => { + // The dummy value is irrelevant. + GraphQLUpload.parseLiteral(parseValue('""')); + }, + { + name: "GraphQLError", + message: "Upload literal unsupported.", + locations: [{ line: 1, column: 1 }], + }, + ); + }); - tests.add("`GraphQLUpload` scalar `serialize`.", () => { - throws( - () => { - // The dummy value is irrelevant. - GraphQLUpload.serialize(""); - }, - { - name: "GraphQLError", - message: "Upload serialization unsupported.", - }, - ); - }); -}; + it("Method `serialize`.", () => { + throws( + () => { + // The dummy value is irrelevant. + GraphQLUpload.serialize(""); + }, + { + name: "GraphQLError", + message: "Upload serialization unsupported.", + }, + ); + }); + }, +); diff --git a/Upload.test.mjs b/Upload.test.mjs index 26e6bc6..f28a2d6 100644 --- a/Upload.test.mjs +++ b/Upload.test.mjs @@ -1,59 +1,62 @@ // @ts-check import { ok, rejects, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; import Upload from "./Upload.mjs"; -/** - * Adds `Upload` tests. - * @param {import("test-director").default} tests Test director. - */ -export default (tests) => { - tests.add("`Upload` class resolving a file.", async () => { - const upload = new Upload(); +describe( + "Class `Upload`.", + { + concurrency: true, + }, + () => { + it("Resolving a file.", async () => { + const upload = new Upload(); - ok(upload.promise instanceof Promise); - strictEqual(typeof upload.resolve, "function"); + ok(upload.promise instanceof Promise); + strictEqual(typeof upload.resolve, "function"); - /** @type {any} */ - const file = {}; + /** @type {any} */ + const file = {}; - upload.resolve(file); + upload.resolve(file); - const resolved = await upload.promise; + const resolved = await upload.promise; - strictEqual(resolved, file); - strictEqual(upload.file, file); - }); + strictEqual(resolved, file); + strictEqual(upload.file, file); + }); - tests.add("`Upload` class with a handled rejection.", async () => { - const upload = new Upload(); + it("Handled rejection.", async () => { + const upload = new Upload(); - ok(upload.promise instanceof Promise); - strictEqual(typeof upload.reject, "function"); + ok(upload.promise instanceof Promise); + strictEqual(typeof upload.reject, "function"); - const error = new Error("Message."); + const error = new Error("Message."); - upload.reject(error); + upload.reject(error); - // This is the safe way to check the promise status, see: - // https://github.com/nodejs/node/issues/31392#issuecomment-575451230 - await rejects(Promise.race([upload.promise, Promise.resolve()]), error); - }); + // This is the safe way to check the promise status, see: + // https://github.com/nodejs/node/issues/31392#issuecomment-575451230 + await rejects(Promise.race([upload.promise, Promise.resolve()]), error); + }); - tests.add("`Upload` class with an unhandled rejection.", async () => { - const upload = new Upload(); + it("Unhandled rejection.", async () => { + const upload = new Upload(); - ok(upload.promise instanceof Promise); - strictEqual(typeof upload.reject, "function"); + ok(upload.promise instanceof Promise); + strictEqual(typeof upload.reject, "function"); - const error = new Error("Message."); + const error = new Error("Message."); - upload.reject(error); + upload.reject(error); - // Node.js CLI flag `--unhandled-rejections=throw` must be used when these - // tests are run with Node.js v14 (it’s unnecessary for Node.js v15+) or the - // process won’t exit with an error if the unhandled rejection is’t silenced - // as intended. - }); -}; + // Node.js CLI flag `--unhandled-rejections=throw` must be used when these + // tests are run with Node.js v14 (it’s unnecessary for Node.js v15+) or + // the process won’t exit with an error if the unhandled rejection is’t + // silenced as intended. + }); + }, +); diff --git a/changelog.md b/changelog.md index 3a10e06..bed3df1 100644 --- a/changelog.md +++ b/changelog.md @@ -4,10 +4,11 @@ ### Major -- Updated Node.js support to `^18.15.0 || >=20.4.0`. +- Updated Node.js support to `^18.18.0 || ^20.9.0 || >=22.0.0`. - Updated dev dependencies, some of which require newer Node.js versions than previously supported. - Refactored tests to use the standard `AbortController`, `fetch`, `File`, and `FormData` APIs available in modern Node.js and removed the dev dependencies [`node-abort-controller`](https://npm.im/node-abort-controller) and [`node-fetch`](https://npm.im/node-fetch). - Replaced the test utility function `streamToString` with the function `text` from `node:stream/consumers` that’s available in modern Node.js. +- Use the Node.js test runner API and remove the dev dependency [`test-director`](https://npm.im/test-director). ### Minor diff --git a/graphqlUploadExpress.test.mjs b/graphqlUploadExpress.test.mjs index b810c9c..b2d3322 100644 --- a/graphqlUploadExpress.test.mjs +++ b/graphqlUploadExpress.test.mjs @@ -4,6 +4,7 @@ import "./test/polyfillFile.mjs"; import { deepStrictEqual, ok, strictEqual } from "node:assert"; import { createServer } from "node:http"; +import { describe, it } from "node:test"; import express from "express"; import createError from "http-errors"; @@ -12,14 +13,13 @@ import graphqlUploadExpress from "./graphqlUploadExpress.mjs"; import processRequest from "./processRequest.mjs"; import listen from "./test/listen.mjs"; -/** - * Adds `graphqlUploadExpress` tests. - * @param {import("test-director").default} tests Test director. - */ -export default (tests) => { - tests.add( - "`graphqlUploadExpress` with a non multipart request.", - async () => { +describe( + "Function `graphqlUploadExpress`.", + { + concurrency: true, + }, + () => { + it("Non multipart request.", async () => { let processRequestRan = false; const app = express().use( @@ -39,48 +39,48 @@ export default (tests) => { } finally { close(); } - }, - ); - - tests.add("`graphqlUploadExpress` with a multipart request.", async () => { - /** - * @type {{ - * variables: { - * file: import("./Upload.mjs").default, - * }, - * } | undefined} - */ - let requestBody; - - const app = express() - .use(graphqlUploadExpress()) - .use((request, response, next) => { - requestBody = request.body; - next(); - }); - - const { port, close } = await listen(createServer(app)); - - try { - const body = new FormData(); - - body.append("operations", JSON.stringify({ variables: { file: null } })); - body.append("map", JSON.stringify({ 1: ["variables.file"] })); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); - - await fetch(`http://localhost:${port}`, { method: "POST", body }); - - ok(requestBody); - ok(requestBody.variables); - ok(requestBody.variables.file); - } finally { - close(); - } - }); - - tests.add( - "`graphqlUploadExpress` with a multipart request and option `processRequest`.", - async () => { + }); + + it("Multipart request.", async () => { + /** + * @type {{ + * variables: { + * file: import("./Upload.mjs").default, + * }, + * } | undefined} + */ + let requestBody; + + const app = express() + .use(graphqlUploadExpress()) + .use((request, response, next) => { + requestBody = request.body; + next(); + }); + + const { port, close } = await listen(createServer(app)); + + try { + const body = new FormData(); + + body.append( + "operations", + JSON.stringify({ variables: { file: null } }), + ); + body.append("map", JSON.stringify({ 1: ["variables.file"] })); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + + await fetch(`http://localhost:${port}`, { method: "POST", body }); + + ok(requestBody); + ok(requestBody.variables); + ok(requestBody.variables.file); + } finally { + close(); + } + }); + + it("Multipart request and option `processRequest`.", async () => { let processRequestRan = false; /** @@ -127,12 +127,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`graphqlUploadExpress` with a multipart request and option `processRequest` throwing an exposed HTTP error.", - async () => { + it("Multipart request and option `processRequest` throwing an exposed HTTP error.", async () => { let expressError; let requestCompleted; let responseStatusCode; @@ -201,12 +198,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`graphqlUploadExpress` with a multipart request following middleware throwing an error.", - async () => { + it("Multipart request following middleware throwing an error.", async () => { let expressError; let requestCompleted; @@ -268,6 +262,6 @@ export default (tests) => { } finally { close(); } - }, - ); -}; + }); + }, +); diff --git a/graphqlUploadKoa.test.mjs b/graphqlUploadKoa.test.mjs index 4b49799..d81005d 100644 --- a/graphqlUploadKoa.test.mjs +++ b/graphqlUploadKoa.test.mjs @@ -4,6 +4,7 @@ import "./test/polyfillFile.mjs"; import { deepStrictEqual, ok, strictEqual } from "node:assert"; import { createServer } from "node:http"; +import { describe, it } from "node:test"; import Koa from "koa"; @@ -11,72 +12,74 @@ import graphqlUploadKoa from "./graphqlUploadKoa.mjs"; import processRequest from "./processRequest.mjs"; import listen from "./test/listen.mjs"; -/** - * Adds `graphqlUploadKoa` tests. - * @param {import("test-director").default} tests Test director. - */ -export default (tests) => { - tests.add("`graphqlUploadKoa` with a non multipart request.", async () => { - let processRequestRan = false; - - const app = new Koa().use( - graphqlUploadKoa({ - /** @type {any} */ - async processRequest() { - processRequestRan = true; - }, - }), - ); - - const { port, close } = await listen(createServer(app.callback())); - - try { - await fetch(`http://localhost:${port}`, { method: "POST" }); - strictEqual(processRequestRan, false); - } finally { - close(); - } - }); - - tests.add("`graphqlUploadKoa` with a multipart request.", async () => { - /** - * @type {{ - * variables: { - * file: import("./Upload.mjs").default, - * }, - * } | undefined} - */ - let ctxRequestBody; - - const app = new Koa().use(graphqlUploadKoa()).use(async (ctx, next) => { - ctxRequestBody = - // @ts-ignore By convention this should be present. - ctx.request.body; - await next(); +describe( + "Function `graphqlUploadKoa`.", + { + concurrency: true, + }, + () => { + it("Non multipart request.", async () => { + let processRequestRan = false; + + const app = new Koa().use( + graphqlUploadKoa({ + /** @type {any} */ + async processRequest() { + processRequestRan = true; + }, + }), + ); + + const { port, close } = await listen(createServer(app.callback())); + + try { + await fetch(`http://localhost:${port}`, { method: "POST" }); + strictEqual(processRequestRan, false); + } finally { + close(); + } }); - const { port, close } = await listen(createServer(app.callback())); + it("Multipart request.", async () => { + /** + * @type {{ + * variables: { + * file: import("./Upload.mjs").default, + * }, + * } | undefined} + */ + let ctxRequestBody; - try { - const body = new FormData(); + const app = new Koa().use(graphqlUploadKoa()).use(async (ctx, next) => { + ctxRequestBody = + // @ts-ignore By convention this should be present. + ctx.request.body; + await next(); + }); - body.append("operations", JSON.stringify({ variables: { file: null } })); - body.append("map", JSON.stringify({ 1: ["variables.file"] })); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + const { port, close } = await listen(createServer(app.callback())); - await fetch(`http://localhost:${port}`, { method: "POST", body }); + try { + const body = new FormData(); - ok(ctxRequestBody); - ok(ctxRequestBody.variables); - ok(ctxRequestBody.variables.file); - } finally { - close(); - } - }); + body.append( + "operations", + JSON.stringify({ variables: { file: null } }), + ); + body.append("map", JSON.stringify({ 1: ["variables.file"] })); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + + await fetch(`http://localhost:${port}`, { method: "POST", body }); - tests.add( - "`graphqlUploadKoa` with a multipart request and option `processRequest`.", - async () => { + ok(ctxRequestBody); + ok(ctxRequestBody.variables); + ok(ctxRequestBody.variables.file); + } finally { + close(); + } + }); + + it("Multipart request and option `processRequest`.", async () => { let processRequestRan = false; /** @@ -125,12 +128,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`graphqlUploadKoa` with a multipart request and option `processRequest` throwing an error.", - async () => { + it("Multipart request and option `processRequest` throwing an error.", async () => { let koaError; let requestCompleted; @@ -177,12 +177,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`graphqlUploadKoa` with a multipart request and following middleware throwing an error.", - async () => { + it("Multipart request and following middleware throwing an error.", async () => { let koaError; let requestCompleted; @@ -225,6 +222,6 @@ export default (tests) => { } finally { close(); } - }, - ); -}; + }); + }, +); diff --git a/ignoreStream.test.mjs b/ignoreStream.test.mjs index 2d3eba8..b0602dd 100644 --- a/ignoreStream.test.mjs +++ b/ignoreStream.test.mjs @@ -1,27 +1,32 @@ // @ts-check import { doesNotThrow, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; import ignoreStream from "./ignoreStream.mjs"; import CountReadableStream from "./test/CountReadableStream.mjs"; -/** - * Adds `ignoreStream` tests. - * @param {import("test-director").default} tests Test director. - */ -export default (tests) => { - tests.add("`ignoreStream` ignores errors.", () => { - doesNotThrow(() => { - const stream = new CountReadableStream(); - ignoreStream(stream); - stream.emit("error", new Error("Message.")); +describe( + "Function `ignoreStream`.", + { + concurrency: true, + }, + () => { + it("Ignores errors.", () => { + doesNotThrow(() => { + const stream = new CountReadableStream(); + ignoreStream(stream); + stream.emit("error", new Error("Message.")); + }); }); - }); - tests.add("`ignoreStream` resumes a paused stream.", () => { - const stream = new CountReadableStream(); - stream.pause(); - ignoreStream(stream); - strictEqual(stream.isPaused(), false); - }); -}; + it("Resumes a paused stream.", () => { + doesNotThrow(() => { + const stream = new CountReadableStream(); + stream.pause(); + ignoreStream(stream); + strictEqual(stream.isPaused(), false); + }); + }); + }, +); diff --git a/package.json b/package.json index da8af29..976afe1 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "./Upload.mjs": "./Upload.mjs" }, "engines": { - "node": "^18.15.0 || >=20.4.0" + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" }, "peerDependencies": { "@types/express": "4.0.29 - 5", @@ -76,18 +76,17 @@ "eslint": "^8.48.0", "eslint-plugin-simple-import-sort": "^10.0.0", "express": "^5.0.0", - "form-data-encoder": "^3.0.0", + "form-data-encoder": "^4.0.2", "graphql": "^16.8.0", "koa": "^2.14.2", "prettier": "^3.0.2", - "test-director": "^11.0.0", "typescript": "^5.2.2" }, "scripts": { "eslint": "eslint .", "prettier": "prettier -c .", "types": "tsc -p jsconfig.json", - "tests": "coverage-node test.mjs", + "tests": "coverage-node --test-reporter=spec --test *.test.mjs", "test": "npm run eslint && npm run prettier && npm run types && npm run tests", "prepublishOnly": "npm test" } diff --git a/processRequest.test.mjs b/processRequest.test.mjs index b69f5fc..b16bc3f 100644 --- a/processRequest.test.mjs +++ b/processRequest.test.mjs @@ -12,6 +12,7 @@ import { } from "node:assert"; import { createServer } from "node:http"; import { text } from "node:stream/consumers"; +import { describe, it } from "node:test"; import { ReadStream } from "fs-capacitor"; @@ -21,44 +22,43 @@ import Deferred from "./test/Deferred.mjs"; import listen from "./test/listen.mjs"; import Upload from "./Upload.mjs"; -/** - * Adds `processRequest` tests. - * @param {import("test-director").default} tests Test director. - */ -export default (tests) => { - tests.add("`processRequest` with no files.", async () => { - let serverError; +describe( + "Function `processRequest`.", + { + concurrency: true, + }, + () => { + it("No files.", async () => { + let serverError; - const operation = { variables: { a: true } }; - const server = createServer(async (request, response) => { - try { - deepStrictEqual(await processRequest(request, response), operation); - } catch (error) { - serverError = error; - } finally { - response.end(); - } - }); + const operation = { variables: { a: true } }; + const server = createServer(async (request, response) => { + try { + deepStrictEqual(await processRequest(request, response), operation); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); - const { port, close } = await listen(server); + const { port, close } = await listen(server); - try { - const body = new FormData(); + try { + const body = new FormData(); - body.append("operations", JSON.stringify(operation)); - body.append("map", "{}"); + body.append("operations", JSON.stringify(operation)); + body.append("map", "{}"); - await fetch(`http://localhost:${port}`, { method: "POST", body }); + await fetch(`http://localhost:${port}`, { method: "POST", body }); - if (serverError) throw serverError; - } finally { - close(); - } - }); + if (serverError) throw serverError; + } finally { + close(); + } + }); - tests.add( - "`processRequest` with a single file, default `createReadStream` options, file name chars `latin1`.", - async () => { + it("A single file, default `createReadStream` options, file name chars `latin1`.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -112,12 +112,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with a single file, default `createReadStream` options, file name chars non `latin1`.", - async () => { + it("A single file, default `createReadStream` options, file name chars non `latin1`.", async () => { const fileName = "你好.txt"; let serverError; @@ -173,12 +170,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with a single file and custom `createReadStream` options.", - async () => { + it("A single file and custom `createReadStream` options.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -234,212 +228,212 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add("`processRequest` with a single file, batched.", async () => { - let serverError; + it("A single file, batched.", async () => { + let serverError; - const server = createServer(async (request, response) => { - try { - const operations = - /** - * @type {Array<{ - * variables: { - * file: Upload, - * }, - * }>} - */ - (await processRequest(request, response)); + const server = createServer(async (request, response) => { + try { + const operations = + /** + * @type {Array<{ + * variables: { + * file: Upload, + * }, + * }>} + */ + (await processRequest(request, response)); - ok(operations[0].variables.file instanceof Upload); + ok(operations[0].variables.file instanceof Upload); - const uploadA = await operations[0].variables.file.promise; + const uploadA = await operations[0].variables.file.promise; - strictEqual(uploadA.filename, "a.txt"); - strictEqual(uploadA.mimetype, "text/plain"); - strictEqual(uploadA.encoding, "7bit"); + strictEqual(uploadA.filename, "a.txt"); + strictEqual(uploadA.mimetype, "text/plain"); + strictEqual(uploadA.encoding, "7bit"); + + const streamA = uploadA.createReadStream(); + + ok(streamA instanceof ReadStream); + strictEqual(await text(streamA), "a"); + + ok(operations[1].variables.file instanceof Upload); + + const uploadB = await operations[1].variables.file.promise; + + strictEqual(uploadB.filename, "b.txt"); + strictEqual(uploadB.mimetype, "text/plain"); + strictEqual(uploadB.encoding, "7bit"); - const streamA = uploadA.createReadStream(); + const streamB = uploadB.createReadStream(); - ok(streamA instanceof ReadStream); - strictEqual(await text(streamA), "a"); + ok(streamB instanceof ReadStream); + strictEqual(await text(streamB), "b"); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); - ok(operations[1].variables.file instanceof Upload); + const { port, close } = await listen(server); - const uploadB = await operations[1].variables.file.promise; + try { + const body = new FormData(); - strictEqual(uploadB.filename, "b.txt"); - strictEqual(uploadB.mimetype, "text/plain"); - strictEqual(uploadB.encoding, "7bit"); + body.append( + "operations", + JSON.stringify([ + { variables: { file: null } }, + { variables: { file: null } }, + ]), + ); + body.append( + "map", + JSON.stringify({ 1: ["0.variables.file"], 2: ["1.variables.file"] }), + ); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); - const streamB = uploadB.createReadStream(); + await fetch(`http://localhost:${port}`, { method: "POST", body }); - ok(streamB instanceof ReadStream); - strictEqual(await text(streamB), "b"); - } catch (error) { - serverError = error; + if (serverError) throw serverError; } finally { - response.end(); + close(); } }); - const { port, close } = await listen(server); - - try { - const body = new FormData(); - - body.append( - "operations", - JSON.stringify([ - { variables: { file: null } }, - { variables: { file: null } }, - ]), - ); - body.append( - "map", - JSON.stringify({ 1: ["0.variables.file"], 2: ["1.variables.file"] }), - ); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); - body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); - - await fetch(`http://localhost:${port}`, { method: "POST", body }); - - if (serverError) throw serverError; - } finally { - close(); - } - }); - - tests.add("`processRequest` with deduped files.", async () => { - let serverError; - - const server = createServer(async (request, response) => { + it("Deduped files.", async () => { + let serverError; + + const server = createServer(async (request, response) => { + try { + const operation = + /** + * @type {{ + * variables: { + * files: Array, + * }, + * }} + */ + (await processRequest(request, response)); + + ok(operation.variables.files[0] instanceof Upload); + ok(operation.variables.files[1] instanceof Upload); + strictEqual( + operation.variables.files[0], + operation.variables.files[1], + ); + + const [upload1, upload2] = await Promise.all([ + operation.variables.files[0].promise, + operation.variables.files[1].promise, + ]); + + strictEqual(upload1, upload2); + strictEqual(upload1.filename, "a.txt"); + strictEqual(upload1.mimetype, "text/plain"); + strictEqual(upload1.encoding, "7bit"); + + const stream1 = upload1.createReadStream(); + const stream2 = upload2.createReadStream(); + + notStrictEqual(stream1, stream2); + ok(stream1 instanceof ReadStream); + ok(stream2 instanceof ReadStream); + + const [content1, content2] = await Promise.all([ + text(stream1), + text(stream2), + ]); + + strictEqual(content1, "a"); + strictEqual(content2, "a"); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); + + const { port, close } = await listen(server); + try { - const operation = - /** - * @type {{ - * variables: { - * files: Array, - * }, - * }} - */ - (await processRequest(request, response)); - - ok(operation.variables.files[0] instanceof Upload); - ok(operation.variables.files[1] instanceof Upload); - strictEqual(operation.variables.files[0], operation.variables.files[1]); - - const [upload1, upload2] = await Promise.all([ - operation.variables.files[0].promise, - operation.variables.files[1].promise, - ]); - - strictEqual(upload1, upload2); - strictEqual(upload1.filename, "a.txt"); - strictEqual(upload1.mimetype, "text/plain"); - strictEqual(upload1.encoding, "7bit"); - - const stream1 = upload1.createReadStream(); - const stream2 = upload2.createReadStream(); - - notStrictEqual(stream1, stream2); - ok(stream1 instanceof ReadStream); - ok(stream2 instanceof ReadStream); - - const [content1, content2] = await Promise.all([ - text(stream1), - text(stream2), - ]); - - strictEqual(content1, "a"); - strictEqual(content2, "a"); - } catch (error) { - serverError = error; + const body = new FormData(); + + body.append( + "operations", + JSON.stringify({ variables: { files: [null, null] } }), + ); + body.append( + "map", + JSON.stringify({ 1: ["variables.files.0", "variables.files.1"] }), + ); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + + await fetch(`http://localhost:${port}`, { method: "POST", body }); + + if (serverError) throw serverError; } finally { - response.end(); + close(); } }); - const { port, close } = await listen(server); + it("Unconsumed uploads.", async () => { + let serverError; - try { - const body = new FormData(); + const server = createServer(async (request, response) => { + try { + const operation = + /** + * @type {{ + * variables: { + * fileA: Upload, + * fileB: Upload, + * }, + * }} + */ + (await processRequest(request, response)); - body.append( - "operations", - JSON.stringify({ variables: { files: [null, null] } }), - ); - body.append( - "map", - JSON.stringify({ 1: ["variables.files.0", "variables.files.1"] }), - ); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + ok(operation.variables.fileB instanceof Upload); - await fetch(`http://localhost:${port}`, { method: "POST", body }); + const uploadB = await operation.variables.fileB.promise; + const streamB = uploadB.createReadStream(); - if (serverError) throw serverError; - } finally { - close(); - } - }); + await text(streamB); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); - tests.add("`processRequest` with unconsumed uploads.", async () => { - let serverError; + const { port, close } = await listen(server); - const server = createServer(async (request, response) => { try { - const operation = - /** - * @type {{ - * variables: { - * fileA: Upload, - * fileB: Upload, - * }, - * }} - */ - (await processRequest(request, response)); - - ok(operation.variables.fileB instanceof Upload); - - const uploadB = await operation.variables.fileB.promise; - const streamB = uploadB.createReadStream(); - - await text(streamB); - } catch (error) { - serverError = error; + const body = new FormData(); + + body.append( + "operations", + JSON.stringify({ variables: { fileA: null, fileB: null } }), + ); + body.append( + "map", + JSON.stringify({ 1: ["variables.fileA"], 2: ["variables.fileB"] }), + ); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); + + await fetch(`http://localhost:${port}`, { method: "POST", body }); + + if (serverError) throw serverError; } finally { - response.end(); + close(); } }); - const { port, close } = await listen(server); - - try { - const body = new FormData(); - - body.append( - "operations", - JSON.stringify({ variables: { fileA: null, fileB: null } }), - ); - body.append( - "map", - JSON.stringify({ 1: ["variables.fileA"], 2: ["variables.fileB"] }), - ); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); - body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); - - await fetch(`http://localhost:${port}`, { method: "POST", body }); - - if (serverError) throw serverError; - } finally { - close(); - } - }); - - tests.add( - "`processRequest` with an extraneous multipart form field file.", - async () => { + it("An extraneous multipart form field file.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -492,12 +486,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with a missing multipart form field file.", - async () => { + it("A missing multipart form field file.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -543,57 +534,54 @@ export default (tests) => { } finally { close(); } - }, - ); + }); + + it("Option `maxFiles`.", async () => { + let serverError; + + const server = createServer(async (request, response) => { + try { + await rejects(processRequest(request, response, { maxFiles: 1 }), { + name: "PayloadTooLargeError", + message: "1 max file uploads exceeded.", + status: 413, + expose: true, + }); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); - tests.add("`processRequest` with option `maxFiles`.", async () => { - let serverError; + const { port, close } = await listen(server); - const server = createServer(async (request, response) => { try { - await rejects(processRequest(request, response, { maxFiles: 1 }), { - name: "PayloadTooLargeError", - message: "1 max file uploads exceeded.", - status: 413, - expose: true, - }); - } catch (error) { - serverError = error; + const body = new FormData(); + + body.append( + "operations", + JSON.stringify({ variables: { files: [null, null] } }), + ); + body.append( + "map", + JSON.stringify({ + 1: ["variables.files.0"], + 2: ["variables.files.1"], + }), + ); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); + + await fetch(`http://localhost:${port}`, { method: "POST", body }); + + if (serverError) throw serverError; } finally { - response.end(); + close(); } }); - const { port, close } = await listen(server); - - try { - const body = new FormData(); - - body.append( - "operations", - JSON.stringify({ variables: { files: [null, null] } }), - ); - body.append( - "map", - JSON.stringify({ - 1: ["variables.files.0"], - 2: ["variables.files.1"], - }), - ); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); - body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); - - await fetch(`http://localhost:${port}`, { method: "POST", body }); - - if (serverError) throw serverError; - } finally { - close(); - } - }); - - tests.add( - "`processRequest` with option `maxFiles` and an interspersed extraneous file.", - async () => { + it("Option `maxFiles` and an interspersed extraneous file.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -663,131 +651,135 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add("`processRequest` with option `maxFileSize`.", async () => { - let serverError; + it("Option `maxFileSize`.", async () => { + let serverError; - const server = createServer(async (request, response) => { - try { - const operation = - /** - * @type {{ - * variables: { - * files: Array, - * }, - * }} - */ - ( - await processRequest(request, response, { - // Todo: Change this back to 1 once this `busboy` bug is fixed: - // https://github.com/mscdex/busboy/issues/297 - maxFileSize: 2, - }) - ); + const server = createServer(async (request, response) => { + try { + const operation = + /** + * @type {{ + * variables: { + * files: Array, + * }, + * }} + */ + ( + await processRequest(request, response, { + // Todo: Change this back to 1 once this `busboy` bug is fixed: + // https://github.com/mscdex/busboy/issues/297 + maxFileSize: 2, + }) + ); - ok(operation.variables.files[0] instanceof Upload); + ok(operation.variables.files[0] instanceof Upload); - const { createReadStream } = await operation.variables.files[0].promise; + const { createReadStream } = + await operation.variables.files[0].promise; + + await throws( + () => { + createReadStream(); + }, + { + name: "PayloadTooLargeError", + message: "File truncated as it exceeds the 2 byte size limit.", + status: 413, + expose: true, + }, + ); - await throws( - () => { - createReadStream(); - }, - { - name: "PayloadTooLargeError", - message: "File truncated as it exceeds the 2 byte size limit.", - status: 413, - expose: true, - }, - ); + ok(operation.variables.files[0] instanceof Upload); - ok(operation.variables.files[0] instanceof Upload); + const uploadB = await operation.variables.files[1].promise; - const uploadB = await operation.variables.files[1].promise; + strictEqual(uploadB.filename, "b.txt"); + strictEqual(uploadB.mimetype, "text/plain"); + strictEqual(uploadB.encoding, "7bit"); - strictEqual(uploadB.filename, "b.txt"); - strictEqual(uploadB.mimetype, "text/plain"); - strictEqual(uploadB.encoding, "7bit"); + const streamB = uploadB.createReadStream(); - const streamB = uploadB.createReadStream(); + ok(streamB instanceof ReadStream); + strictEqual(await text(streamB), "b"); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); - ok(streamB instanceof ReadStream); - strictEqual(await text(streamB), "b"); - } catch (error) { - serverError = error; - } finally { - response.end(); - } - }); + const { port, close } = await listen(server); - const { port, close } = await listen(server); - - try { - const body = new FormData(); - - body.append( - "operations", - JSON.stringify({ variables: { files: [null, null] } }), - ); - body.append( - "map", - JSON.stringify({ - 1: ["variables.files.0"], - 2: ["variables.files.1"], - }), - ); - body.append("1", new File(["aa"], "a.txt", { type: "text/plain" })); - body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); - - await fetch(`http://localhost:${port}`, { method: "POST", body }); - - if (serverError) throw serverError; - } finally { - close(); - } - }); - - tests.add("`processRequest` with option `maxFieldSize`.", async () => { - let serverError; - - const server = createServer(async (request, response) => { try { - await rejects(processRequest(request, response, { maxFieldSize: 1 }), { - name: "PayloadTooLargeError", - message: - "The ‘operations’ multipart field value exceeds the 1 byte size limit.", - status: 413, - expose: true, - }); - } catch (error) { - serverError = error; + const body = new FormData(); + + body.append( + "operations", + JSON.stringify({ variables: { files: [null, null] } }), + ); + body.append( + "map", + JSON.stringify({ + 1: ["variables.files.0"], + 2: ["variables.files.1"], + }), + ); + body.append("1", new File(["aa"], "a.txt", { type: "text/plain" })); + body.append("2", new File(["b"], "b.txt", { type: "text/plain" })); + + await fetch(`http://localhost:${port}`, { method: "POST", body }); + + if (serverError) throw serverError; } finally { - response.end(); + close(); } }); - const { port, close } = await listen(server); + it("Option `maxFieldSize`.", async () => { + let serverError; + + const server = createServer(async (request, response) => { + try { + await rejects( + processRequest(request, response, { maxFieldSize: 1 }), + { + name: "PayloadTooLargeError", + message: + "The ‘operations’ multipart field value exceeds the 1 byte size limit.", + status: 413, + expose: true, + }, + ); + } catch (error) { + serverError = error; + } finally { + response.end(); + } + }); + + const { port, close } = await listen(server); - try { - const body = new FormData(); + try { + const body = new FormData(); - body.append("operations", JSON.stringify({ variables: { file: null } })); - body.append("map", JSON.stringify({ 1: ["variables.file"] })); - body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); + body.append( + "operations", + JSON.stringify({ variables: { file: null } }), + ); + body.append("map", JSON.stringify({ 1: ["variables.file"] })); + body.append("1", new File(["a"], "a.txt", { type: "text/plain" })); - await fetch(`http://localhost:${port}`, { method: "POST", body }); + await fetch(`http://localhost:${port}`, { method: "POST", body }); - if (serverError) throw serverError; - } finally { - close(); - } - }); + if (serverError) throw serverError; + } finally { + close(); + } + }); - tests.add( - "`processRequest` with an aborted request and immediate stream creation.", - async () => { + it("An aborted request and immediate stream creation.", async () => { let serverError; // In other tests a fetch request can be awaited that resolves once the @@ -927,12 +919,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with an aborted request and delayed stream creation.", - async () => { + it("An aborted request and delayed stream creation.", async () => { let serverError; // In other tests a fetch request can be awaited that resolves once the @@ -1071,12 +1060,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with multipart form field `map` misordered before `operations`.", - async () => { + it("Multipart form field `map` misordered before `operations`.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1113,12 +1099,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with multipart form field file misordered before `map`.", - async () => { + it("Multipart form field file misordered before `map`.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1155,12 +1138,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with multipart form fields `map` and file missing.", - async () => { + it("Multipart form fields `map` and file missing.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1195,12 +1175,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with multipart form fields `operations`, `map` and file missing.", - async () => { + it("Multipart form fields `operations`, `map` and file missing.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1231,12 +1208,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with invalid multipart form field `operations` JSON and a small file.", - async () => { + it("Invalid multipart form field `operations` JSON and a small file.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1270,12 +1244,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with invalid multipart form field `operations` JSON and a large file.", - async () => { + it("Invalid multipart form field `operations` JSON and a large file.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1321,17 +1292,14 @@ export default (tests) => { } finally { close(); } - }, - ); - - for (const [type, value] of [ - ["null", null], - ["boolean", true], - ["string", ""], - ]) - tests.add( - `\`processRequest\` with invalid multipart form field \`operations\` type, ${type}.`, - async () => { + }); + + for (const [type, value] of [ + ["null", null], + ["boolean", true], + ["string", ""], + ]) + it(`Invalid multipart form field \`operations\` type, ${type}.`, async () => { let serverError; const server = createServer(async (request, response) => { @@ -1365,12 +1333,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with invalid multipart form field `map` JSON.", - async () => { + it("Invalid multipart form field `map` JSON.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1407,18 +1372,15 @@ export default (tests) => { } finally { close(); } - }, - ); - - for (const [type, value] of [ - ["null", null], - ["array", []], - ["boolean", true], - ["string", ""], - ]) - tests.add( - `\`processRequest\` with invalid multipart form field \`map\` type, ${type}.`, - async () => { + }); + + for (const [type, value] of [ + ["null", null], + ["array", []], + ["boolean", true], + ["string", ""], + ]) + it(`Invalid multipart form field \`map\` type, ${type}.`, async () => { let serverError; const server = createServer(async (request, response) => { @@ -1455,12 +1417,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with invalid multipart form field `map` entry type.", - async () => { + it("Invalid multipart form field `map` entry type.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1497,12 +1456,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with invalid multipart form field `map` entry array item type.", - async () => { + it("Invalid multipart form field `map` entry array item type.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1539,12 +1495,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with invalid multipart form field `map` entry array item object path.", - async () => { + it("Invalid multipart form field `map` entry array item object path.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1578,12 +1531,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with an unparsable multipart request.", - async () => { + it("An unparsable multipart request.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1616,12 +1566,9 @@ export default (tests) => { } finally { close(); } - }, - ); + }); - tests.add( - "`processRequest` with a maliciously malformed multipart request.", - async () => { + it("A maliciously malformed multipart request.", async () => { let serverError; const server = createServer(async (request, response) => { @@ -1657,6 +1604,6 @@ export default (tests) => { } finally { close(); } - }, - ); -}; + }); + }, +); diff --git a/readme.md b/readme.md index 8d959f6..9a1c8e2 100644 --- a/readme.md +++ b/readme.md @@ -50,7 +50,7 @@ The [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-mult Supported runtime environments: -- [Node.js](https://nodejs.org) versions `^18.15.0 || >=20.4.0`. +- [Node.js](https://nodejs.org) versions `^18.18.0 || ^20.9.0 || >=22.0.0`. Projects must configure [TypeScript](https://typescriptlang.org) to use types from the ECMAScript modules that have a `// @ts-check` comment: diff --git a/test.mjs b/test.mjs deleted file mode 100644 index 9f164ad..0000000 --- a/test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-check - -import TestDirector from "test-director"; - -import test_GraphQLUpload from "./GraphQLUpload.test.mjs"; -import test_graphqlUploadExpress from "./graphqlUploadExpress.test.mjs"; -import test_graphqlUploadKoa from "./graphqlUploadKoa.test.mjs"; -import test_ignoreStream from "./ignoreStream.test.mjs"; -import test_processRequest from "./processRequest.test.mjs"; -import test_Upload from "./Upload.test.mjs"; - -const tests = new TestDirector(); - -test_ignoreStream(tests); -test_GraphQLUpload(tests); -test_graphqlUploadExpress(tests); -test_graphqlUploadKoa(tests); -test_processRequest(tests); -test_Upload(tests); - -tests.run(); diff --git a/test/abortingMultipartRequest.mjs b/test/abortingMultipartRequest.mjs index a699435..75fa57c 100644 --- a/test/abortingMultipartRequest.mjs +++ b/test/abortingMultipartRequest.mjs @@ -23,10 +23,7 @@ export default async function abortingMultipartRequest( requestReceived, ) { const abortController = new AbortController(); - const encoder = new FormDataEncoder( - // @ts-expect-error https://github.com/octet-stream/form-data-encoder/issues/16 - formData, - ); + const encoder = new FormDataEncoder(formData); try { await fetch(url, {