diff --git a/.eslintrc.json b/.eslintrc.json index faabd6b7..abf36bc3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -54,6 +54,8 @@ }, "ignorePatterns": [ "**/*.js", + "**/*.mjs", + "**/*.cjs", "dist", "azure-functions-language-worker-protobuf" ] diff --git a/package-lock.json b/package-lock.json index c2427309..387a6623 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/fs-extra": "^9.0.13", "@types/minimist": "^1.2.2", "@types/mocha": "^2.2.48", + "@types/mock-fs": "^4.13.1", "@types/mock-require": "^2.0.1", "@types/node": "^16.9.6", "@types/semver": "^7.3.9", @@ -30,6 +31,7 @@ "@typescript-eslint/parser": "^5.12.1", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", + "escape-string-regexp": "^4.0.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-deprecation": "^1.3.2", @@ -511,6 +513,15 @@ "integrity": "sha512-nlK/iyETgafGli8Zh9zJVCTicvU3iajSkRwOh3Hhiva598CMqNJ4NcVCGMTGKpGpTYj/9R8RLzS9NAykSSCqGw==", "dev": true }, + "node_modules/@types/mock-fs": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", + "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mock-require": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mock-require/-/mock-require-2.0.1.tgz", @@ -1566,6 +1577,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", @@ -1876,18 +1899,6 @@ "node": ">=10" } }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -3087,18 +3098,6 @@ "node": ">=0.3.1" } }, - "node_modules/mocha/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mocha/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5140,6 +5139,15 @@ "integrity": "sha512-nlK/iyETgafGli8Zh9zJVCTicvU3iajSkRwOh3Hhiva598CMqNJ4NcVCGMTGKpGpTYj/9R8RLzS9NAykSSCqGw==", "dev": true }, + "@types/mock-fs": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", + "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/mock-require": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mock-require/-/mock-require-2.0.1.tgz", @@ -5926,6 +5934,12 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, "eslint": { "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", @@ -5974,12 +5988,6 @@ "v8-compile-cache": "^2.0.3" }, "dependencies": { - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -7022,12 +7030,6 @@ "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 65f86bbe..1edb5d19 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@types/fs-extra": "^9.0.13", "@types/minimist": "^1.2.2", "@types/mocha": "^2.2.48", + "@types/mock-fs": "^4.13.1", "@types/mock-require": "^2.0.1", "@types/node": "^16.9.6", "@types/semver": "^7.3.9", @@ -26,6 +27,7 @@ "@typescript-eslint/parser": "^5.12.1", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", + "escape-string-regexp": "^4.0.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-deprecation": "^1.3.2", @@ -74,4 +76,4 @@ "dist" ], "main": "dist/src/nodejsWorker.js" -} +} \ No newline at end of file diff --git a/src/FunctionLoader.ts b/src/FunctionLoader.ts index 238414c1..2edb5165 100644 --- a/src/FunctionLoader.ts +++ b/src/FunctionLoader.ts @@ -1,11 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import * as url from 'url'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import { FunctionInfo } from './FunctionInfo'; +import { loadScriptFile } from './loadScriptFile'; +import { PackageJson } from './parsers/parsePackageJson'; import { InternalException } from './utils/InternalException'; -import { PackageJson } from './WorkerChannel'; +import { nonNullProp } from './utils/nonNull'; export interface IFunctionLoader { load(functionId: string, metadata: rpc.IRpcFunctionMetadata, packageJson: PackageJson): Promise; @@ -26,22 +27,7 @@ export class FunctionLoader implements IFunctionLoader { if (metadata.isProxy === true) { return; } - const scriptFilePath = (metadata && metadata.scriptFile); - let script: any; - if (this.isESModule(scriptFilePath, packageJson)) { - // IMPORTANT: pathToFileURL is only supported in Node.js version >= v10.12.0 - const scriptFileUrl = url.pathToFileURL(scriptFilePath); - if (scriptFileUrl.href) { - // use eval so it doesn't get compiled into a require() - script = await eval('import(scriptFileUrl.href)'); - } else { - throw new InternalException( - `'${scriptFilePath}' could not be converted to file URL (${scriptFileUrl.href})` - ); - } - } else { - script = require(scriptFilePath); - } + const script: any = await loadScriptFile(nonNullProp(metadata, 'scriptFile'), packageJson); const entryPoint = (metadata && metadata.entryPoint); const [userFunction, thisArg] = getEntryPoint(script, entryPoint); this.#loadedFunctions[functionId] = { @@ -69,16 +55,6 @@ export class FunctionLoader implements IFunctionLoader { throw new InternalException(`Function code for '${functionId}' is not loaded and cannot be invoked.`); } } - - isESModule(filePath: string, packageJson: PackageJson): boolean { - if (filePath.endsWith('.mjs')) { - return true; - } - if (filePath.endsWith('.cjs')) { - return false; - } - return packageJson.type === 'module'; - } } function getEntryPoint(f: any, entryPoint?: string): [Function, unknown] { diff --git a/src/WorkerChannel.ts b/src/WorkerChannel.ts index 078cfead..7225490c 100644 --- a/src/WorkerChannel.ts +++ b/src/WorkerChannel.ts @@ -2,20 +2,15 @@ // Licensed under the MIT License. import { HookCallback, HookContext } from '@azure/functions-core'; -import { readJson } from 'fs-extra'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import { Disposable } from './Disposable'; import { IFunctionLoader } from './FunctionLoader'; import { IEventStream } from './GrpcClient'; +import { PackageJson, parsePackageJson } from './parsers/parsePackageJson'; import { ensureErrorType } from './utils/ensureErrorType'; -import path = require('path'); import LogLevel = rpc.RpcLog.Level; import LogCategory = rpc.RpcLog.RpcLogCategory; -export interface PackageJson { - type?: string; -} - export class WorkerChannel { eventStream: IEventStream; functionLoader: IFunctionLoader; @@ -92,20 +87,11 @@ export class WorkerChannel { async updatePackageJson(dir: string): Promise { try { - this.packageJson = await readJson(path.join(dir, 'package.json')); + this.packageJson = await parsePackageJson(dir); } catch (err) { - const error: Error = ensureErrorType(err); - let errorMsg: string; - if (error.name === 'SyntaxError') { - errorMsg = `file is not a valid JSON: ${error.message}`; - } else if (error.message.startsWith('ENOENT')) { - errorMsg = `file does not exist.`; - } else { - errorMsg = error.message; - } - errorMsg = `Worker failed to load package.json: ${errorMsg}`; + const error = ensureErrorType(err); this.log({ - message: errorMsg, + message: `Worker failed to load package.json: ${error.message}`, level: LogLevel.Warning, logCategory: LogCategory.System, }); diff --git a/src/eventHandlers/WorkerInitHandler.ts b/src/eventHandlers/WorkerInitHandler.ts index 2541763c..a45da3f7 100644 --- a/src/eventHandlers/WorkerInitHandler.ts +++ b/src/eventHandlers/WorkerInitHandler.ts @@ -2,11 +2,14 @@ // Licensed under the MIT License. import { access, constants } from 'fs'; +import { pathExists } from 'fs-extra'; import * as path from 'path'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { isError } from '../utils/ensureErrorType'; +import { loadScriptFile } from '../loadScriptFile'; +import { ensureErrorType, isError } from '../utils/ensureErrorType'; import { InternalException } from '../utils/InternalException'; import { systemError } from '../utils/Logger'; +import { nonNullProp } from '../utils/nonNull'; import { WorkerChannel } from '../WorkerChannel'; import { EventHandler } from './EventHandler'; import LogCategory = rpc.RpcLog.RpcLogCategory; @@ -25,6 +28,12 @@ export class WorkerInitHandler extends EventHandler<'workerInitRequest', 'worker async handleEvent(channel: WorkerChannel, msg: rpc.IWorkerInitRequest): Promise { const response = this.getDefaultResponse(msg); + channel.log({ + message: 'Received WorkerInitRequest', + level: LogLevel.Debug, + logCategory: LogCategory.System, + }); + // Validate version const version = process.version; if ( @@ -54,8 +63,34 @@ export class WorkerInitHandler extends EventHandler<'workerInitRequest', 'worker } logColdStartWarning(channel); - if (msg.functionAppDirectory) { - await channel.updatePackageJson(msg.functionAppDirectory); + const functionAppDirectory = nonNullProp(msg, 'functionAppDirectory'); + await channel.updatePackageJson(functionAppDirectory); + + const entryPointFile = channel.packageJson.main; + if (entryPointFile) { + channel.log({ + message: `Loading entry point "${entryPointFile}"`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }); + try { + const entryPointFullPath = path.join(functionAppDirectory, entryPointFile); + if (!(await pathExists(entryPointFullPath))) { + throw new Error(`file does not exist`); + } + + await loadScriptFile(entryPointFullPath, channel.packageJson); + channel.log({ + message: `Loaded entry point "${entryPointFile}"`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }); + } catch (err) { + const error = ensureErrorType(err); + error.isAzureFunctionsInternalException = true; + error.message = `Worker was unable to load entry point "${entryPointFile}": ${error.message}`; + throw error; + } } response.capabilities = { diff --git a/src/loadScriptFile.ts b/src/loadScriptFile.ts new file mode 100644 index 00000000..b16ac2e4 --- /dev/null +++ b/src/loadScriptFile.ts @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as url from 'url'; +import { PackageJson } from './parsers/parsePackageJson'; +import { InternalException } from './utils/InternalException'; + +export async function loadScriptFile(filePath: string, packageJson: PackageJson): Promise { + let script: unknown; + if (isESModule(filePath, packageJson)) { + const fileUrl = url.pathToFileURL(filePath); + if (fileUrl.href) { + // use eval so it doesn't get compiled into a require() + script = await eval('import(fileUrl.href)'); + } else { + throw new InternalException(`'${filePath}' could not be converted to file URL (${fileUrl.href})`); + } + } else { + script = require(filePath); + } + return script; +} + +export function isESModule(filePath: string, packageJson: PackageJson): boolean { + if (filePath.endsWith('.mjs')) { + return true; + } + if (filePath.endsWith('.cjs')) { + return false; + } + return packageJson.type === 'module'; +} diff --git a/src/parsers/parsePackageJson.ts b/src/parsers/parsePackageJson.ts new file mode 100644 index 00000000..9cad9be7 --- /dev/null +++ b/src/parsers/parsePackageJson.ts @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import { pathExists, readJson } from 'fs-extra'; +import * as path from 'path'; +import { ensureErrorType } from '../utils/ensureErrorType'; + +export interface PackageJson { + type?: string; + main?: string; +} + +/** + * @returns A parsed & sanitized package.json + */ +export async function parsePackageJson(dir: string): Promise { + try { + const filePath = path.join(dir, 'package.json'); + if (!(await pathExists(filePath))) { + throw new Error('file does not exist'); + } + + const data: unknown = await readJson(filePath); + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new Error('file content is not an object'); + } + + const stringFields = ['main', 'type']; + for (const field of stringFields) { + if (field in data && typeof data[field] !== 'string') { + // ignore fields with an unexpected type + delete data[field]; + } + } + return data; + } catch (err) { + const error: Error = ensureErrorType(err); + if (error.name === 'SyntaxError') { + error.message = `file content is not valid JSON: ${error.message}`; + } + throw error; + } +} diff --git a/test/FunctionLoader.test.ts b/test/FunctionLoader.test.ts index aebf696e..cfebdfd9 100644 --- a/test/FunctionLoader.test.ts +++ b/test/FunctionLoader.test.ts @@ -166,36 +166,6 @@ describe('FunctionLoader', () => { expect(userFunction2['hello']).to.be.undefined; }); - it('respects .cjs extension', () => { - const result = loader.isESModule('test.cjs', { - type: 'module', - }); - expect(result).to.be.false; - }); - - it('respects .mjs extension', () => { - const result = loader.isESModule('test.mjs', { - type: 'commonjs', - }); - expect(result).to.be.true; - }); - - it('respects package.json module type', () => { - const result = loader.isESModule('test.js', { - type: 'module', - }); - expect(result).to.be.true; - }); - - it('defaults to using commonjs', () => { - expect(loader.isESModule('test.js', {})).to.be.false; - expect( - loader.isESModule('test.js', { - type: 'commonjs', - }) - ).to.be.false; - }); - afterEach(() => { mock.stopAll(); }); diff --git a/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts b/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts index 0e1e9b1b..01563d8e 100644 --- a/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts +++ b/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts @@ -61,7 +61,7 @@ namespace Msg { } export const noPackageJsonWarning: rpc.IStreamingMessage = warning( - `Worker failed to load package.json: file does not exist.` + `Worker failed to load package.json: file does not exist` ); } diff --git a/test/eventHandlers/FunctionLoadHandler.test.ts b/test/eventHandlers/FunctionLoadHandler.test.ts index 0ed4f781..5869b80c 100644 --- a/test/eventHandlers/FunctionLoadHandler.test.ts +++ b/test/eventHandlers/FunctionLoadHandler.test.ts @@ -5,7 +5,7 @@ import 'mocha'; import * as sinon from 'sinon'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; import { FunctionLoader } from '../../src/FunctionLoader'; -import { PackageJson } from '../../src/WorkerChannel'; +import { PackageJson } from '../../src/parsers/parsePackageJson'; import { beforeEventHandlerSuite } from './beforeEventHandlerSuite'; import { TestEventStream } from './TestEventStream'; import LogCategory = rpc.RpcLog.RpcLogCategory; diff --git a/test/eventHandlers/TestEventStream.ts b/test/eventHandlers/TestEventStream.ts index d5d9e90d..97b9c09d 100644 --- a/test/eventHandlers/TestEventStream.ts +++ b/test/eventHandlers/TestEventStream.ts @@ -26,7 +26,7 @@ export class TestEventStream extends EventEmitter implements IEventStream { /** * Waits up to a second for the expected number of messages to be written and then validates those messages */ - async assertCalledWith(...expectedMsgs: rpc.IStreamingMessage[]): Promise { + async assertCalledWith(...expectedMsgs: (rpc.IStreamingMessage | RegExpStreamingMessage)[]): Promise { try { // Wait for up to a second for the expected number of messages to come in const maxTime = Date.now() + 1000; @@ -48,10 +48,17 @@ export class TestEventStream extends EventEmitter implements IEventStream { 'Message count does not match. This may be caused by the previous test writing extraneous messages.' ); for (let i = 0; i < expectedMsgs.length; i++) { - const expectedMsg = convertHttpResponse(expectedMsgs[i]); const call = calls[i]; expect(call.args).to.have.length(1); const actualMsg = convertHttpResponse(call.args[0]); + + let expectedMsg = expectedMsgs[i]; + if (expectedMsg instanceof RegExpStreamingMessage) { + expectedMsg.validateRegExpProps(actualMsg); + expectedMsg = expectedMsg.expectedMsg; + } + expectedMsg = convertHttpResponse(expectedMsg); + expect(actualMsg).to.deep.equal(expectedMsg); } } finally { @@ -69,7 +76,8 @@ export class TestEventStream extends EventEmitter implements IEventStream { } } -function getShortenedMsg(msg: rpc.IStreamingMessage): string { +function getShortenedMsg(msg: rpc.IStreamingMessage | RegExpStreamingMessage): string { + msg = msg instanceof RegExpStreamingMessage ? msg.expectedMsg : msg; if (msg.rpcLog?.message) { return msg.rpcLog.message; } else { @@ -123,3 +131,38 @@ function convertHttpResponse(msg: rpc.IStreamingMessage): rpc.IStreamingMessage } return msg; } + +type RegExpProps = { [keyPath: string]: RegExp }; + +/** + * Allows you to use regular expressions to validate properties of the message instead of just deep equal + */ +export class RegExpStreamingMessage { + expectedMsg: rpc.IStreamingMessage; + #regExpProps: RegExpProps; + + constructor(expectedMsg: rpc.IStreamingMessage, regExpProps: RegExpProps) { + this.expectedMsg = expectedMsg; + this.#regExpProps = regExpProps; + } + + validateRegExpProps(actualMsg: rpc.IStreamingMessage) { + for (const [keyPath, regExp] of Object.entries(this.#regExpProps)) { + let lastKey: string = keyPath; + let lastObject: {} = actualMsg; + let value: unknown = actualMsg; + for (const subpath of keyPath.split('.')) { + if (typeof value === 'object' && value !== null) { + lastKey = subpath; + lastObject = value; + value = value[subpath]; + } else { + break; + } + } + expect(value).to.match(regExp); + + delete lastObject[lastKey]; + } + } +} diff --git a/test/eventHandlers/WorkerInitHandler.test.ts b/test/eventHandlers/WorkerInitHandler.test.ts index edf93d82..aadf8f1f 100644 --- a/test/eventHandlers/WorkerInitHandler.test.ts +++ b/test/eventHandlers/WorkerInitHandler.test.ts @@ -2,19 +2,20 @@ // Licensed under the MIT License. import { expect } from 'chai'; +import * as escapeStringRegexp from 'escape-string-regexp'; import 'mocha'; -import * as mock from 'mock-fs'; +import * as mockFs from 'mock-fs'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; import { logColdStartWarning } from '../../src/eventHandlers/WorkerInitHandler'; import { WorkerChannel } from '../../src/WorkerChannel'; import { beforeEventHandlerSuite } from './beforeEventHandlerSuite'; -import { TestEventStream } from './TestEventStream'; +import { RegExpStreamingMessage, TestEventStream } from './TestEventStream'; import path = require('path'); import LogCategory = rpc.RpcLog.RpcLogCategory; import LogLevel = rpc.RpcLog.Level; namespace Msg { - export function init(functionAppDirectory?: string): rpc.IStreamingMessage { + export function init(functionAppDirectory: string = __dirname): rpc.IStreamingMessage { return { requestId: 'id', workerInitRequest: { @@ -41,6 +42,53 @@ namespace Msg { }, }; + export function failedResponse(fileName: string, errorMessage: string): RegExpStreamingMessage { + const expectedMsg: rpc.IStreamingMessage = { + requestId: 'id', + workerInitResponse: { + result: { + status: rpc.StatusResult.Status.Failure, + exception: { + message: errorMessage, + }, + }, + }, + }; + return new RegExpStreamingMessage(expectedMsg, { + 'workerInitResponse.result.exception.stackTrace': new RegExp( + `Error: ${escapeStringRegexp(errorMessage)}\\s*at` + ), + }); + } + + export const receivedInitLog: rpc.IStreamingMessage = { + rpcLog: { + message: 'Received WorkerInitRequest', + level: LogLevel.Debug, + logCategory: LogCategory.System, + }, + }; + + export function loadingEntryPoint(fileName: string): rpc.IStreamingMessage { + return { + rpcLog: { + message: `Loading entry point "${fileName}"`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }, + }; + } + + export function loadedEntryPoint(fileName: string): rpc.IStreamingMessage { + return { + rpcLog: { + message: `Loaded entry point "${fileName}"`, + level: LogLevel.Debug, + logCategory: LogCategory.System, + }, + }; + } + export function warning(message: string): rpc.IStreamingMessage { return { rpcLog: { @@ -51,6 +99,16 @@ namespace Msg { }; } + export function error(message: string): rpc.IStreamingMessage { + return { + rpcLog: { + message, + level: LogLevel.Error, + logCategory: LogCategory.System, + }, + }; + } + export const coldStartWarning: rpc.IStreamingMessage = { rpcLog: { message: @@ -70,13 +128,14 @@ describe('WorkerInitHandler', () => { }); afterEach(async () => { - mock.restore(); + mockFs.restore(); await stream.afterEachEventHandlerTest(); }); it('responds to init', async () => { + mockFs({ [__dirname]: { 'package.json': '{}' } }); stream.addTestMessage(Msg.init()); - await stream.assertCalledWith(Msg.response); + await stream.assertCalledWith(Msg.receivedInitLog, Msg.response); }); it('does not init for Node.js v8.x and v2 compatability = false', () => { @@ -103,20 +162,20 @@ describe('WorkerInitHandler', () => { const expectedPackageJson = { type: 'module', }; - mock({ + mockFs({ [appDir]: { 'package.json': JSON.stringify(expectedPackageJson), }, }); stream.addTestMessage(Msg.init(appDir)); - await stream.assertCalledWith(Msg.response); + await stream.assertCalledWith(Msg.receivedInitLog, Msg.response); expect(channel.packageJson).to.deep.equal(expectedPackageJson); }); it('loads empty package.json', async () => { const appDir = 'appDir'; - mock({ + mockFs({ [appDir]: { 'not-package-json': 'some content', }, @@ -124,7 +183,8 @@ describe('WorkerInitHandler', () => { stream.addTestMessage(Msg.init(appDir)); await stream.assertCalledWith( - Msg.warning(`Worker failed to load package.json: file does not exist.`), + Msg.receivedInitLog, + Msg.warning(`Worker failed to load package.json: file does not exist`), Msg.response ); expect(channel.packageJson).to.be.empty; @@ -132,7 +192,7 @@ describe('WorkerInitHandler', () => { it('ignores malformed package.json', async () => { const appDir = 'appDir'; - mock({ + mockFs({ [appDir]: { 'package.json': 'gArB@g3 dAtA', }, @@ -140,8 +200,9 @@ describe('WorkerInitHandler', () => { stream.addTestMessage(Msg.init(appDir)); await stream.assertCalledWith( + Msg.receivedInitLog, Msg.warning( - `Worker failed to load package.json: file is not a valid JSON: ${path.join( + `Worker failed to load package.json: file content is not valid JSON: ${path.join( appDir, 'package.json' )}: Unexpected token g in JSON at position 0` @@ -150,4 +211,72 @@ describe('WorkerInitHandler', () => { ); expect(channel.packageJson).to.be.empty; }); + + for (const extension of ['.js', '.mjs', '.cjs']) { + it(`Loads entry point (${extension})`, async () => { + const fileName = `entryPointFiles/doNothing${extension}`; + const expectedPackageJson = { + main: fileName, + }; + mockFs({ + [__dirname]: { + 'package.json': JSON.stringify(expectedPackageJson), + // 'require' and 'mockFs' don't play well together so we need these files in both the mock and real file systems + entryPointFiles: mockFs.load(path.join(__dirname, 'entryPointFiles')), + }, + }); + + stream.addTestMessage(Msg.init(__dirname)); + await stream.assertCalledWith( + Msg.receivedInitLog, + Msg.loadingEntryPoint(fileName), + Msg.loadedEntryPoint(fileName), + Msg.response + ); + }); + } + + it('Fails for missing entry point', async () => { + const fileName = 'entryPointFiles/missing.js'; + const expectedPackageJson = { + main: fileName, + }; + mockFs({ + [__dirname]: { + 'package.json': JSON.stringify(expectedPackageJson), + }, + }); + + stream.addTestMessage(Msg.init(__dirname)); + const errorMessage = `Worker was unable to load entry point "${fileName}": file does not exist`; + await stream.assertCalledWith( + Msg.receivedInitLog, + Msg.loadingEntryPoint(fileName), + Msg.error(errorMessage), + Msg.failedResponse(fileName, errorMessage) + ); + }); + + it('Fails for invalid entry point', async () => { + const fileName = 'entryPointFiles/throwError.js'; + const expectedPackageJson = { + main: fileName, + }; + mockFs({ + [__dirname]: { + 'package.json': JSON.stringify(expectedPackageJson), + // 'require' and 'mockFs' don't play well together so we need these files in both the mock and real file systems + entryPointFiles: mockFs.load(path.join(__dirname, 'entryPointFiles')), + }, + }); + + stream.addTestMessage(Msg.init(__dirname)); + const errorMessage = `Worker was unable to load entry point "${fileName}": test`; + await stream.assertCalledWith( + Msg.receivedInitLog, + Msg.loadingEntryPoint(fileName), + Msg.error(errorMessage), + Msg.failedResponse(fileName, errorMessage) + ); + }); }); diff --git a/test/eventHandlers/entryPointFiles/doNothing.cjs b/test/eventHandlers/entryPointFiles/doNothing.cjs new file mode 100644 index 00000000..2f6b2d60 --- /dev/null +++ b/test/eventHandlers/entryPointFiles/doNothing.cjs @@ -0,0 +1 @@ +// do nothing \ No newline at end of file diff --git a/test/eventHandlers/entryPointFiles/doNothing.js b/test/eventHandlers/entryPointFiles/doNothing.js new file mode 100644 index 00000000..2f6b2d60 --- /dev/null +++ b/test/eventHandlers/entryPointFiles/doNothing.js @@ -0,0 +1 @@ +// do nothing \ No newline at end of file diff --git a/test/eventHandlers/entryPointFiles/doNothing.mjs b/test/eventHandlers/entryPointFiles/doNothing.mjs new file mode 100644 index 00000000..2f6b2d60 --- /dev/null +++ b/test/eventHandlers/entryPointFiles/doNothing.mjs @@ -0,0 +1 @@ +// do nothing \ No newline at end of file diff --git a/test/eventHandlers/entryPointFiles/throwError.js b/test/eventHandlers/entryPointFiles/throwError.js new file mode 100644 index 00000000..478d9ec6 --- /dev/null +++ b/test/eventHandlers/entryPointFiles/throwError.js @@ -0,0 +1 @@ +throw new Error('test'); \ No newline at end of file diff --git a/test/loadScriptFile.test.ts b/test/loadScriptFile.test.ts new file mode 100644 index 00000000..d34e43e7 --- /dev/null +++ b/test/loadScriptFile.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as chai from 'chai'; +import 'mocha'; +import { isESModule } from '../src/loadScriptFile'; +const expect = chai.expect; + +describe('loadScriptFile', () => { + it('respects .cjs extension', () => { + const result = isESModule('test.cjs', { + type: 'module', + }); + expect(result).to.be.false; + }); + + it('respects .mjs extension', () => { + const result = isESModule('test.mjs', { + type: 'commonjs', + }); + expect(result).to.be.true; + }); + + it('respects package.json module type', () => { + const result = isESModule('test.js', { + type: 'module', + }); + expect(result).to.be.true; + }); + + it('defaults to using commonjs', () => { + expect(isESModule('test.js', {})).to.be.false; + expect( + isESModule('test.js', { + type: 'commonjs', + }) + ).to.be.false; + }); +}); diff --git a/test/parsers/parsePackageJson.test.ts b/test/parsers/parsePackageJson.test.ts new file mode 100644 index 00000000..0bbd56a4 --- /dev/null +++ b/test/parsers/parsePackageJson.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import * as chai from 'chai'; +import { expect } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import 'mocha'; +import * as mockFs from 'mock-fs'; +import { parsePackageJson } from '../../src/parsers/parsePackageJson'; +chai.use(chaiAsPromised); + +describe('parsePackageJson', () => { + const testDir = 'testDir'; + + afterEach(async () => { + mockFs.restore(); + }); + + it('normal', async () => { + mockFs({ [testDir]: { 'package.json': '{ "main": "index.js", "type": "commonjs" }' } }); + await expect(parsePackageJson(testDir)).to.eventually.deep.equal({ main: 'index.js', type: 'commonjs' }); + }); + + it('invalid type', async () => { + mockFs({ [testDir]: { 'package.json': '{ "main": "index.js", "type": {} }' } }); + await expect(parsePackageJson(testDir)).to.eventually.deep.equal({ main: 'index.js' }); + }); + + it('invalid main', async () => { + mockFs({ [testDir]: { 'package.json': '{ "main": 55, "type": "commonjs" }' } }); + await expect(parsePackageJson(testDir)).to.eventually.deep.equal({ type: 'commonjs' }); + }); + + it('missing file', async () => { + await expect(parsePackageJson(testDir)).to.be.rejectedWith('file does not exist'); + }); + + it('empty', async () => { + mockFs({ [testDir]: { 'package.json': '' } }); + await expect(parsePackageJson(testDir)).to.be.rejectedWith(/^file content is not valid JSON:/); + }); + + it('missing bracket', async () => { + mockFs({ [testDir]: { 'package.json': '{' } }); + await expect(parsePackageJson(testDir)).to.be.rejectedWith(/^file content is not valid JSON:/); + }); + + it('null', async () => { + mockFs({ [testDir]: { 'package.json': 'null' } }); + await expect(parsePackageJson(testDir)).to.be.rejectedWith('file content is not an object'); + }); + + it('array', async () => { + mockFs({ [testDir]: { 'package.json': '[]' } }); + await expect(parsePackageJson(testDir)).to.be.rejectedWith('file content is not an object'); + }); +});