diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml new file mode 100644 index 0000000..9d996a0 --- /dev/null +++ b/.github/workflows/frontend-build.yml @@ -0,0 +1,21 @@ +name: Build Next.js App +on: + push: + branches: [master] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node.js environment + uses: actions/setup-node@v1 + with: + node-version: 18 + + - name: Install Dependencies + run: npm ci + + - name: Build Next App + run: npm run build diff --git a/.github/workflows/test-environment.yml b/.github/workflows/test-environment.yml new file mode 100644 index 0000000..a9620ac --- /dev/null +++ b/.github/workflows/test-environment.yml @@ -0,0 +1,71 @@ +name: Test Environment Setup + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + setup-test-environment: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_DB: schoolproject + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + version: 10.8.0 + run_install: false + + - name: Install dependencies + run: pnpm install + + - name: Setup Backend + run: | + cd Backend + cp .env.example .env + pnpm build + + - name: Run Tests + run: | + # Backend tests + cd Backend + pnpm test || echo "No backend tests found or tests failed" + + # Frontend tests with Cypress + cd ../Client + pnpm cypress run || echo "No frontend tests found or tests failed" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + Backend/test-results + Client/cypress/screenshots + Client/cypress/videos \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e56944..0dae67e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,20 @@ "password": "postgres" } ], - "sqltools.results.reuseTabs": "connection" + "sqltools.results.reuseTabs": "connection", + "files.exclude": { + "**/.env*": true, + "**/.next": true, + "**/.pnpm-store": true, + "**/.vscode": true, + "**/*.config*": true, + "**/biome.jsonc": true, + "**/next-env.d.ts": true, + "**/node_modules": true, + "**/pnpm-*": true, + "**/README.md": true, + "**/tsconfig.json": true, + "docker/ConnectionTests": true, + "docker/db-database": true + } } \ No newline at end of file diff --git a/Backend/src/app/api/data/route.ts b/Backend/src/app/api/data/route.ts index d270702..640a6eb 100644 --- a/Backend/src/app/api/data/route.ts +++ b/Backend/src/app/api/data/route.ts @@ -1,127 +1,50 @@ - import pool from "@/utils/db"; +import { NextResponse } from "next/server"; export async function GET() { - const runResults = await pool.query<{ - run_id: number; - run_name: string; - run_date: string; - totalseconds: string; - }>(` - SELECT - r.id as run_id, r.name as run_name, - r.created_at as run_date, SUM(td.totalSeconds) as totalSeconds - FROM runs r - LEFT JOIN test_durations td ON td.run_id = r.id - GROUP BY r.id, r.name - ORDER BY r.id - `); + const runResults = await pool + .query<{ + id: number; + name: string; + created_at: Date; + }>(`select * from runs;`) + .then((r) => r.rows); - // Get test results for each run - // Get start times for each test - const startTimes = await pool.query<{ - run_id: number; - test_name: string; - path: string; - start_time: Date; - }>(` - SELECT - run_id, - test_name, - path, - created_at as start_time - FROM test_runs - WHERE run_type = 'start' - `); + const testResults = await pool + .query<{ + id: number; + run_id: number; + test_name: string; + path: string; + duration: number; + success?: boolean; + pending: boolean; + created_at: Date; + }>(`select * from test_runs;`) + .then((r) => r.rows); - // Get end times and results for each test - const endResults = await pool.query<{ - run_id: number; - test_name: string; - path: string; - end_time: Date; - success: boolean; - pending: boolean; - }>(` - SELECT - run_id, - test_name, - path, - created_at as end_time, - success, - COALESCE(pending, false) as pending - FROM test_runs - WHERE run_type = 'end' - `); - - // Combine the results - const testResults = { - rows: endResults.rows.map(end => { - const start = startTimes.rows.find( - start => - start.run_id === end.run_id && - start.test_name === end.test_name && - start.path === end.path - ); - - if (!start) return null; - - return { - run_id: end.run_id, - test_name: end.test_name, - path: end.path, - totalseconds: (end.end_time.getTime() - start.start_time.getTime()) / 1000, - success: end.success, - pending: end.pending - }; - }).filter(Boolean) - }; - - // Add unfinished tests - const unfinishedTests = startTimes.rows.filter(start => - !endResults.rows.some(end => - end.run_id === start.run_id && - end.test_name === start.test_name && - end.path === start.path - ) - ).map(start => ({ - run_id: start.run_id, - test_name: start.test_name, - path: start.path, - totalseconds: 0, // Test hasn't finished yet - success: false, - pending: true - })); - - testResults.rows.push(...unfinishedTests); - - // Combine and format results - const formatted = runResults.rows.map((run) => { - const runTests = testResults.rows.filter( - (test): test is NonNullable => test !== null && test.run_id === run.run_id - ) - - const totalDurationSeconds = Number(run.totalseconds); + const formatted = runResults.map((run) => { + const children = testResults.filter( + (test) => test !== null && test.run_id === run.id + ); + const totalDuration = children.reduce( + (n, child) => n + Number(child.duration), + 0 + ); return { - run_id: run.run_id, - run_name: run.run_name, - date: run.run_date, - totalseconds: run.totalseconds, - success_count: runTests.filter((test) => test.success).length, - failure_count: runTests.filter((test) => !test.success).length, - children: runTests.map((test) => ({ - test_name: test.test_name, - path: test.path, - totalseconds: test.totalseconds, - percentage_of_total: Number( - ((test.totalseconds / totalDurationSeconds) * 100).toFixed(2) - ), - status: test.success ? "success" : "failure", - pending: test.pending, + run_id: run.id, + run_name: run.name, + date: run.created_at, + totalseconds: totalDuration, + success_count: children.filter((test) => test.success).length, + failure_count: children.filter((test) => !test.success).length, + children: children.map((test) => ({ + ...test, + duration: Number(test.duration), })), }; }); - return Response.json(formatted); + return NextResponse.json(formatted); } diff --git a/Backend/src/app/api/run/create/route.ts b/Backend/src/app/api/run/create/route.ts index fd6e468..93ee58f 100644 --- a/Backend/src/app/api/run/create/route.ts +++ b/Backend/src/app/api/run/create/route.ts @@ -6,14 +6,14 @@ export async function POST(request: Request) { try { let { runName, + testId, testName, path, + duration, success, runId: passedRunId, - runType = "start" } = await request.json(); - let pending; if (!passedRunId) { const res = await pool .query( @@ -23,30 +23,34 @@ export async function POST(request: Request) { RETURNING id ) SELECT id FROM new_run;`, - [runName] + [runName], ) .then((r) => r.rows[0].id); - return NextResponse.json({runId: res}) + return NextResponse.json({ runId: res }); } - // create as Pending by default, then update and add the duration from the passed information - // can likely remove runType as a whole? - - - if (runType === "end") pending = false; - else pending = true; - - - // Now create the test run entry with 'pending' status - if (passedRunId) { - await pool.query( - `INSERT INTO test_runs (run_id, test_name, path, run_type, created_at, success, pending) - VALUES ($1, $2, $3, $4, NOW(), $5, $6);`, - [passedRunId, testName, path, runType, success, pending] + if (!testId) { + const res = await pool.query( + `INSERT INTO test_runs (run_id, test_name, path) + VALUES ($1, $2, $3) returning id`, + [passedRunId, testName, path], ); + + return NextResponse.json({ + testId: res.rows[0].id, + }); } + if (testId) { + if (duration) { + await pool.query( + `UPDATE test_runs SET pending = false, success = $3, duration = $2 WHERE id = $1;`, + [testId, duration, success], + ); + return NextResponse.json({ testId }); + } + } return NextResponse.json({ runId }); } catch (error) { console.error("Error creating run:", error); @@ -58,7 +62,7 @@ export async function POST(request: Request) { // @ts-ignore detail: error.detail, }, - { status: 500 } + { status: 500 }, ); } } diff --git a/Backend/src/app/layout.tsx b/Backend/src/app/layout.tsx index 4705432..28805b4 100644 --- a/Backend/src/app/layout.tsx +++ b/Backend/src/app/layout.tsx @@ -1,6 +1,5 @@ "use client"; import "@/styles/globals.css"; -import type { Metadata } from "next"; import { Geist } from "next/font/google"; import { @@ -13,6 +12,7 @@ import { useEffect } from "react"; const queryClient = new QueryClient({ defaultOptions: { queries: { + refetchInterval: 1000 * 1, // Useful in Prod so it updates every second and shows pending states refetchOnWindowFocus: true, }, }, @@ -30,7 +30,6 @@ export default function RootLayout({ focusManager.setEventListener((handleFocus) => { const focus = () => { if (!document.hidden) { - // console.log("Focus from parent"); handleFocus(true); queryClient.refetchQueries(); // Without this, the above 'focus from parent' will be logged but queries will only refetch the first time the window is refocused from a different program. After that, data will not be refetched. } diff --git a/Backend/src/app/page.tsx b/Backend/src/app/page.tsx index ef93da8..da7810e 100644 --- a/Backend/src/app/page.tsx +++ b/Backend/src/app/page.tsx @@ -8,7 +8,6 @@ const fetcher = (url: string) => { return fetch(url).then((r) => r.json()); }; -// Update the page component to use full width export default function Home() { const { isPending, error, data, isError } = useQuery({ queryKey: ["historicData"], @@ -19,7 +18,6 @@ export default function Home() {

