diff --git a/packages/create-plugin/__e2e__/e2e.test.ts b/packages/create-plugin/__e2e__/e2e.test.ts new file mode 100644 index 0000000000..b1290902d7 --- /dev/null +++ b/packages/create-plugin/__e2e__/e2e.test.ts @@ -0,0 +1,84 @@ +import assert from "assert"; +import type { QuestionInput } from "./utils/executeCommand"; +import { executeCommandWithInteractiveInput } from "./utils/executeCommand"; +import { + CREATE_PLUGIN_COMMAND, + DEFAULT_ANSWER, + ANSWER_NO, +} from "./utils/constants"; +import path from "path"; +import { generateWorkingDir } from "./utils/generateWorkingDir"; +import fs from "fs"; +import { rimrafSync } from "rimraf"; +import { + assertObjectIncludes, + readPluginManifestJson, +} from "./utils/verification"; +import { getBoundMessage } from "../src/messages"; + +describe("create-plugin", function () { + let workingDir: string; + beforeEach(() => { + workingDir = generateWorkingDir(); + console.log(`Working directory: ${workingDir}`); + }); + + it("#JsSdkTest-1 Should able to create a plugin with specified output directory and required options successfully", async () => { + const m = getBoundMessage("en"); + const outputDir = "test1"; + const questionsInput: QuestionInput[] = [ + { + question: m("Q_NameEn"), + answer: "test1-name", + }, + { + question: m("Q_DescriptionEn"), + answer: "test1-description", + }, + { + question: m("Q_SupportJa"), + answer: DEFAULT_ANSWER, + }, + { + question: m("Q_SupportZh"), + answer: DEFAULT_ANSWER, + }, + { + question: m("Q_websiteUrlEn"), + answer: DEFAULT_ANSWER, + }, + { + question: m("Q_MobileSupport"), + answer: ANSWER_NO, + }, + { + question: m("Q_enablePluginUploader"), + answer: ANSWER_NO, + }, + ]; + + const response = await executeCommandWithInteractiveInput({ + command: CREATE_PLUGIN_COMMAND, + workingDir, + outputDir, + questionsInput, + }); + + assert(response.status === 0, "Failed to create plugin"); + + const pluginDir = path.resolve(workingDir, outputDir); + assert.ok(fs.existsSync(pluginDir), "plugin dir is not created."); + + const actualManifestJson = readPluginManifestJson(pluginDir); + const expectedManifestJson = { + name: { en: "test1-name" }, + description: { en: "test1-description" }, + }; + assertObjectIncludes(actualManifestJson, expectedManifestJson); + }); + + afterEach(() => { + rimrafSync(workingDir); + console.log(`Working directory ${workingDir} has been removed`); + }); +}); diff --git a/packages/create-plugin/__e2e__/utils/constants.ts b/packages/create-plugin/__e2e__/utils/constants.ts new file mode 100644 index 0000000000..aca2e484a0 --- /dev/null +++ b/packages/create-plugin/__e2e__/utils/constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_ANSWER = ""; +export const ANSWER_YES = "Yes"; +export const ANSWER_NO = "No"; + +export const CREATE_PLUGIN_COMMAND = "create-plugin"; +export const CREATE_KINTONE_PLUGIN_COMMAND = "create-kintone-plugin"; diff --git a/packages/create-plugin/__e2e__/utils/executeCommand.ts b/packages/create-plugin/__e2e__/utils/executeCommand.ts new file mode 100644 index 0000000000..956464805c --- /dev/null +++ b/packages/create-plugin/__e2e__/utils/executeCommand.ts @@ -0,0 +1,147 @@ +import { spawn } from "child_process"; +import fs from "fs"; +import path from "path"; + +export type Response = { + status: number; + stdout: Buffer; + stderr: Buffer; + error?: Error; +}; + +export type QuestionInput = { + question: string; + answer: string; +}; + +export const getCommands = (): { [key: string]: string } => { + const packageJson = JSON.parse( + fs.readFileSync(path.resolve("package.json"), "utf8"), + ); + return Object.fromEntries( + Object.entries(packageJson.bin).map(([command, relativePath]) => { + return [command, path.resolve(relativePath as string)]; + }), + ); +}; + +export const execCommand = ( + command: string, + args: string, + options: { + env?: { [key: string]: string }; + cwd?: string; + }, +) => { + return spawn(command, parseArgs(args, options?.env), { + stdio: ["pipe", "pipe", "pipe"], + env: options?.env ?? process.env, + cwd: options?.cwd ?? process.cwd(), + shell: true, + }); +}; + +const parseArgs = (args: string, envVars?: { [key: string]: string }) => { + const replacedArgs = replaceTokenWithEnvVars(args, envVars).match( + /(?:[^\s'"]+|"[^"]*"|'[^']*')+/g, + ); + + if (!replacedArgs) { + throw new Error("Failed to parse command arguments."); + } + + return replacedArgs.map((arg) => arg.replace(/^['"]|['"]$/g, "")); +}; + +const replaceTokenWithEnvVars = ( + input: string, + envVars: { [key: string]: string } | undefined, +) => + input + .replace(/\$\$[a-zA-Z0-9_]+/g, processEnvReplacer) + .replace(/\$[a-zA-Z0-9_]+/g, inputEnvReplacer(envVars)); + +export const executeCommandWithInteractiveInput = async (options: { + command: string; + workingDir: string; + outputDir: string; + questionsInput: QuestionInput[]; + commandArguments?: string; +}) => { + const { command, workingDir, outputDir, questionsInput, commandArguments } = + options; + const commands = getCommands(); + if (!commands[command]) { + throw new Error(`Command ${command} not found.`); + } + + const commandString = `${commands[command]} ${commandArguments || ""} ${outputDir}`; + const cliProcess = execCommand("node", commandString, { + cwd: workingDir, + }); + + let stdout: Buffer; + let stderr: Buffer; + + const cliExitPromise = new Promise((resolve, reject) => { + cliProcess.on("exit", (code: number) => { + resolve({ + status: code, + stdout: stdout ?? Buffer.from(""), + stderr: stderr ?? Buffer.from(""), + }); + }); + cliProcess.on("error", (error) => { + reject({ + error, + }); + }); + }); + + let currentStep = 0; + cliProcess.stdout.on("data", async (data: Buffer) => { + const output = data.toString(); + stdout = stdout ? Buffer.concat([stdout, data]) : data; + if (currentStep === questionsInput.length || !questionsInput[currentStep]) { + cliProcess.stdin.end(); + return; + } + + if (output.includes(questionsInput[currentStep].question)) { + cliProcess.stdin.write(questionsInput[currentStep].answer); + cliProcess.stdin.write("\n"); + currentStep++; + } + }); + + cliProcess.stderr.on("data", async (data: Buffer) => { + stderr = data; + cliProcess.stdin.end(); + }); + + return cliExitPromise; +}; + +const processEnvReplacer = (substring: string) => { + const key = substring.replace("$$", ""); + const value = process.env[key]; + if (value === undefined) { + throw new Error(`The env variable in process.env is missing: ${key}`); + } + return value; +}; + +const inputEnvReplacer = (envVars: { [key: string]: string } | undefined) => { + return (substring: string) => { + if (!envVars) { + return substring; + } + + const key = substring.replace("$", ""); + const value = envVars[key]; + if (value === undefined) { + throw new Error(`The env variable in input parameter is missing: ${key}`); + } + return value; + }; +}; diff --git a/packages/create-plugin/__e2e__/utils/generateWorkingDir.ts b/packages/create-plugin/__e2e__/utils/generateWorkingDir.ts new file mode 100644 index 0000000000..ef37c544a0 --- /dev/null +++ b/packages/create-plugin/__e2e__/utils/generateWorkingDir.ts @@ -0,0 +1,9 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; + +export const generateWorkingDir = (): string => { + return fs.mkdtempSync( + path.join(os.tmpdir(), `create-plugin-e2e-test-${new Date().valueOf()}-`), + ); +}; diff --git a/packages/create-plugin/__e2e__/utils/helper.ts b/packages/create-plugin/__e2e__/utils/helper.ts new file mode 100644 index 0000000000..d66c2f6c58 --- /dev/null +++ b/packages/create-plugin/__e2e__/utils/helper.ts @@ -0,0 +1,12 @@ +export const generateRandomString = (length: number) => { + let result = ""; + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; +}; diff --git a/packages/create-plugin/__e2e__/utils/verification.ts b/packages/create-plugin/__e2e__/utils/verification.ts new file mode 100644 index 0000000000..452be9048f --- /dev/null +++ b/packages/create-plugin/__e2e__/utils/verification.ts @@ -0,0 +1,35 @@ +import fs from "fs"; +import path from "path"; +import assert from "assert"; + +type PluginTemplate = "minimum" | "modern"; + +export const readPluginManifestJson = ( + pluginDir: string, + template: PluginTemplate = "minimum", +) => { + try { + const manifestJsonPath = path.resolve( + pluginDir, + template === "modern" ? "plugin" : "src", + "manifest.json", + ); + + const fileContent = fs.readFileSync(manifestJsonPath, "utf8"); + return JSON.parse(fileContent); + } catch (e) { + throw new Error(`Failed to read manifest.json\n${e}`); + } +}; + +export const assertObjectIncludes = ( + actual: { [key: PropertyKey]: unknown }, + expected: { [key: PropertyKey]: unknown }, + message?: string | Error, +) => { + for (const key in expected) { + if (Object.prototype.hasOwnProperty.call(expected, key)) { + assert.deepStrictEqual(actual[key], expected[key], message); + } + } +}; diff --git a/packages/create-plugin/jest.e2e.config.js b/packages/create-plugin/jest.e2e.config.js new file mode 100644 index 0000000000..933b22e058 --- /dev/null +++ b/packages/create-plugin/jest.e2e.config.js @@ -0,0 +1,8 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +const config = { + roots: [""], + testRegex: "/__e2e__/.*\\.test\\.ts$", + testEnvironment: "node", + testTimeout: 120000, +}; +module.exports = config; diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json index 9fadbeca4d..58072e3065 100644 --- a/packages/create-plugin/package.json +++ b/packages/create-plugin/package.json @@ -53,7 +53,8 @@ "e2e": "cross-env NODE_ENV=e2e jest --testPathPattern=/__tests__/e2e\\.test\\.ts$", "test": "pnpm unittest", "test:all": "run-p test e2e", - "test:ci": "pnpm test:all" + "test:ci": "pnpm test:all", + "test:e2e": "cross-env NODE_ENV=e2e jest --config=jest.e2e.config.js" }, "keywords": [ "kintone"