i'm about to rework so much better save

This commit is contained in:
Lio 2025-05-20 13:13:50 +00:00
commit 8bb5fee1c0
45 changed files with 4600 additions and 0 deletions

16
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/typescript-node/.devcontainer/base.Dockerfile
# [Choice] Node.js version: 16, 18, 20
ARG VARIANT="22"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:1-${VARIANT}
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# To install more global node packages
RUN su node -c "npm install -g pnpm@10 ts-node"

View file

@ -0,0 +1,31 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/typescript-node
{
"name": "School Project",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 16, 18, 20
"args": {
"VARIANT": "22"
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000, 4321],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "pnpm install",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"customizations": {
"vscode": {
"extensions": [
"astro-build.astro-vscode",
"denoland.vscode-deno",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
// Optional tbh
"christian-kohler.path-intellisense",
"YoavBls.pretty-ts-errors"
]
}
}
}

47
.gitignore vendored Normal file
View file

@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnpm-store
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
build/
.next
# misc
.DS_Store
*.pem
db-database/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea

15
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"driver": "PostgreSQL",
"name": "School Project",
"connectString": "postgresql://postgres:postgres@db.schoolproject.orb.local:5432/schoolproject",
"database": "schoolproject",
"username": "postgres",
"password": "postgres"
}
],
"sqltools.results.reuseTabs": "connection"
}

14
Backend/.env.example Normal file
View file

@ -0,0 +1,14 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Example:
# SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar"

29
Backend/README.md Normal file
View file

@ -0,0 +1,29 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Drizzle](https://orm.drizzle.team)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

26
Backend/biome.jsonc Normal file
View file

@ -0,0 +1,26 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": { "ignoreUnknown": false, "ignore": [] },
"formatter": { "enabled": true },
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"nursery": {
"useSortedClasses": {
"level": "warn",
"fix": "safe",
"options": {
"functions": ["clsx", "cva", "cn"]
}
}
},
"recommended": true
}
}
}

21
Backend/components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

10
Backend/next.config.js Normal file
View file

@ -0,0 +1,10 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};
export default config;

48
Backend/package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "SchoolProject",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "biome check .",
"check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .",
"dev": "next dev --turbo",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-slot": "^1.2.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.76.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.509.0",
"next": "^15.2.3",
"next-themes": "^0.4.6",
"pg": "^8.14.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.2.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.13",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.2"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.8.0"
}

1485
Backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@biomejs/biome'
- sharp

View file

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

BIN
Backend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,127 @@
import pool from "@/utils/db";
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
`);
// 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'
`);
// 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<typeof test> => test !== null && test.run_id === run.run_id
)
const totalDurationSeconds = Number(run.totalseconds);
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,
})),
};
});
return Response.json(formatted);
}

View file

@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import pool from "@/utils/db";
export async function POST(request: Request) {
let runId;
try {
let {
runName,
testName,
path,
success,
runId: passedRunId,
runType = "start"
} = await request.json();
let pending;
if (!passedRunId) {
const res = await pool
.query(
`WITH new_run AS (
INSERT INTO runs (name)
VALUES ($1)
RETURNING id
)
SELECT id FROM new_run;`,
[runName]
)
.then((r) => r.rows[0].id);
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]
);
}
return NextResponse.json({ runId });
} catch (error) {
console.error("Error creating run:", error);
return NextResponse.json(
{
error: "Failed to create run",
// @ts-ignore
message: error.message,
// @ts-ignore
detail: error.detail,
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,56 @@
"use client";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import {
focusManager,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { useEffect } from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
},
},
});
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
useEffect(() => {
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.
}
};
window.addEventListener("visibilitychange", focus);
window.addEventListener("focus", focus);
return () => {
window.removeEventListener("visibilitychange", focus);
window.removeEventListener("focus", focus);
};
});
}, []);
return (
<html lang="en" className={`${geist.variable}`}>
<body>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</body>
</html>
);
}

42
Backend/src/app/page.tsx Normal file
View file