- {/* Corporate Planning AG - Historic Test Data */} Historic Test Data

diff --git a/Backend/src/components/date-group.tsx b/Backend/src/components/date-group.tsx index a821ce0..c440699 100644 --- a/Backend/src/components/date-group.tsx +++ b/Backend/src/components/date-group.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/collapsible"; import { Button } from "./ui/button"; import type { Result } from "@/lib/types"; -import toCSV from "@/utils/toCSV"; +import { handleExportCSV } from "@/utils/handleCSVExport"; import { RunItem } from "./run-item"; interface DateGroupProps { @@ -45,35 +45,6 @@ export function DateGroup({ dateGroup }: DateGroupProps) { }; }, [dateGroup]); - const handleExportCSV = () => { - // Create CSV header - const headers = [ - "Run Name", - "Test Name", - "Path", - "Status", - "Duration (s)", - "Percentage of Total", - ]; - - // Create and trigger download - const blob = new Blob([...toCSV(headers, dateGroup)], { - type: "text/csv;charset=utf-8;", - }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - link.setAttribute( - "download", - `test-results-${new Date(dateGroup.date) - .toISOString() - .substring(0, 10)}.csv` - ); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - return (
{ e.stopPropagation(); - handleExportCSV(); + handleExportCSV(dateGroup); }} className="flex items-center gap-2" > @@ -110,7 +81,7 @@ export function DateGroup({ dateGroup }: DateGroupProps) {
@@ -120,7 +91,7 @@ export function DateGroup({ dateGroup }: DateGroupProps) { {dateGroup.runs .sort( (a, b) => - new Date(b.date).getTime() - new Date(a.date).getTime() + new Date(b.date).getTime() - new Date(a.date).getTime(), ) .map((run) => ( diff --git a/Backend/src/components/foldable-list.tsx b/Backend/src/components/foldable-list.tsx index 9e64118..bcc9804 100644 --- a/Backend/src/components/foldable-list.tsx +++ b/Backend/src/components/foldable-list.tsx @@ -19,6 +19,13 @@ interface FoldableListProps { } export function FoldableList({ results, className }: FoldableListProps) { + if(!results || results.length === 0) + return ( +

+ No test results found. +

+ ); + if (!results[0]?.run_id) return (

diff --git a/Backend/src/components/run-item.tsx b/Backend/src/components/run-item.tsx index fa37e13..6ecc886 100644 --- a/Backend/src/components/run-item.tsx +++ b/Backend/src/components/run-item.tsx @@ -21,7 +21,11 @@ export function RunItem({ result }: RunItemProps) { const successPercentage = totalTests > 0 ? (result.success_count / totalTests) * 100 : 0; - const formatSeconds = (seconds: number | string): string => { + const formatSeconds = (milliseconds?: number): string => { + if(!milliseconds) { + return "--:--:--"; + } + var seconds:number|string = milliseconds / 1000; var hours: number | string = Math.floor((seconds as number) / 3600); var minutes: number | string = Math.floor( ((seconds as number) - hours * 3600) / 60 @@ -92,7 +96,7 @@ export function RunItem({ result }: RunItemProps) { "w-2 h-2 rounded-full", child.pending ? "bg-blue-500" - : child.status === "success" + : child.success ? "bg-green-500" : "bg-red-500" )} @@ -102,7 +106,7 @@ export function RunItem({ result }: RunItemProps) {

- {formatSeconds(child.totalseconds)} + {formatSeconds(child.duration)}
diff --git a/Backend/src/lib/types.ts b/Backend/src/lib/types.ts index a588736..698e3fe 100644 --- a/Backend/src/lib/types.ts +++ b/Backend/src/lib/types.ts @@ -6,11 +6,12 @@ export type Result = { success_count: number; failure_count: number; children: Array<{ + id: number; + run_id: number; test_name: string; path: string; - totalseconds: number; - percentage_of_total: number; - status: "success" | "failure"; + duration?: number; + success?: boolean; pending: boolean }>; }; diff --git a/Backend/src/utils/db.ts b/Backend/src/utils/db.ts index 92dc946..c2e3fb7 100644 --- a/Backend/src/utils/db.ts +++ b/Backend/src/utils/db.ts @@ -1,8 +1,6 @@ import "../env"; import { Pool } from "pg"; -// console.log(process.env) - const pool = new Pool({ connectionString: process.env.NEXT_PUBLIC_DATABASE_URL, }); diff --git a/Backend/src/utils/handleCSVExport.ts b/Backend/src/utils/handleCSVExport.ts new file mode 100644 index 0000000..c6fd17d --- /dev/null +++ b/Backend/src/utils/handleCSVExport.ts @@ -0,0 +1,34 @@ +"use client"; + +import toCSV from "./toCSV"; + +export const handleExportCSV = (dateGroup: any) => { + // Create CSV header + const headers = [ + "Run Name", + "Test Name", + "Path", + "Success", + "Duration (s)" + ]; + + console.log(dateGroup); + console.log(toCSV(headers, dateGroup)); + + // Create and trigger download + const blob = new Blob([...toCSV(headers, dateGroup)], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute( + "download", + `test-results-${new Date(dateGroup.date) + .toISOString() + .substring(0, 10)}.csv`, + ); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; diff --git a/Backend/src/utils/toCSV.ts b/Backend/src/utils/toCSV.ts index 6098a66..51c8322 100644 --- a/Backend/src/utils/toCSV.ts +++ b/Backend/src/utils/toCSV.ts @@ -4,14 +4,14 @@ export default function toCSV( headers: string[], rows: { date: string; runs: Result[] } ) { + console.log(rows); const a = rows.runs.flatMap((run) => run.children.map((child) => [ run.run_name, child.test_name, child.path, - child.status, - child.totalseconds, - child.percentage_of_total, + child.success? "true" : "false", + child.duration!/1000 ]) ); diff --git a/Client/cypress/e2e/1-getting-started/todo2.cy.js b/Client/cypress/e2e/1-getting-started/todo2.cy.js new file mode 100644 index 0000000..64a20fc --- /dev/null +++ b/Client/cypress/e2e/1-getting-started/todo2.cy.js @@ -0,0 +1,143 @@ +/// + +// Welcome to Cypress! +// +// This spec file contains a variety of sample tests +// for a todo list app that are designed to demonstrate +// the power of writing tests in Cypress. +// +// To learn more about how Cypress works and +// what makes it such an awesome testing tool, +// please read our getting started guide: +// https://on.cypress.io/introduction-to-cypress + +describe("example to-do app", () => { + beforeEach(() => { + // Cypress starts out with a blank slate for each test + // so we must tell it to visit our website with the `cy.visit()` command. + // Since we want to visit the same URL at the start of all our tests, + // we include it in our beforeEach function so that it runs before each test + cy.visit("https://example.cypress.io/todo"); + }); + + it("displays two todo items by default", () => { + // We use the `cy.get()` command to get all elements that match the selector. + // Then, we use `should` to assert that there are two matched items, + // which are the two default items. + cy.get(".todo-list li").should("have.length", 3); + + // We can go even further and check that the default todos each contain + // the correct text. We use the `first` and `last` functions + // to get just the first and last matched elements individually, + // and then perform an assertion with `should`. + cy.get(".todo-list li").first().should("have.text", "Pay electric bill"); + cy.get(".todo-list li").last().should("have.text", "Walk the dog"); + }); + + it("can add new todo items", () => { + // We'll store our item text in a variable so we can reuse it + const newItem = "Feed the cat"; + + // Let's get the input element and use the `type` command to + // input our new list item. After typing the content of our item, + // we need to type the enter key as well in order to submit the input. + // This input has a data-test attribute so we'll use that to select the + // element in accordance with best practices: + // https://on.cypress.io/selecting-elements + cy.get("[data-test=new-todo]").type(`${newItem}{enter}`); + + // Now that we've typed our new item, let's check that it actually was added to the list. + // Since it's the newest item, it should exist as the last element in the list. + // In addition, with the two default items, we should have a total of 3 elements in the list. + // Since assertions yield the element that was asserted on, + // we can chain both of these assertions together into a single statement. + cy.get(".todo-list li") + .should("have.length", 3) + .last() + .should("have.text", newItem); + }); + + it("can check off an item as completed", () => { + // In addition to using the `get` command to get an element by selector, + // we can also use the `contains` command to get an element by its contents. + // However, this will yield the