i'm about to rework so much better save
This commit is contained in:
commit
8bb5fee1c0
45 changed files with 4600 additions and 0 deletions
16
.devcontainer/Dockerfile
Normal file
16
.devcontainer/Dockerfile
Normal 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"
|
31
.devcontainer/devcontainer.json
Normal file
31
.devcontainer/devcontainer.json
Normal 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
47
.gitignore
vendored
Normal 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
15
.vscode/settings.json
vendored
Normal 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
14
Backend/.env.example
Normal 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
29
Backend/README.md
Normal 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
26
Backend/biome.jsonc
Normal 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
21
Backend/components.json
Normal 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
10
Backend/next.config.js
Normal 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
48
Backend/package.json
Normal 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
1485
Backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Backend/pnpm-workspace.yaml
Normal file
3
Backend/pnpm-workspace.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@biomejs/biome'
|
||||||
|
- sharp
|
5
Backend/postcss.config.js
Normal file
5
Backend/postcss.config.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
BIN
Backend/public/favicon.ico
Normal file
BIN
Backend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
127
Backend/src/app/api/data/route.ts
Normal file
127
Backend/src/app/api/data/route.ts
Normal 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);
|
||||||
|
}
|
64
Backend/src/app/api/run/create/route.ts
Normal file
64
Backend/src/app/api/run/create/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
56
Backend/src/app/layout.tsx
Normal file
56
Backend/src/app/layout.tsx
Normal 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
42
Backend/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
133
Backend/src/components/date-group.tsx
Normal file
133
Backend/src/components/date-group.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
58
Backend/src/components/foldable-list.tsx
Normal file
58
Backend/src/components/foldable-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
114
Backend/src/components/run-item.tsx
Normal file
114
Backend/src/components/run-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
46
Backend/src/components/ui/badge.tsx
Normal file
46
Backend/src/components/ui/badge.tsx
Normal 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 }
|
57
Backend/src/components/ui/button.tsx
Normal file
57
Backend/src/components/ui/button.tsx
Normal 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 };
|
11
Backend/src/components/ui/collapsible.tsx
Normal file
11
Backend/src/components/ui/collapsible.tsx
Normal 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
42
Backend/src/env.js
Normal 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
16
Backend/src/lib/types.ts
Normal 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
8
Backend/src/lib/utils.ts
Normal 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) {}
|
125
Backend/src/styles/globals.css
Normal file
125
Backend/src/styles/globals.css
Normal 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
10
Backend/src/utils/db.ts
Normal 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;
|
19
Backend/src/utils/toCSV.ts
Normal file
19
Backend/src/utils/toCSV.ts
Normal 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
42
Backend/tsconfig.json
Normal 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
13
Client/cypress.config.js
Normal 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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
143
Client/cypress/e2e/1-getting-started/todo.cy.js
Normal file
143
Client/cypress/e2e/1-getting-started/todo.cy.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
5
Client/cypress/fixtures/example.json
Normal file
5
Client/cypress/fixtures/example.json
Normal 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"
|
||||||
|
}
|
0
Client/cypress/support/e2e.js
Normal file
0
Client/cypress/support/e2e.js
Normal file
49
Client/rollup.config.js
Normal file
49
Client/rollup.config.js
Normal 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
135
Client/src/index.js
Normal 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
9
Client/tsconfig.json
Normal 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
19
compose.yml
Normal 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
|
14
docker/ConnectionTests/01.sql
Normal file
14
docker/ConnectionTests/01.sql
Normal 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;
|
32
docker/entrypoint/01-init.sql
Normal file
32
docker/entrypoint/01-init.sql
Normal 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)
|
||||||
|
);
|
104
docker/entrypoint/02-seed.sql
Normal file
104
docker/entrypoint/02-seed.sql
Normal 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
1
package.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"devDependencies":{"cypress":"^14.3.2"}}
|
1354
pnpm-lock.yaml
generated
Normal file
1354
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- cypress
|
Loading…
Add table
Reference in a new issue