@ -0,0 +1,42 @@
"use client";
import { FoldableList } from "@/components/foldable-list";
import type { Result } from "@/lib/types";
import { useQuery } from "@tanstack/react-query";
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"],
queryFn: () => fetcher(`${window.location}/api/data`),
});
return (
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-800 text-white">
<div className="w-full max-w-4xl mx-auto">
<h1 className="text-2xl font-semibold mb-6 text-center">
{/* Corporate Planning AG - Historic Test Data */}
Historic Test Data
</h1>
<div className="">
{isError && (
<div>
<h1>Encountered an Error while running the Query.</h1>
<code>{error.message}</code>
</div>
)}
{(!isPending && <FoldableList results={data as Result[]} />) || (
<h1 className="text-2xl font-semibold mb-6 text-center">
Loading...
</h1>
)}
</div>
</div>
</main>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState, useMemo } from "react";
import { ChevronDown, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "./ui/button";
import type { Result } from "@/lib/types";
import toCSV from "@/utils/toCSV";
import { RunItem } from "./run-item";
interface DateGroupProps {
dateGroup: {
date: string;
runs: Result[];
};
}
export function DateGroup({ dateGroup }: DateGroupProps) {
const [isOpen, setIsOpen] = useState(true);
// Calculate aggregate statistics for this date
const stats = useMemo(() => {
let totalSuccess = 0;
let totalFailure = 0;
dateGroup.runs.forEach((run) => {
totalSuccess += run.success_count;
totalFailure += run.failure_count;
});
const totalTests = totalSuccess + totalFailure;
const successPercentage =
totalTests > 0 ? (totalSuccess / totalTests) * 100 : 0;
return {
totalSuccess,
totalFailure,
totalTests,
successPercentage,
};
}, [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 (
<div className="mb-4 border border-zinc-600 rounded-lg overflow-hidden">
<Collapsible
defaultOpen={true}
open={isOpen}
onOpenChange={setIsOpen}
className="w-full"
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-4 text-left font-medium hover:bg-zinc-600 focus-visible:ring-offset-2 px-4 transition-colors bg-zinc-700">
<div className="flex items-center gap-4">
<Button
variant="default"
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleExportCSV();
}}
className="flex items-center gap-2"
>
<Download className="h-4 w-4" />
</Button>
<span className="text-lg font-semibold">{dateGroup.date}</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<div className="rounded-full flex gap-1 pr-1"></div>
<span className="text-xs text-zinc-400 ml-2">
{stats.totalSuccess}/{stats.totalTests} passed
</span>
</div>
<span className="text-sm text-zinc-200">
{dateGroup.runs.length} runs
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-zinc-100 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-zinc-700">
{dateGroup.runs
.sort(
(a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
.map((run) => (
<RunItem key={run.run_id} result={run} />
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { Result } from "@/lib/types";
import { DateGroup } from "./date-group";
// Type for date-grouped results
type DateGroupedResults = {
[date: string]: {
date: string;
runs: Result[];
};
};
interface FoldableListProps {
results: Result[];
className?: string;
}
export function FoldableList({ results, className }: FoldableListProps) {
if (!results[0]?.run_id)
return (
<h1 className="text-center text-red-400">
There was an Error while fetching test results.
</h1>
);
// Group results by date
const dateGroupedResults = useMemo(() => {
const grouped: DateGroupedResults = {};
results.forEach((result) => {
const date = new Date(result.date).toDateString();
if (!grouped[date]) {
grouped[date] = {
date,
runs: [],
};
}
grouped[date].runs.push(result);
});
// Convert to array and sort by date (newest first)
return Object.values(grouped).sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
}, [results]);
return (
<div className={cn("w-full", className)}>
{dateGroupedResults.map((dateGroup) => (
<DateGroup key={dateGroup.date} dateGroup={dateGroup} />
))}
</div>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { Result } from "@/lib/types";
interface RunItemProps {
result: Result;
}
export function RunItem({ result }: RunItemProps) {
const [isOpen, setIsOpen] = useState(false);
const hasChildren = result.children && result.children.length > 0;
const totalTests = result.success_count + result.failure_count;
const successPercentage =
totalTests > 0 ? (result.success_count / totalTests) * 100 : 0;
const formatSeconds = (seconds: number | string): string => {
var hours: number | string = Math.floor((seconds as number) / 3600);
var minutes: number | string = Math.floor(
((seconds as number) - hours * 3600) / 60
);
seconds = (seconds as number) - hours * 3600 - minutes * 60;
if (hours < 10) {
hours = "0" + hours;
}
if (minutes < 10) {
minutes = "0" + minutes;
}
if (seconds < 10) {
seconds = "0" + seconds.toFixed(1);
}
var time = hours + ":" + minutes + ":" + seconds;
return time;
};
return (
<div className="w-full">
<Collapsible
defaultOpen={false}
open={isOpen}
onOpenChange={setIsOpen}
className="w-full"
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-3 text-left font-medium text-zinc-300 hover:bg-zinc-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2 px-4 transition-colors">
<div className="flex items-center gap-2">
<span>{result.run_name}</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<div className="w-24 h-1.5 rounded-full overflow-hidden flex">
<div
className="h-full bg-green-500"
style={{ width: `${successPercentage}%` }}
></div>
<div
className="h-full bg-red-500"
style={{ width: `${100 - successPercentage}%` }}
></div>
</div>
<span className="text-xs text-zinc-400 ml-2">
{result.success_count}/{totalTests} passed
</span>
</div>
<span className="text-sm text-zinc-300">
{formatSeconds(result.totalseconds)}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-gray-500 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
{hasChildren &&
result.children.map((child, index) => (
<div
key={index}
className="flex items-center justify-between py-3 px-4 pl-8 border-t border-zinc-700 hover:bg-zinc-700"
>
<div className="flex items-center gap-2">
<div
className={cn(
"w-2 h-2 rounded-full",
child.pending
? "bg-blue-500"
: child.status === "success"
? "bg-green-500"
: "bg-red-500"
)}
></div>
<span className="text-zinc-200">{child.test_name}</span>
<span className="text-xs text-zinc-400">({child.path})</span>
</div>
<div className="flex items-center gap-4 pr-8">
<span className="text-sm text-zinc-300 w-12 text-right">
{formatSeconds(child.totalseconds)}
</span>
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
return (
<span
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

42
Backend/src/env.js Normal file
View file

@ -0,0 +1,42 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_DATABASE_URL: z.string()//.url(),
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_DATABASE_URL: process.env.NEXT_PUBLIC_DATABASE_URL,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

16
Backend/src/lib/types.ts Normal file
View file

@ -0,0 +1,16 @@
export type Result = {
run_id: number;
run_name: string;
date: string;
totalseconds: number;
success_count: number;
failure_count: number;
children: Array<{
test_name: string;
path: string;
totalseconds: number;
percentage_of_total: number;
status: "success" | "failure";
pending: boolean
}>;
};

8
Backend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,8 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function format(inputs: any) {}

View file

@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

10
Backend/src/utils/db.ts Normal file
View file

@ -0,0 +1,10 @@
import "../env";
import { Pool } from "pg";
// console.log(process.env)
const pool = new Pool({
connectionString: process.env.NEXT_PUBLIC_DATABASE_URL,
});
export default pool;

View file

@ -0,0 +1,19 @@
import type { Result } from "@/lib/types";
export default function toCSV(
headers: string[],
rows: { date: string; runs: Result[] }
) {
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,
])
);
return [headers.join(","), ...a.map((row) => row.join(","))].join("\n");
}

42
Backend/tsconfig.json Normal file
View file

@ -0,0 +1,42 @@
{
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}

13
Client/cypress.config.js Normal file
View file

@ -0,0 +1,13 @@
const { defineConfig } = require("cypress");
const apiRequestPlugin = require("./src/index");
module.exports = defineConfig({
historyTrackerURL: "http://localhost:3001",
e2e: {
setupNodeEvents(on, config) {
// Set up the API request plugin
apiRequestPlugin(on, config);
return config;
},
},
});

View file

@ -0,0 +1,143 @@
/// <reference types="cypress" />
// 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", 2);
// 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 <label>, which is lowest-level element that contains the text.
// In order to check the item, we'll find the <input> element for this <label>
// by traversing up the dom to the parent element. From there, we can `find`
// the child checkbox <input> element and use the `check` command to check it.
cy.contains("Pay electric bill")
.parent()
.find("input[type=checkbox]")
.check();
// Now that we've checked the button, we can go ahead and make sure
// that the list element is now marked as completed.
// Again we'll use `contains` to find the <label> element and then use the `parents` command
// to traverse multiple levels up the dom until we find the corresponding <li> element.
// Once we get that element, we can assert that it has the completed class.
cy.contains("Pay electric bill")
.parents("li")
.should("have.class", "completed");
});
context("with a checked task", () => {
beforeEach(() => {
// We'll take the command we used above to check off an element
// Since we want to perform multiple tests that start with checking
// one element, we put it in the beforeEach hook
// so that it runs at the start of every test.
cy.contains("Pay electric bill")
.parent()
.find("input[type=checkbox]")
.check();
});
it("can filter for uncompleted tasks", () => {
// We'll click on the "active" button in order to
// display only incomplete items
cy.contains("Active").click();
// After filtering, we can assert that there is only the one
// incomplete item in the list.
cy.get(".todo-list li")
.should("have.length", 1)
.first()
.should("have.text", "Walk the dog");
// For good measure, let's also assert that the task we checked off
// does not exist on the page.
cy.contains("Pay electric bill").should("not.exist");
});
it("can filter for completed tasks", () => {
// We can perform similar steps as the test above to ensure
// that only completed tasks are shown
cy.contains("Completed").click();
cy.get(".todo-list li")
.should("have.length", 1)
.first()
.should("have.text", "Pay electric bill");
cy.contains("Walk the dog").should("not.exist");
});
it("can delete all completed tasks", () => {
// First, let's click the "Clear completed" button
// `contains` is actually serving two purposes here.
// First, it's ensuring that the button exists within the dom.
// This button only appears when at least one task is checked
// so this command is implicitly verifying that it does exist.
// Second, it selects the button so we can click it.
cy.contains("Clear completed").click();
// Then we can make sure that there is only one element
// in the list and our element does not exist
cy.get(".todo-list li")
.should("have.length", 1)
.should("not.have.text", "Pay electric bill");
// Finally, make sure that the clear button no longer exists.
cy.contains("Clear completed").should("not.exist");
});
});
});

View file

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View file

49
Client/rollup.config.js Normal file
View file

@ -0,0 +1,49 @@
import pkg from "./package.json";
import del from "rollup-plugin-delete";
import commonjs from "rollup-plugin-commonjs";
import json from "rollup-plugin-json";
import copy from "rollup-plugin-copy";
const config = [
{
input: "./src/index.js",
output: [
{
file: pkg.main,
format: "cjs",
globals: {
Cypress: "cypress",
},
},
{ file: pkg.module, format: "es" },
],
plugins: [
// Delete contents of target folder
del({
targets: pkg.files,
}),
// Resolve JSON files
json(),
// Compile to commonjs and bundle
commonjs(),
// Copy type definitions to target folder
// copy({
// targets: [{ src: './types/**/*.d.ts', dest: './dist/types' }],
// }),
],
/**
* Mark all dependencies as external to prevent Rollup from
* including them in the bundle. We'll let the package manager
* take care of dependency resolution and stuff so we don't
* have to download the exact same code multiple times, once
* in this bundle and also as a dependency of another package.
*/
external: [...Object.keys(pkg.dependencies || {})],
},
];
module.exports = config;

135
Client/src/index.js Normal file
View file

@ -0,0 +1,135 @@
// This plugin makes API requests at specific points during Cypress test execution
// - Before any test in the entire test suite runs
// - Before each test file runs
// - After each test file completes
/**
* Makes an API request with the provided configuration
* @param {Object} options - Request options
* @param {string} options.url - The URL to request
* @param {string} options.method - HTTP method (GET, POST, etc.)
* @param {Object} [options.body] - Request body for POST/PUT/PATCH requests
* @param {Object} [options.headers] - Request headers
* @param {string} eventType - Description of when this request is being made
* @returns {Promise} - Promise resolving to the API response
*/
const makeApiRequest = async (options, eventType) => {
console.log(`Making API request for event: ${eventType}`);
try {
const response = await fetch(options.url, {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
...options.headers,
},
...(options.body && { body: JSON.stringify(options.body) }),
});
const data = await response.json();
console.log(
`API request for ${eventType} completed with status: ${response.status}`,
);
console.log(data);
return data;
} catch (error) {
console.error(`API request for ${eventType} failed:`, error);
throw error;
}
};
/**
* Configuration for the different API requests to be made
*/
const apiRequests = {
beforeAllTests: {},
beforeTestFile: {},
afterTestFile: {},
};
/**
* Sets up the plugin in Cypress
* @param {Object} on - Cypress event registration function
* @param {Object} config - Cypress configuration
*/
// Track if event handlers have been registered to prevent duplicate registrations
let eventHandlersRegistered = false;
function setupApiRequestPlugin(on, config) {
// If event handlers are already registered, don't register them again
if (eventHandlersRegistered) {
console.log("API plugin event handlers already registered, skipping...");
return;
}
// Track whether we've already run the before all tests request
let beforeAllTestsExecuted = false;
// Track the current run ID across all tests
let currentRunId = null;
// Before all tests run (only once)
on("before:run", async () => {
if (!beforeAllTestsExecuted) {
apiRequests.beforeAllTests = {
url: `${config.historyTrackerURL || ""}/api/run/create`,
method: "POST",
body: {
runName: `Cypress Run @ ${new Date().toLocaleTimeString()}`,
},
};
const res = await makeApiRequest(
apiRequests.beforeAllTests,
"beforeAllTests",
);
currentRunId = res.runId;
beforeAllTestsExecuted = true;
}
});
// TODO: FIGURE OUT WHY THIS RUNS TWICE
// Process individual test events
on("before:spec", async (test) => {
if (!currentRunId) return;
const testRequest = {
url: `${config.historyTrackerURL || ""}/api/run/create`,
method: "POST",
body: {
runId: currentRunId,
testName: test.name,
path: test.relative,
},
};
await makeApiRequest(testRequest, "beforeTest");
});
on("after:spec", async (test, results) => {
if (!currentRunId) return;
const testRequest = {
url: `${config.historyTrackerURL || ""}/api/run/create`,
method: "POST",
body: {
runId: currentRunId,
testName: test.name,
path: test.relative,
runType: "end",
success: results.stats.failures === 0,
},
};
console.log("specs", results);
await makeApiRequest(testRequest, "afterTest");
});
// Mark event handlers as registered to prevent duplicate registrations
eventHandlersRegistered = true;
console.log("API plugin event handlers registered successfully");
}
module.exports = setupApiRequestPlugin;

9
Client/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"lib": ["es2015", "dom"],
"allowJs": true,
"types": ["cypress"],
"outDir": "./dist"
},
"include": ["cypress/**/*.js", "src/**/*.js"]
}

19
compose.yml Normal file
View file

@ -0,0 +1,19 @@
version: "3.8"
services:
db:
image: postgres:latest
volumes:
- ./docker/db-database:/var/lib/postgresql/data
- ./docker/entrypoint:/docker-entrypoint-initdb.d
environment:
POSTGRES_DB: schoolproject
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
# backend:
# build:
# context: ./Backend
# dockerfile: Dockerfile

View file

@ -0,0 +1,14 @@
SELECT
-- *,
EXTRACT(EPOCH FROM age(tr_end.created_at, tr_start.created_at)) as totalSec,
tr_end.created_at - tr_start.created_at as duration
FROM test_runs tr_end
JOIN test_runs tr_start
ON tr_start.run_id = tr_end.run_id
AND tr_start.test_name = tr_end.test_name
AND tr_start.path = tr_end.path
AND tr_start.run_type = 'start'
AND tr_end.run_type = 'end';
-- LEFT JOIN runs r on td.run_id = r.id
SELECT * from test_durations;

View file

@ -0,0 +1,32 @@
-- Drop existing tables if they exist
DROP TABLE IF EXISTS test_runs;
DROP TABLE IF EXISTS runs;
-- Drop existing enum if it exists
DROP TYPE IF EXISTS run_type;
-- Create an ENUM type for RunType
CREATE TYPE run_type AS ENUM ('start', 'end');
-- Create Runs table
CREATE TABLE
runs (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create TestRuns table
CREATE TABLE
test_runs (
id SERIAL PRIMARY KEY,
run_id INTEGER NOT NULL,
test_name TEXT NOT NULL,
path TEXT NOT NULL,
run_type run_type NOT NULL,
success BOOLEAN,
pending BOOLEAN default true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (run_id) REFERENCES runs (id)
);

View file

@ -0,0 +1,104 @@
-- Insert example data into runs table
INSERT INTO runs (name,created_at) VALUES
('Test Suite Run 1', (now() - interval '1 min')),
('Test Suite Run 2', (now() - interval '1.5 min')),
('Integration Tests', (now() - interval '1 days 2 min'));
-- Insert example data into test_runs table
INSERT INTO test_runs (run_id, test_name, path, run_type, created_at, success, pending ) VALUES
(1, 'Login Test', '/tests/auth/login.test.ts', 'start', (now()), true, false),
(1, 'Login Test', '/tests/auth/login.test.ts', 'end', (now() + interval '8 second'), true, false),
(1, 'Registration Test', '/tests/auth/register.test.ts', 'start', (now() + interval '10 second'), true, false),
(1, 'Registration Test', '/tests/auth/register.test.ts', 'end', (now() + interval '25 second'), true, false),
(1, 'Password Reset Test', '/tests/auth/password-reset.test.ts', 'start', (now() + interval '30 second'), true, false),
(1, 'Password Reset Test', '/tests/auth/password-reset.test.ts', 'end', (now() + interval '42 second'), true, false),
(2, 'Login Test', '/tests/auth/login.test.ts', 'start', (now() + interval '1 minute'), true, false),
(2, 'Login Test', '/tests/auth/login.test.ts', 'end', (now() + interval '1 minute 12 second'), false, false),
(2, 'Registration Test', '/tests/auth/register.test.ts', 'start', (now() + interval '2 minute'), true, false),
(2, 'Registration Test', '/tests/auth/register.test.ts', 'end', (now() + interval '2 minute 18 second'), true, false),
(2, 'Password Reset Test', '/tests/auth/password-reset.test.ts', 'start', (now() + interval '3 minute'), true, false),
(2, 'Password Reset Test', '/tests/auth/password-reset.test.ts', 'end', (now() + interval '5 minute 15.5 second'), false, false),
(3, 'API Integration', '/tests/api/integration.test.ts', 'start', (now() + interval '1 hours'), true, false),
(3, 'API Integration', '/tests/api/integration.test.ts', 'end', (now() + interval '1 hours 8 minutes'), true, false),
(3, 'Database Integration', '/tests/api/database.test.ts', 'start', (now() + interval '1 hours 10 minutes' - interval '1 days'), true, false),
(3, 'Database Integration', '/tests/api/database.test.ts', 'end', (now() + interval '1 hours 25 minutes' - interval '1 days'), false, false);
-- Create a view that shows test durations
CREATE VIEW test_durations AS
SELECT
tr_end.run_id,
tr_end.test_name,
tr_end.path,
EXTRACT(EPOCH FROM age(tr_end.created_at, tr_start.created_at)) as totalSeconds,
tr_start.created_at as started_at,
tr_end.created_at as ended_at
FROM test_runs tr_end
JOIN test_runs tr_start
ON tr_start.run_id = tr_end.run_id
AND tr_start.test_name = tr_end.test_name
AND tr_start.path = tr_end.path
AND tr_start.run_type = 'start'
AND tr_end.run_type = 'end';
-- Create a view that shows total run durations
CREATE VIEW run_durations AS
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;
-- Create a view that returns JSON-structured test results
CREATE VIEW formatted_test_results AS
WITH test_results AS (
SELECT tr.run_id, tr.test_name, tr.path, tr.success, td.totalSeconds
FROM test_runs tr
JOIN test_durations td ON td.run_id = tr.run_id
AND td.test_name = tr.test_name
AND td.path = tr.path
WHERE tr.run_type = 'end'
),
test_runs_json AS (
SELECT
rd.run_id, rd.run_name, rd.totalSeconds, rd.run_date, SUM(CASE WHEN tr.success THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN NOT tr.success THEN 1 ELSE 0 END) as failure_count,
json_agg(
json_build_object(
'test_name', tr.test_name,
'path', tr.path,
'duration', tr.totalSeconds,
-- 'duration_seconds', ROUND(EXTRACT(EPOCH FROM tr.duration)::numeric, 2),
'status', CASE WHEN tr.success THEN 'success' ELSE 'failure' END
) ORDER BY tr.test_name
) as children
FROM run_durations rd
JOIN test_results tr ON tr.run_id = rd.run_id
GROUP BY rd.run_id, rd.run_name, rd.totalSeconds, rd.run_date
)
SELECT json_build_object(
'run_id', trj.run_id,
'run_name', trj.run_name,
'date', trj.run_date,
'total_duration', trj.totalSeconds,
'success_count', trj.success_count,
'failure_count', trj.failure_count,
'children', trj.children
) as result
FROM test_runs_json trj
ORDER BY trj.run_id;

1
package.json Normal file
View file

@ -0,0 +1 @@
{"devDependencies":{"cypress":"^14.3.2"}}

1354
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- cypress