diff --git a/cypress.config.js b/cypress.config.js index 808827b..705ec08 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,8 +1,10 @@ import { defineConfig } from "cypress"; export default defineConfig({ + viewportHeight: 1080, + viewportWidth: 1920, e2e: { - baseUrl: "https://reactive-api-console.vercel.app/", + baseUrl: "http://localhost:5173/", //"https://reactive-api-console.vercel.app/" supportFile: "cypress/support/e2e.{js,jsx,ts,tsx}", }, component: { diff --git a/cypress/e2e/api-should-fail.cy.ts b/cypress/e2e/api-should-fail.cy.ts new file mode 100644 index 0000000..2bf02da --- /dev/null +++ b/cypress/e2e/api-should-fail.cy.ts @@ -0,0 +1,43 @@ +/// + +describe("API Failing Cases", () => { + beforeEach(() => { + cy.visit("/"); + }); + + it("should show error for unsupported command", () => { + cy.get('input[placeholder*="Type a command"]').type("unsupported command"); + cy.get("button").contains("Send").click(); + cy.contains("No results for").should("be.visible"); + }); + + it("should show error if command is valid but API is disabled", () => { + // Assume Cat Facts is enabled by default, so we disable it first + cy.contains("Cat Facts").click(); // This should disable the API + cy.contains("Cat Facts").parent().not("have.class", "ring-orange-500"); + cy.get('input[placeholder*="Type a command"]').type("get cat fact"); + cy.get("button").contains("Send").click(); + cy.contains("No results for").should("be.visible"); + }); + + it("should show error if network/API is down (network failure)", () => { + cy.intercept("GET", "https://catfact.ninja/fact", { + forceNetworkError: true, + }).as("getCatFactFail"); + cy.get('input[placeholder*="Type a command"]').type("get cat fact"); + cy.get("button").contains("Send").click(); + cy.wait("@getCatFactFail"); + cy.contains("Network error").should("be.visible"); + }); + + it("should show error if API returns 500 error", () => { + cy.intercept("GET", "https://catfact.ninja/fact", { + statusCode: 500, + body: {}, + }).as("getCatFact500"); + cy.get('input[placeholder*="Type a command"]').type("get cat fact"); + cy.get("button").contains("Send").click(); + cy.wait("@getCatFact500"); + cy.contains("Error executing").should("be.visible"); + }); +}); diff --git a/cypress/e2e/special-commands/clear-commands.cy.ts b/cypress/e2e/special-commands/clear-commands.cy.ts new file mode 100644 index 0000000..b30c0db --- /dev/null +++ b/cypress/e2e/special-commands/clear-commands.cy.ts @@ -0,0 +1,16 @@ +/// + +describe("Clear Commands", () => { + it("should clear chat when 'clear' command is executed", () => { + cy.visit("/"); + // Execute a command to populate chat + cy.get('input[placeholder*="Type a command"]').type("get cat fact"); + cy.get("button").contains("Send").click(); + cy.contains("✅ Executed: get cat fact").should("be.visible"); + + cy.get('input[placeholder*="Type a command"]').type("clear"); + cy.get("button").contains("Send").click(); + // The chat should be cleared, so the executed message should not be visible + cy.contains("✅ Executed: get cat fact").should("not.exist"); + }); +}); diff --git a/cypress/e2e/special-commands/help-command.cy.ts b/cypress/e2e/special-commands/help-command.cy.ts new file mode 100644 index 0000000..7171e8a --- /dev/null +++ b/cypress/e2e/special-commands/help-command.cy.ts @@ -0,0 +1,14 @@ +/// + +describe("Help Commands", () => { + beforeEach(() => { + cy.visit("/"); + }); + + it("should display help information when 'help' command is executed", () => { + cy.get('input[placeholder*="Type a command"]').type("help"); + cy.get("button").should("not.be.disabled"); + cy.get("button").contains("Send").click(); + cy.contains("Available Commands").should("be.visible"); + }); +}); diff --git a/cypress/e2e/special-commands/history-command.cy.ts b/cypress/e2e/special-commands/history-command.cy.ts new file mode 100644 index 0000000..086871e --- /dev/null +++ b/cypress/e2e/special-commands/history-command.cy.ts @@ -0,0 +1,31 @@ +/// + +describe("History Commands", () => { + beforeEach(() => { + cy.visit("/"); + }); + + it("should show No history when 'history' command is executed without any history", () => { + // check history + cy.get('input[placeholder*="Type a command"]').type("history"); + cy.get("button").contains("Send").click(); + cy.contains("No command").should("be.visible"); + }); + + it("should show command history when 'history' command is executed", () => { + // Execute a command to ensure there is history + cy.get('input[placeholder*="Type a command"]').type("get chuck joke"); + cy.get("button").contains("Send").click(); + + cy.get('input[placeholder*="Type a command"]').type("history"); + cy.get("button").contains("Send").click(); + + cy.get('input[placeholder*="Type a command"]').type("get activity"); + cy.get("button").contains("Send").click(); + + // Now check history + cy.get('input[placeholder*="Type a command"]').type("history"); + cy.get("button").contains("Send").click(); + cy.contains("Command History").should("be.visible"); + }); +}); diff --git a/package.json b/package.json index df7de18..959e567 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --port 5173", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", @@ -13,7 +13,8 @@ "test:ui": "vitest --ui", "test:coverage": "vitest --coverage.enabled --coverage.reportsDirectory ./coverage", "cy:open": "cypress open", - "cy:run": "cypress run" + "cy:run": "cypress run", + "cy:start": "concurrently \"pnpm run dev\" \"cypress run\"" }, "dependencies": { "@reduxjs/toolkit": "^2.8.2", @@ -46,6 +47,7 @@ "react-dom": "^19.1.0", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", + "concurrently": "9.2.0", "vite": "^6.3.5", "vitest": "^3.2.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5f8b71..a910041 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@vitest/ui': specifier: 3.2.4 version: 3.2.4(vitest@3.2.4) + concurrently: + specifier: 9.2.0 + version: 9.2.0 cypress: specifier: ^14.5.1 version: 14.5.1 @@ -1077,6 +1080,10 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1106,6 +1113,11 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concurrently@9.2.0: + resolution: {integrity: sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==} + engines: {node: '>=18'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1409,6 +1421,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2019,6 +2035,10 @@ packages: request-progress@3.0.0: resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -2074,6 +2094,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -2460,6 +2484,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2467,6 +2495,14 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -3417,6 +3453,12 @@ snapshots: slice-ansi: 3.0.0 string-width: 4.2.3 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3438,6 +3480,16 @@ snapshots: concat-map@0.0.1: {} + concurrently@9.2.0: + dependencies: + chalk: 4.1.2 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + convert-source-map@2.0.0: {} core-util-is@1.0.2: {} @@ -3814,6 +3866,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4351,6 +4405,8 @@ snapshots: dependencies: throttleit: 1.0.1 + require-directory@2.1.1: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -4414,6 +4470,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -4771,10 +4829,24 @@ snapshots: wrappy@1.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@5.0.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 diff --git a/src/components/molecules/ChatCommand/ChatCommand.tsx b/src/components/molecules/ChatCommand/ChatCommand.tsx index 886b092..c74245a 100644 --- a/src/components/molecules/ChatCommand/ChatCommand.tsx +++ b/src/components/molecules/ChatCommand/ChatCommand.tsx @@ -1,34 +1,32 @@ import { useEffect, useState } from "react"; -import { BehaviorSubject, debounceTime, tap } from "rxjs"; +import { debounceTime, BehaviorSubject as Subject, tap } from "rxjs"; type ChatCommandProps = { onSendCommand: (command: string) => void; }; -const messageChange = new BehaviorSubject(""); +const messageChange = new Subject(""); const messageChange$ = messageChange.asObservable(); export const ChatCommand = ({ onSendCommand }: ChatCommandProps) => { - const [command, setCommand] = useState(""); - + const [disabled, setDisabled] = useState(true); useEffect(() => { - const subscription = messageChange$ + const subscription$ = messageChange$ .pipe( debounceTime(500), - tap((value) => { - setCommand(value); - }) + tap((value) => setDisabled(!value.trim())) ) .subscribe(); - return () => subscription.unsubscribe(); + return () => subscription$.unsubscribe(); }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (command.trim()) { - onSendCommand(command.trim()); - messageChange.next(""); + + if (messageChange.value.trim()) { + onSendCommand(messageChange.value.trim()); (e.target as HTMLFormElement).reset(); + setDisabled(true); } }; @@ -45,7 +43,7 @@ export const ChatCommand = ({ onSendCommand }: ChatCommandProps) => { />