diff --git a/.gitignore b/.gitignore index 4d74e971..07ac93fa 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ experiments coverage test-results.xml .history +/test/test-homedir # default fauna config file names fauna.config.yaml, diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index a08f7333..f4878362 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -1,5 +1,6 @@ //@ts-check +import path from "node:path"; import repl from "node:repl"; import { container } from "../cli.mjs"; @@ -7,15 +8,28 @@ import { // ensureDbScopeClient, commonConfigurableQueryOptions, } from "../lib/command-helpers.mjs"; +import { dirExists, fileExists } from "../lib/file-util.mjs"; import { performQuery } from "./eval.mjs"; async function doShell(argv) { + const fs = container.resolve("fs"); const logger = container.resolve("logger"); let completionPromise; if (argv.dbPath) logger.stdout(`Starting shell for database ${argv.dbPath}`); logger.stdout("Type Ctrl+D or .exit to exit the shell"); + // Setup history file + const homedir = container.resolve("homedir"); + const historyDir = path.join(homedir.toString(), ".fauna"); + if (!dirExists(historyDir)) { + fs.mkdirSync(historyDir, { recursive: true }); + } + const historyFile = path.join(historyDir, "history"); + if (!fileExists(historyFile)) { + fs.writeFileSync(historyFile, ""); + } + /** @type {import('node:repl').ReplOptions} */ const replArgs = { prompt: `${argv.db_path || ""}> `, @@ -27,9 +41,18 @@ async function doShell(argv) { input: container.resolve("stdinStream"), eval: await buildCustomEval(argv), terminal: true, + historySize: 1000 }; const shell = repl.start(replArgs); + + // Setup history + shell.setupHistory(historyFile, (err) => { + if (err) { + logger.stderr(`Error setting up history: ${err.message}`); + } + }); + // eslint-disable-next-line no-console shell.on("error", console.error); @@ -55,6 +78,25 @@ async function doShell(argv) { shell.prompt(); }, }, + { + cmd: "clearhistory", + help: "Clear command history", + action: () => { + try { + fs.writeFileSync(historyFile, ''); + logger.stdout('History cleared'); + // Reinitialize history + shell.setupHistory(historyFile, (err) => { + if (err) { + logger.stderr(`Error reinitializing history: ${err.message}`); + } + }); + } catch (err) { + logger.stderr(`Error clearing history: ${err.message}`); + } + shell.prompt(); + }, + } ].forEach(({ cmd, ...cmdOptions }) => shell.defineCommand(cmd, cmdOptions)); return completionPromise; diff --git a/src/config/setup-container.mjs b/src/config/setup-container.mjs index adc8ae35..009e6723 100644 --- a/src/config/setup-container.mjs +++ b/src/config/setup-container.mjs @@ -16,7 +16,7 @@ import { makeAccountRequest } from "../lib/account.mjs"; import OAuthClient from "../lib/auth/oauth-client.mjs"; import { getSimpleClient } from "../lib/command-helpers.mjs"; import { makeFaunaRequest } from "../lib/db.mjs"; -import { getV10Client,runV10Query } from "../lib/fauna.mjs"; +import { getV10Client, runV10Query } from "../lib/fauna.mjs"; import { FaunaAccountClient } from "../lib/fauna-account-client.mjs"; import fetchWrapper from "../lib/fetch-wrapper.mjs"; import { AccountKey, SecretKey } from "../lib/file-util.mjs"; diff --git a/src/config/setup-test-container.mjs b/src/config/setup-test-container.mjs index eac75672..345609cc 100644 --- a/src/config/setup-test-container.mjs +++ b/src/config/setup-test-container.mjs @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { normalize } from "node:path"; +import path from "node:path"; import { PassThrough } from "node:stream"; import * as awilix from "awilix"; @@ -38,10 +38,12 @@ function confirmManualMocks(manualMocks, thingsToManuallyMock) { } export function setupTestContainer() { + const __dirname = import.meta.dirname; + const container = setupCommonContainer(); const thingsToManuallyMock = automock(container); - const customfs = stub(fs); + const customfs = stub({ ...fs }); // this is a mock used by the default profile behavior customfs.readdirSync.withArgs(process.cwd()).returns([]); @@ -51,6 +53,11 @@ export function setupTestContainer() { stdoutStream: awilix.asClass(InMemoryWritableStream).singleton(), stderrStream: awilix.asClass(InMemoryWritableStream).singleton(), + // test home directory + homedir: awilix + .asFunction(() => path.join(__dirname, "../../test/test-homedir")) + .scoped(), + // wrap it in a spy so we can record calls, but use the // real implementation parseYargs: awilix.asValue(spy(parseYargs)), @@ -73,7 +80,7 @@ export function setupTestContainer() { error.code = exitCode; throw error; }), - normalize: awilix.asValue(spy(normalize)), + normalize: awilix.asValue(spy(path.normalize)), fetch: awilix.asValue(stub().resolves(f({}))), gatherFSL: awilix.asValue(stub().resolves([])), makeFaunaRequest: awilix.asValue(spy(makeFaunaRequest)), diff --git a/src/lib/file-util.mjs b/src/lib/file-util.mjs index b1ecd300..e45dff27 100644 --- a/src/lib/file-util.mjs +++ b/src/lib/file-util.mjs @@ -1,6 +1,4 @@ //@ts-check - -import fs from "node:fs"; import path from "node:path"; import { container } from "../cli.mjs"; @@ -22,6 +20,7 @@ export function fixPath(path) { * @returns {boolean} */ export function dirExists(path) { + const fs = container.resolve("fs"); const stat = fs.statSync(fixPath(path), { // returns undefined instead of throwing if the file doesn't exist throwIfNoEntry: false, @@ -39,6 +38,7 @@ export function dirExists(path) { * @returns {boolean} */ export function dirIsWriteable(path) { + const fs = container.resolve("fs"); try { fs.accessSync(fixPath(path), fs.constants.W_OK); } catch (e) { @@ -54,7 +54,8 @@ export function dirIsWriteable(path) { * @param {string} path - The path to the file. * @returns {boolean} - Returns true if the file exists, otherwise false. */ -function fileExists(path) { +export function fileExists(path) { + const fs = container.resolve("fs"); try { fs.readFileSync(fixPath(path)); return true; @@ -69,6 +70,7 @@ function fileExists(path) { * @returns {Object.} - The parsed JSON content of the file. */ function getJSONFileContents(path) { + const fs = container.resolve("fs"); // Open file for reading and writing without truncating const fileContent = fs.readFileSync(path, { flag: "r+" }).toString(); if (!fileContent) { @@ -103,11 +105,13 @@ export class Credentials { * @param {string} [filename=""] - The name of the credentials file. */ constructor(filename = "") { + const fs = container.resolve("fs"); + this.logger = container.resolve("logger"); this.filename = filename; const homedir = container.resolve("homedir"); - this.credsDir = path.join(homedir.toString(),".fauna/credentials"); + this.credsDir = path.join(homedir.toString(), ".fauna/credentials"); if (!dirExists(this.credsDir)) { fs.mkdirSync(this.credsDir, { recursive: true }); @@ -146,6 +150,8 @@ export class Credentials { * @param {string} params.key - The key to index the creds under */ save({ creds, overwrite = false, key }) { + const fs = container.resolve("fs"); + try { const existingContent = overwrite ? {} : this.get(); const newContent = { @@ -159,6 +165,8 @@ export class Credentials { } delete(key) { + const fs = container.resolve("fs"); + try { const existingContent = this.get(); delete existingContent[key]; @@ -193,6 +201,8 @@ export class SecretKey extends Credentials { * @param {string} opts.creds.role - The role to save the secret */ this.save = ({ creds, overwrite = false, key }) => { + const fs = container.resolve("fs"); + try { const existingContent = overwrite ? {} : this.get(); const existingAccountSecrets = existingContent[key] || {}; diff --git a/test/authNZ.mjs b/test/authNZ.mjs index 6a62d4a5..7303b289 100644 --- a/test/authNZ.mjs +++ b/test/authNZ.mjs @@ -1,5 +1,4 @@ //@ts-check -import path from "node:path"; import * as awilix from "awilix"; import { expect } from "chai"; @@ -31,13 +30,9 @@ describe.skip("authNZMiddleware", function () { }; beforeEach(() => { - const __dirname = import.meta.dirname; - const homedir = path.join(__dirname, "./test-homedir"); - container = setupContainer(); container.register({ accountClient: awilix.asFunction(mockAccountClient).scoped(), - homedir: awilix.asFunction(() => homedir).scoped(), }); fetch = container.resolve("fetch"); }); diff --git a/test/config.mjs b/test/config.mjs index 2c553d21..ad060bc2 100644 --- a/test/config.mjs +++ b/test/config.mjs @@ -4,13 +4,13 @@ import path from "node:path"; import * as awilix from "awilix"; import { expect } from "chai"; +import chalk from "chalk"; import notAllowed from "not-allowed"; import sinon from "sinon"; import { builtYargs, run } from "../src/cli.mjs"; import { performQuery, performV10Query } from "../src/commands/eval.mjs"; import { setupTestContainer as setupContainer } from "../src/config/setup-test-container.mjs"; -import chalk from "chalk"; import { validDefaultConfigNames } from "../src/lib/config/config.mjs"; const __dirname = import.meta.dirname; diff --git a/test/login.mjs b/test/login.mjs index 1eb16094..3bccfa65 100644 --- a/test/login.mjs +++ b/test/login.mjs @@ -1,5 +1,4 @@ //@ts-check -import path from "node:path"; import * as awilix from "awilix"; import { expect } from "chai"; @@ -63,15 +62,11 @@ describe("login", function () { }; beforeEach(() => { - const __dirname = import.meta.dirname; - const homedir = path.join(__dirname, "./test-homedir"); - container = setupContainer(); container.register({ oauthClient: awilix.asFunction(mockOAuth).scoped(), accountClient: awilix.asFunction(mockAccountClient).scoped(), accountCreds: awilix.asClass(AccountKey).scoped(), - homedir: awilix.asFunction(() => homedir).scoped(), }); fs = container.resolve("fs"); }); diff --git a/test/schema/diff.mjs b/test/schema/diff.mjs index d2643baa..627e726f 100644 --- a/test/schema/diff.mjs +++ b/test/schema/diff.mjs @@ -148,11 +148,10 @@ describe("schema diff", function () { it("can parse home directory paths", async function () { const homedir = container.resolve("homedir"); - homedir.returns("/Users/test-user"); await run(`schema diff --secret "secret" --dir ~`, container); - expect(gatherFSL).to.have.been.calledWith("/Users/test-user"); + expect(gatherFSL).to.have.been.calledWith(homedir); }); it.skip("errors if user provides both --staged and --active flags"); diff --git a/test/shell.mjs b/test/shell.mjs index 9e9c15ab..db91ec2b 100644 --- a/test/shell.mjs +++ b/test/shell.mjs @@ -1,12 +1,16 @@ //@ts-check +import node_fs from "node:fs"; import { EOL } from "node:os"; +import path from "node:path"; +import * as awilix from "awilix"; import { expect } from "chai"; import sinon from "sinon"; import { run } from "../src/cli.mjs"; import { setupTestContainer as setupContainer } from "../src/config/setup-test-container.mjs"; +import { dirExists } from "../src/lib/file-util.mjs"; // this is defined up here so the indentation doesn't make it harder to use :( const v10Object1 = `{ @@ -43,12 +47,27 @@ const v4Object2 = `{ ] }`; +const sleep = async (ms) => + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); + describe("shell", function () { let container, stdin, stdout, logger; - let prompt = `${EOL}\x1B[1G\x1B[0J> \x1B[3G`; + + const promptReset = "\x1B[1G\x1B[0J> "; + const prompt = `${EOL}${promptReset}\x1B[3G`; + const getHistoryPrompt = (text) => + `${promptReset}${text}\u001b[${3 + text.length}G`; beforeEach(() => { container = setupContainer(); + + // we need to use the actual node fs, not the mock + container.register({ + fs: awilix.asValue(node_fs), + }); + stdin = container.resolve("stdinStream"); stdout = container.resolve("stdoutStream"); logger = container.resolve("logger"); @@ -89,6 +108,144 @@ describe("shell", function () { it.skip("can read input from a file", async function () {}); it.skip("can set a connection timeout", async function () {}); + + describe("history", function () { + const upArrow = "\x1b[A"; + const downArrow = "\x1b[B"; + + const registerHomedir = (container, subdir = "") => { + const __dirname = import.meta.dirname; + const homedir = path.join(__dirname, "../test/test-homedir", subdir); + + container.register({ + homedir: awilix.asFunction(() => homedir).scoped(), + }); + }; + + it("can be navigated through", async function () { + registerHomedir(container, "track-history"); + + // start the shell + const runPromise = run( + `shell --secret "secret" --typecheck`, + container, + ); + + // send our first command + stdin.push(`1\n2\n3\n`); + await stdout.waitForWritten(); + + // navigate up through history + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("3")); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("2")); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("1")); + stdout.clear(); + stdin.push(downArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("2")); + stdout.clear(); + stdin.push(downArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("3")); + + expect(container.resolve("stderrStream").getWritten()).to.equal(""); + + stdin.push(null); + + return runPromise; + }); + + it("can be cleared", async function () { + registerHomedir(container, "clear-history"); + + // start the shell + const runPromise = run( + `shell --secret "secret" --typecheck`, + container, + ); + + // send our first command + stdin.push("4\n5\n6\n"); + await stdout.waitForWritten(); + + const command = ".clearhistory"; + const expected = `${command}\r\nHistory cleared${prompt}`; + + // confirm feedback that .clearhistory command was run + stdout.clear(); + stdin.push(`${command}\n`); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(expected); + + // sleep to allow time for history to be cleared + await sleep(100); + + // Confirm that history is indeed cleared + // When there is no history to flip thorugh, stdout will not be written + // to. Allow some time for stdout to change to catch issues where stdout + // is written to. + stdin.push(upArrow); + await Promise.any([stdout.waitForWritten(), sleep(100)]); + expect(stdout.getWritten()).to.equal(expected); + + expect(container.resolve("stderrStream").getWritten()).to.equal(""); + + stdin.push(null); + + return runPromise; + }); + + it("can be persisted between sessions", async function () { + registerHomedir(container, "persist-history"); + + // create history file + // NOTE: this would be more precise if we could run multiple shell + // sessions, but there are complications trying to reset stdin after + // pushing null. + const fs = container.resolve("fs"); + const homedir = container.resolve("homedir"); + if (!dirExists(path.join(homedir, ".fauna"))) { + fs.mkdirSync(path.join(homedir, ".fauna"), { recursive: true }); + } + fs.writeFileSync(path.join(homedir, ".fauna/history"), "9\n8\n7\n"); + + // start the shell + const runPromise = run( + `shell --secret "secret" --typecheck`, + container, + ); + + // navigate up through history + await stdout.waitForWritten(); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("9")); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("8")); + stdout.clear(); + stdin.push(upArrow); + await stdout.waitForWritten(); + expect(stdout.getWritten()).to.equal(getHistoryPrompt("7")); + + expect(container.resolve("stderrStream").getWritten()).to.equal(""); + + stdin.push(null); + + return runPromise; + }); + }); }); describe("v10", function () {