From a26c6f5a6968ae3ec8fedb3f6437f3e5b78c3dbe Mon Sep 17 00:00:00 2001 From: Shaun Burdick Date: Thu, 22 Aug 2024 22:20:10 -0400 Subject: [PATCH] Breaking out commands to make testing easier --- src/Command.test.tsx | 214 +++++++++++++++++++++++++++++++++++++++ src/Command.tsx | 167 ++++++++++++++++++++++++++++++ src/ShellPrompt.test.tsx | 55 ++++++++++ src/ShellPrompt.tsx | 156 +++------------------------- 4 files changed, 449 insertions(+), 143 deletions(-) create mode 100644 src/Command.test.tsx create mode 100644 src/Command.tsx diff --git a/src/Command.test.tsx b/src/Command.test.tsx new file mode 100644 index 0000000..911a85c --- /dev/null +++ b/src/Command.test.tsx @@ -0,0 +1,214 @@ +import { CommandContext, commandsWithContext } from './Command'; +import { displayUser, User } from './Users'; + +describe('Command', () => { + function buildContext(): CommandContext { + return { + commandHistory: [], + environment: new Map(), + setConsoleLines: jest.fn(), + setEnvironment: jest.fn(), + setLastCommand: jest.fn(), + users: new Map([['test', { name: 'test' }]]), + workingDir: '', + }; + } + + test('Get a command map', () => { + expect(commandsWithContext(buildContext())).toBeDefined(); + }); + + test('clear', (done) => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const response = commands.get('clear')?.run(); + + expect(response).toEqual([]); + + setTimeout(() => { + expect(ctx.setConsoleLines).toHaveBeenCalledTimes(1); + expect(ctx.setLastCommand).toHaveBeenCalledTimes(1); + expect(ctx.setLastCommand).toHaveBeenCalledWith(undefined); + + done(); + }); + }); + + test('env', () => { + const ctx = buildContext(); + ctx.environment.set('foo', 'bar'); + ctx.environment.set('fizz', 'buzz'); + const commands = commandsWithContext(ctx); + + const response = commands.get('env')?.run(); + + expect(response).toEqual([ + ['foo=bar'], + ['fizz=buzz'] + ]); + }); + + test('export', () => { + const ctx = buildContext(); + ctx.environment.set('foo', 'bar'); + ctx.environment.set('fizz', 'buzz'); + const commands = commandsWithContext(ctx); + + const response = commands.get('export')?.run('key', 'value'); + + expect(response).toEqual([ + ['key=value'] + ]); + }); + + test('help (no args)', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const response = commands.get('help')?.run(); + + expect(Array.isArray(response)).toBe(true); + + // make sure no secret commands are listed + expect(response?.filter(line => (line[0] as string).startsWith('secret'))).toEqual([]); + }); + + test('help (with args)', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const help = commands.get('help'); + + expect(help?.run('clear')).toEqual([[commands.get('clear')?.description]]); + expect(help?.run('secret')).toEqual([['I\'m not helping you. It\'s a secret!']]); + expect(help?.run('doesnotexist')).toEqual([['Unknown command: doesnotexist']]); + }); + + test('history', () => { + const ctx = buildContext(); + ctx.commandHistory.push('foo', 'bar'); + const commands = commandsWithContext(ctx); + + const response = commands.get('history')?.run(); + + expect(response).toEqual([ + ['1: foo'], + ['2: bar'] + ]); + }); + + test('open', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const open = commands.get('open'); + + expect(open?.run('http://foo.com')).toEqual([ + ['Opening http://foo.com...'] + ]); + + expect(open?.run('ftp://foo.com')).toEqual([ + ['Unknown protocol: ftp:'] + ]); + + expect(open?.run('floopity/ss/s/s/s')).toEqual([ + ['Cannot open: floopity/ss/s/s/s'] + ]); + }); + + test('pwd', () => { + const ctx = buildContext(); + ctx.workingDir = 'test'; + const commands = commandsWithContext(ctx); + + const response = commands.get('pwd')?.run(); + + expect(response).toEqual([ + ['test'] + ]); + }); + + test('rm', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const response = commands.get('rm')?.run(); + + expect(response).toEqual([ + ['rm never gonna give you up!'] + ]); + }); + + test('secret', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const response = commands.get('secret')?.run(); + + expect(response).toEqual([ + ['You found it!'] + ]); + }); + + test('users', () => { + const ctx = buildContext(); + ctx.users.set('test', { name: 'test' }); + const commands = commandsWithContext(ctx); + + const response = commands.get('users')?.run(); + + expect(response).toEqual([ + ['test'] + ]); + }); + + test('view-source', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const response = commands.get('view-source')?.run(); + + expect(response).toEqual([ + ['Opening GH Page...'] + ]); + }); + + test('whoami', () => { + const ctx = buildContext(); + const commands = commandsWithContext(ctx); + + const response = commands.get('whoami')?.run(); + + expect(response).toEqual([ + ['You\'re you, silly'] + ]); + }); + + test('whois', () => { + const ctx = buildContext(); + ctx.users.set('test', { name: 'test' }); + const commands = commandsWithContext(ctx); + + const whois = commands.get('whois'); + + expect(whois?.run('test')).toEqual( + displayUser(ctx.users.get('test') as User) + ); + + expect(whois?.run('miki')).toEqual([ + ['Hello, miki'] + ]); + + const gfResponse = whois?.run('gamefront'); + expect(Array.isArray(gfResponse)).toBe(true); + + expect(whois?.run('unknown_user')).toEqual([ + ['Unknown user: unknown_user'] + ]); + + expect(whois?.run()).toEqual([ + ['Unknown user: '] + ]); + }); +}); diff --git a/src/Command.tsx b/src/Command.tsx new file mode 100644 index 0000000..bc1df2e --- /dev/null +++ b/src/Command.tsx @@ -0,0 +1,167 @@ +import { CommandResult, ConsoleLine } from './ConsoleOutput'; +import { displayUser, User } from './Users'; + +export interface Command { + description: string; + secret?: boolean; + run: (...args: string[]) => ConsoleLine[]; +} + +export interface CommandContext { + commandHistory: string[]; + environment: Map; + setConsoleLines: React.Dispatch>; + setEnvironment: React.Dispatch>>; + setLastCommand: React.Dispatch>; + users: Map; + workingDir: string; +} + +export const commandsWithContext = ({ + commandHistory, + environment, + setConsoleLines, + setEnvironment, + setLastCommand, + users, + workingDir, +}: CommandContext): Map => { + /** + * A map of commands available to run + */ + const COMMANDS = new Map(); + + COMMANDS.set('clear', { + description: 'Clears the screen', + run: () => { + // use setTimeout to clear screen after this loop is finished + setTimeout(() => { + setConsoleLines([]); + setLastCommand(undefined); + }); + + return []; + } + }); + + COMMANDS.set('env', { + description: 'Print Environment', + run: () => { + const response: ConsoleLine[] = []; + + environment.forEach((v, k) => { + response.push([`${k}=${v}`]); + }); + + return response; + } + }); + + COMMANDS.set('export', { + description: 'Set an environment variable', + run: (key, value) => { + setEnvironment({ ...environment, [key]: value }); + return [[`${key}=${value}`]]; + } + }); + + COMMANDS.set('help', { + description: 'Provides a list of commands. Usage: `help [command]`', + run: (command?: string) => { + if (command) { + if (COMMANDS.get(command)?.secret) { + return [['I\'m not helping you. It\'s a secret!']]; + } else { + return [[COMMANDS.get(command)?.description || `Unknown command: ${command}`]]; + } + } else { + return [ + ['List of Commands:'], + ...[...COMMANDS] + .filter(cmd => !cmd[1].secret) + .map(([commandName, commandInfo]) => [`${commandName}:`, commandInfo.description]) + ]; + } + } + }); + + COMMANDS.set('history', { + description: 'Show previous commands', + run: () => commandHistory.map((command, index) => [`${index + 1}: ${command}`]) + }); + + COMMANDS.set('open', { + description: 'Open a file or URL', + run: (target) => { + try { + const url = new URL(target); + if (['http:', 'https:'].includes(url.protocol)) { + window.open(target); + return [[`Opening ${target}...`]]; + } else { + return [[`Unknown protocol: ${url.protocol}`]]; + } + } catch { + return [[`Cannot open: ${target}`]]; + } + } + }); + + COMMANDS.set('pwd', { + description: 'Return the working directory', + run: () => [[workingDir]] + }); + + COMMANDS.set('rm', { + description: 'Remove directory entries', + run: () => { + window.open('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + return [['rm never gonna give you up!']]; + } + }); + + COMMANDS.set('secret', { + description: 'A secret command', + secret: true, + run: () => [['You found it!']] + }); + + COMMANDS.set('users', { + description: 'List users', + run: () => [...users.keys()].map(userName => [userName]) + }); + + COMMANDS.set('view-source', { + description: 'View the source of this app', + run: () => { + window.open('https://github.com/shaunburdick/shaunburdick.com'); + return [['Opening GH Page...']]; + } + }); + + COMMANDS.set('whoami', { + description: 'Tell you a little about yourself', + run: () => [ + ['You\'re you, silly'] + ] + }); + + COMMANDS.set('whois', { + description: 'Tell you a little about a user. Usage: `whois `', + run: (username: string) => { + const user = users.get(username); + if (user) { + return displayUser(user); + } else if(/miki|mikey|faktrl/.test(username)) { + window.open('https://www.youtube.com/watch?v=YjyUIwKPAxA'); + return [[`Hello, ${username}`]]; + } else if (username === 'gamefront') { + return [[Gamefront, 'is just FilesNetwork with a better skin']]; + } else { + return [[`Unknown user: ${username || ''}`]]; + } + } + }); + + return COMMANDS; +}; diff --git a/src/ShellPrompt.test.tsx b/src/ShellPrompt.test.tsx index ff2bfe4..40adbe5 100644 --- a/src/ShellPrompt.test.tsx +++ b/src/ShellPrompt.test.tsx @@ -9,6 +9,13 @@ describe('ShellPrompt', () => { expect(document.body.querySelector('.shell')).toBeInTheDocument(); }); + test('Rest command history if invalid', () => { + localStorage.setItem(LS_KEY_COMMAND_HISTORY, '"'); + + act(() => render()); + expect(localStorage.getItem(LS_KEY_COMMAND_HISTORY)).toBe('[]'); + }); + describe('Commands', () => { describe('history', () => { test('should save your history', async () => { @@ -86,4 +93,52 @@ describe('ShellPrompt', () => { expect(input?.value).toEqual('whois shaun'); }); }); + + describe('Keyboard', () => { + describe('tab completions', () => { + test('should prevent loss of focus on the input if there is content', async () => { + userEvent.setup(); + act(() => render()); + + const cmdInput = document.querySelector('#console-input'); + expect(cmdInput).not.toBeNull(); + + // Should be focused on input + expect(document.activeElement).toEqual(cmdInput); + + // type a command and try to tab out + await userEvent.keyboard('command1{Tab}'); + + // Should be focused on input + expect(document.activeElement).toEqual(cmdInput); + + // clear input + (cmdInput as HTMLInputElement).value = ''; + + await userEvent.keyboard('{Tab}'); + + // Should not be focused on input + expect(document.activeElement).not.toEqual(cmdInput); + }); + }); + + describe('escape', () => { + test('should clear the input', async () => { + userEvent.setup(); + act(() => render()); + + const cmdInput = document.querySelector('#console-input'); + expect(cmdInput).not.toBeNull(); + + // type a command + await userEvent.keyboard('command1'); + expect((cmdInput as HTMLInputElement).value).toEqual('command1'); + + await userEvent.keyboard('{Escape}'); + + // Should be empty + expect((cmdInput as HTMLInputElement).value).toEqual(''); + }); + }); + }); }); diff --git a/src/ShellPrompt.tsx b/src/ShellPrompt.tsx index b26ff5a..772a1dc 100644 --- a/src/ShellPrompt.tsx +++ b/src/ShellPrompt.tsx @@ -2,7 +2,8 @@ import React, { useState, useRef, useEffect, useContext } from 'react'; import { TRACKER_EVENTS, TrackerContext } from './Tracker'; import Hints from './Hints'; import ConsoleOutput, { CommandResult, ConsoleLine } from './ConsoleOutput'; -import { displayUser, USERS } from './Users'; +import { USERS } from './Users'; +import { commandsWithContext } from './Command'; export const LS_KEY_LAST_LOGIN = 'lastLogin'; export const LS_KEY_COMMAND_HISTORY = 'commandHistory'; @@ -54,147 +55,14 @@ function ShellPrompt() { const inputRef = useRef(null); const preBottomRef = useRef(null); - interface Command { - description: string; - secret?: boolean; - run: (...args: string[]) => ConsoleLine[]; - } - - /** - * A map of commands available to run - */ - const COMMANDS = new Map(); - - COMMANDS.set('clear', { - description: 'Clears the screen', - run: () => { - // use setTimeout to clear screen after this loop is finished - setTimeout(() => { - setConsoleLines([]); - setLastCommand(undefined); - }); - - return []; - } - }); - - COMMANDS.set('env', { - description: 'Print Environment', - run: () => { - const response: ConsoleLine[] = []; - - environment.forEach((k, v) => { - response.push([`${k}=${v}`]); - }); - - return response; - } - }); - - COMMANDS.set('export', { - description: 'Set an environment variable', - run: (key, value) => { - setEnvironment({ ...environment, [key]: value }); - return [[`${key}=${value}`]]; - } - }); - - COMMANDS.set('help', { - description: 'Provides a list of commands. Usage: `help [command]`', - run: (command?: string) => { - if (command) { - if (COMMANDS.get(command)?.secret) { - return [['I\'m not helping you. It\'s a secret!']]; - } else { - return [[COMMANDS.get(command)?.description || `Unknown command: ${command}`]]; - } - } else { - return [ - ['List of Commands:'], - ...[...COMMANDS] - .filter(cmd => !cmd[1].secret) - .map(([commandName, commandInfo]) => [`${commandName}:`, commandInfo.description]) - ]; - } - } - }); - - COMMANDS.set('history', { - description: 'Show previous commands', - run: () => commandHistory.map((command, index) => [`${index + 1}: ${command}`]) - }); - - COMMANDS.set('open', { - description: 'Open a file or URL', - run: (target) => { - try { - const url = new URL(target); - if (['http:', 'https:'].includes(url.protocol)) { - window.open(target); - return [[`Opening ${target}...`]]; - } else { - return [[`Unknown protocol: ${url.protocol}`]]; - } - } catch { - return [[`Cannot open: ${target}`]]; - } - } - }); - - COMMANDS.set('pwd', { - description: 'Return the working directory', - run: () => [[workingDir]] - }); - - COMMANDS.set('rm', { - description: 'Remove directory entries', - run: () => { - window.open('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); - return [['rm never gonna give you up!']]; - } - }); - - COMMANDS.set('secret', { - description: 'A secret command', - secret: true, - run: () => [['You found it!']] - }); - - COMMANDS.set('users', { - description: 'List users', - run: () => [...USERS.keys()].map(userName => [userName]) - }); - - COMMANDS.set('view-source', { - description: 'View the source of this app', - run: () => { - window.open('https://github.com/shaunburdick/shaunburdick.com'); - return [['Opening GH Page...']]; - } - }); - - COMMANDS.set('whoami', { - description: 'Tell you a little about yourself', - run: () => [ - ['You\'re you, silly'] - ] - }); - - COMMANDS.set('whois', { - description: 'Tell you a little about a user. Usage: `whois `', - run: (username: string) => { - const user = USERS.get(username); - if (user) { - return displayUser(user); - } else if(/miki|mikey|faktrl/.test(username)) { - window.open('https://www.youtube.com/watch?v=YjyUIwKPAxA'); - return [[`Hello, ${username}`]]; - } else if (username === 'gamefront') { - return [[Gamefront, 'is just FilesNetwork with a better skin']]; - } else { - return [[`Unknown user: ${username || ''}`]]; - } - } + const COMMANDS = commandsWithContext({ + commandHistory, + environment, + setConsoleLines, + setEnvironment, + setLastCommand, + workingDir, + users: USERS }); const handleSubmit = (event: React.FormEvent) => { @@ -279,7 +147,7 @@ function ShellPrompt() { } break; default: - // do nothing + // do nothing } }; @@ -329,6 +197,8 @@ function ShellPrompt() { setTimeout(() => { // this causes timing issues with some tests, better safe to check the method exists if (preBottomRef.current?.scrollIntoView) { + // jsDom doesn't support scrolling + /* istanbul ignore next */ preBottomRef.current.scrollIntoView({ behavior: 'smooth' }); } });