This commit is contained in:
Lio 2025-05-23 16:51:31 +02:00
parent 8bb5fee1c0
commit 26375a13b1
19 changed files with 499 additions and 296 deletions

21
.github/workflows/frontend-build.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Build Next.js App
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Node.js environment
uses: actions/setup-node@v1
with:
node-version: 18
- name: Install Dependencies
run: npm ci
- name: Build Next App
run: npm run build

71
.github/workflows/test-environment.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: Test Environment Setup
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
jobs:
setup-test-environment:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_DB: schoolproject
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Setup PNPM
uses: pnpm/action-setup@v3
with:
version: 10.8.0
run_install: false
- name: Install dependencies
run: pnpm install
- name: Setup Backend
run: |
cd Backend
cp .env.example .env
pnpm build
- name: Run Tests
run: |
# Backend tests
cd Backend
pnpm test || echo "No backend tests found or tests failed"
# Frontend tests with Cypress
cd ../Client
pnpm cypress run || echo "No frontend tests found or tests failed"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
Backend/test-results
Client/cypress/screenshots
Client/cypress/videos

17
.vscode/settings.json vendored
View file

@ -11,5 +11,20 @@
"password": "postgres"
}
],
"sqltools.results.reuseTabs": "connection"
"sqltools.results.reuseTabs": "connection",
"files.exclude": {
"**/.env*": true,
"**/.next": true,
"**/.pnpm-store": true,
"**/.vscode": true,
"**/*.config*": true,
"**/biome.jsonc": true,
"**/next-env.d.ts": true,
"**/node_modules": true,
"**/pnpm-*": true,
"**/README.md": true,
"**/tsconfig.json": true,
"docker/ConnectionTests": true,
"docker/db-database": true
}
}

View file

@ -1,127 +1,50 @@
import pool from "@/utils/db";
import { NextResponse } from "next/server";
export async function GET() {
const runResults = await pool.query<{
run_id: number;
run_name: string;
run_date: string;
totalseconds: string;
}>(`
SELECT
r.id as run_id, r.name as run_name,
r.created_at as run_date, SUM(td.totalSeconds) as totalSeconds
FROM runs r
LEFT JOIN test_durations td ON td.run_id = r.id
GROUP BY r.id, r.name
ORDER BY r.id
`);
const runResults = await pool
.query<{
id: number;
name: string;
created_at: Date;
}>(`select * from runs;`)
.then((r) => r.rows);
// Get test results for each run
// Get start times for each test
const startTimes = await pool.query<{
run_id: number;
test_name: string;
path: string;
start_time: Date;
}>(`
SELECT
run_id,
test_name,
path,
created_at as start_time
FROM test_runs
WHERE run_type = 'start'
`);
const testResults = await pool
.query<{
id: number;
run_id: number;
test_name: string;
path: string;
duration: number;
success?: boolean;
pending: boolean;
created_at: Date;
}>(`select * from test_runs;`)
.then((r) => r.rows);
// Get end times and results for each test
const endResults = await pool.query<{
run_id: number;
test_name: string;
path: string;
end_time: Date;
success: boolean;
pending: boolean;
}>(`
SELECT
run_id,
test_name,
path,
created_at as end_time,
success,
COALESCE(pending, false) as pending
FROM test_runs
WHERE run_type = 'end'
`);
// Combine the results
const testResults = {
rows: endResults.rows.map(end => {
const start = startTimes.rows.find(
start =>
start.run_id === end.run_id &&
start.test_name === end.test_name &&
start.path === end.path
);
if (!start) return null;
return {
run_id: end.run_id,
test_name: end.test_name,
path: end.path,
totalseconds: (end.end_time.getTime() - start.start_time.getTime()) / 1000,
success: end.success,
pending: end.pending
};
}).filter(Boolean)
};
// Add unfinished tests
const unfinishedTests = startTimes.rows.filter(start =>
!endResults.rows.some(end =>
end.run_id === start.run_id &&
end.test_name === start.test_name &&
end.path === start.path
)
).map(start => ({
run_id: start.run_id,
test_name: start.test_name,
path: start.path,
totalseconds: 0, // Test hasn't finished yet
success: false,
pending: true
}));
testResults.rows.push(...unfinishedTests);
// Combine and format results
const formatted = runResults.rows.map((run) => {
const runTests = testResults.rows.filter(
(test): test is NonNullable<typeof test> => test !== null && test.run_id === run.run_id
)
const totalDurationSeconds = Number(run.totalseconds);
const formatted = runResults.map((run) => {
const children = testResults.filter(
(test) => test !== null && test.run_id === run.id
);
const totalDuration = children.reduce(
(n, child) => n + Number(child.duration),
0
);
return {
run_id: run.run_id,
run_name: run.run_name,
date: run.run_date,
totalseconds: run.totalseconds,
success_count: runTests.filter((test) => test.success).length,
failure_count: runTests.filter((test) => !test.success).length,
children: runTests.map((test) => ({
test_name: test.test_name,
path: test.path,
totalseconds: test.totalseconds,
percentage_of_total: Number(
((test.totalseconds / totalDurationSeconds) * 100).toFixed(2)
),
status: test.success ? "success" : "failure",
pending: test.pending,
run_id: run.id,
run_name: run.name,
date: run.created_at,
totalseconds: totalDuration,
success_count: children.filter((test) => test.success).length,
failure_count: children.filter((test) => !test.success).length,
children: children.map((test) => ({
...test,
duration: Number(test.duration),
})),
};
});
return Response.json(formatted);
return NextResponse.json(formatted);
}

View file

@ -6,14 +6,14 @@ export async function POST(request: Request) {
try {
let {
runName,
testId,
testName,
path,
duration,
success,
runId: passedRunId,
runType = "start"
} = await request.json();
let pending;
if (!passedRunId) {
const res = await pool
.query(
@ -23,30 +23,34 @@ export async function POST(request: Request) {
RETURNING id
)
SELECT id FROM new_run;`,
[runName]
[runName],
)
.then((r) => r.rows[0].id);
return NextResponse.json({runId: res})
return NextResponse.json({ runId: res });
}
// create as Pending by default, then update and add the duration from the passed information
// can likely remove runType as a whole?
if (runType === "end") pending = false;
else pending = true;
// Now create the test run entry with 'pending' status
if (passedRunId) {
await pool.query(
`INSERT INTO test_runs (run_id, test_name, path, run_type, created_at, success, pending)
VALUES ($1, $2, $3, $4, NOW(), $5, $6);`,
[passedRunId, testName, path, runType, success, pending]
if (!testId) {
const res = await pool.query(
`INSERT INTO test_runs (run_id, test_name, path)
VALUES ($1, $2, $3) returning id`,
[passedRunId, testName, path],
);
return NextResponse.json({
testId: res.rows[0].id,
});
}
if (testId) {
if (duration) {
await pool.query(
`UPDATE test_runs SET pending = false, success = $3, duration = $2 WHERE id = $1;`,
[testId, duration, success],
);
return NextResponse.json({ testId });
}
}
return NextResponse.json({ runId });
} catch (error) {
console.error("Error creating run:", error);
@ -58,7 +62,7 @@ export async function POST(request: Request) {
// @ts-ignore
detail: error.detail,
},
{ status: 500 }
{ status: 500 },
);
}
}

View file

@ -1,6 +1,5 @@
"use client";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import {
@ -13,6 +12,7 @@ import { useEffect } from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchInterval: 1000 * 1, // Useful in Prod so it updates every second and shows pending states
refetchOnWindowFocus: true,
},
},
@ -30,7 +30,6 @@ export default function RootLayout({
focusManager.setEventListener((handleFocus) => {
const focus = () => {
if (!document.hidden) {
// console.log("Focus from parent");
handleFocus(true);
queryClient.refetchQueries(); // Without this, the above 'focus from parent' will be logged but queries will only refetch the first time the window is refocused from a different program. After that, data will not be refetched.
}

View file

@ -8,7 +8,6 @@ const fetcher = (url: string) => {
return fetch(url).then((r) => r.json());
};
// Update the page component to use full width
export default function Home() {
const { isPending, error, data, isError } = useQuery({
queryKey: ["historicData"],
@ -19,7 +18,6 @@ export default function Home() {
<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="">

View file

@ -10,7 +10,7 @@ import {
} from "@/components/ui/collapsible";
import { Button } from "./ui/button";
import type { Result } from "@/lib/types";
import toCSV from "@/utils/toCSV";
import { handleExportCSV } from "@/utils/handleCSVExport";
import { RunItem } from "./run-item";
interface DateGroupProps {
@ -45,35 +45,6 @@ export function DateGroup({ dateGroup }: DateGroupProps) {
};
}, [dateGroup]);
const handleExportCSV = () => {
// Create CSV header
const headers = [
"Run Name",
"Test Name",
"Path",
"Status",
"Duration (s)",
"Percentage of Total",
];
// Create and trigger download
const blob = new Blob([...toCSV(headers, dateGroup)], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`test-results-${new Date(dateGroup.date)
.toISOString()
.substring(0, 10)}.csv`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="mb-4 border border-zinc-600 rounded-lg overflow-hidden">
<Collapsible
@ -89,7 +60,7 @@ export function DateGroup({ dateGroup }: DateGroupProps) {
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleExportCSV();
handleExportCSV(dateGroup);
}}
className="flex items-center gap-2"
>
@ -110,7 +81,7 @@ export function DateGroup({ dateGroup }: DateGroupProps) {
<ChevronDown
className={cn(
"h-4 w-4 text-zinc-100 transition-transform duration-200",
isOpen && "rotate-180"
isOpen && "rotate-180",
)}
/>
</div>
@ -120,7 +91,7 @@ export function DateGroup({ dateGroup }: DateGroupProps) {
{dateGroup.runs
.sort(
(a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
new Date(b.date).getTime() - new Date(a.date).getTime(),
)
.map((run) => (
<RunItem key={run.run_id} result={run} />

View file

@ -19,6 +19,13 @@ interface FoldableListProps {
}
export function FoldableList({ results, className }: FoldableListProps) {
if(!results || results.length === 0)
return (
<h1 className="text-center text-red-400">
No test results found.
</h1>
);
if (!results[0]?.run_id)
return (
<h1 className="text-center text-red-400">

View file

@ -21,7 +21,11 @@ export function RunItem({ result }: RunItemProps) {
const successPercentage =
totalTests > 0 ? (result.success_count / totalTests) * 100 : 0;
const formatSeconds = (seconds: number | string): string => {
const formatSeconds = (milliseconds?: number): string => {
if(!milliseconds) {
return "--:--:--";
}
var seconds:number|string = milliseconds / 1000;
var hours: number | string = Math.floor((seconds as number) / 3600);
var minutes: number | string = Math.floor(
((seconds as number) - hours * 3600) / 60
@ -92,7 +96,7 @@ export function RunItem({ result }: RunItemProps) {
"w-2 h-2 rounded-full",
child.pending
? "bg-blue-500"
: child.status === "success"
: child.success
? "bg-green-500"
: "bg-red-500"
)}
@ -102,7 +106,7 @@ export function RunItem({ result }: RunItemProps) {
</div>
<div className="flex items-center gap-4 pr-8">
<span className="text-sm text-zinc-300 w-12 text-right">
{formatSeconds(child.totalseconds)}
{formatSeconds(child.duration)}
</span>
</div>
</div>

View file

@ -6,11 +6,12 @@ export type Result = {
success_count: number;
failure_count: number;
children: Array<{
id: number;
run_id: number;
test_name: string;
path: string;
totalseconds: number;
percentage_of_total: number;
status: "success" | "failure";
duration?: number;
success?: boolean;
pending: boolean
}>;
};

View file

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

View file

@ -0,0 +1,34 @@
"use client";
import toCSV from "./toCSV";
export const handleExportCSV = (dateGroup: any) => {
// Create CSV header
const headers = [
"Run Name",
"Test Name",
"Path",
"Success",
"Duration (s)"
];
console.log(dateGroup);
console.log(toCSV(headers, dateGroup));
// Create and trigger download
const blob = new Blob([...toCSV(headers, dateGroup)], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`test-results-${new Date(dateGroup.date)
.toISOString()
.substring(0, 10)}.csv`,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

View file

@ -4,14 +4,14 @@ export default function toCSV(
headers: string[],
rows: { date: string; runs: Result[] }
) {
console.log(rows);
const a = rows.runs.flatMap((run) =>
run.children.map((child) => [
run.run_name,
child.test_name,
child.path,
child.status,
child.totalseconds,
child.percentage_of_total,
child.success? "true" : "false",
child.duration!/1000
])
);

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", 3);
// We can go even further and check that the default todos each contain
// the correct text. We use the `first` and `last` functions
// to get just the first and last matched elements individually,
// and then perform an assertion with `should`.
cy.get(".todo-list li").first().should("have.text", "Pay electric bill");
cy.get(".todo-list li").last().should("have.text", "Walk the dog");
});
it("can add new todo items", () => {
// We'll store our item text in a variable so we can reuse it
const newItem = "Feed the cat";
// Let's get the input element and use the `type` command to
// input our new list item. After typing the content of our item,
// we need to type the enter key as well in order to submit the input.
// This input has a data-test attribute so we'll use that to select the
// element in accordance with best practices:
// https://on.cypress.io/selecting-elements
cy.get("[data-test=new-todo]").type(`${newItem}{enter}`);
// Now that we've typed our new item, let's check that it actually was added to the list.
// Since it's the newest item, it should exist as the last element in the list.
// In addition, with the two default items, we should have a total of 3 elements in the list.
// Since assertions yield the element that was asserted on,
// we can chain both of these assertions together into a single statement.
cy.get(".todo-list li")
.should("have.length", 3)
.last()
.should("have.text", newItem);
});
it("can check off an item as completed", () => {
// In addition to using the `get` command to get an element by selector,
// we can also use the `contains` command to get an element by its contents.
// However, this will yield the <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");
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -61,69 +61,79 @@ function setupApiRequestPlugin(on, config) {
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;
// Globals to store the current run ID and test ID
let currentRunId, currentTestId, currentTestName = null;
// Before all tests run (only once)
on("before:run", async () => {
if (!beforeAllTestsExecuted) {
apiRequests.beforeAllTests = {
url: `${config.historyTrackerURL || ""}/api/run/create`,
method: "POST",
body: {
body: {
// This Request creates a new Run, which can then be added
// to with the beforeTestFile and afterTestFile requests
runName: `Cypress Run @ ${new Date().toLocaleTimeString()}`,
},
};
const res = await makeApiRequest(
apiRequests.beforeAllTests,
"beforeAllTests",
);
const res = await makeApiRequest(apiRequests.beforeAllTests,"beforeAllTests",);
// Store the run ID for later use
// This ID is used to associate all test files with this run
// and is passed to the beforeTestFile and afterTestFile requests
currentRunId = res.runId;
beforeAllTestsExecuted = true;
}
});
// TODO: FIGURE OUT WHY THIS RUNS TWICE
// Process individual test events
on("before:spec", async (test) => {
// If the run ID is not set, skip this request
if (!currentRunId) return;
// If the test name is the same as the last one, skip this request
// This prevents duplicate requests for the same test this is
// important because the beforeTestFile request is made
// twice when using cy.visit() (for some reason) (this is a bug in Cypress)
if (currentTestName === test.name) return;
const testRequest = {
url: `${config.historyTrackerURL || ""}/api/run/create`,
method: "POST",
body: {
// This Request creates a new Test, it will be marked as pending
// in the Database and on the Frontend
runId: currentRunId,
testName: test.name,
path: test.relative,
},
};
await makeApiRequest(testRequest, "beforeTest");
const res = await makeApiRequest(testRequest, "beforeTest");
currentTestId = res.testId;
currentTestName = test.name;
});
on("after:spec", async (test, results) => {
if (!currentRunId) return;
// after:spec does not have the same bug as before:spec
// so we don't need to check for duplicates
const testRequest = {
url: `${config.historyTrackerURL || ""}/api/run/create`,
method: "POST",
body: {
runId: currentRunId,
testName: test.name,
path: test.relative,
runType: "end",
testId: currentTestId, // This request updates the Test created in beforeTestFile
testName: test.name,
path: test.relative,
// This checks if there were any failures,
// if there were, it marks the test as failed
// otherwise it marks it as passed
success: results.stats.failures === 0,
// This is the duration of the test in milliseconds
duration: results.stats.duration,
},
};
console.log("specs", results);
await makeApiRequest(testRequest, "afterTest");
});

View file

@ -3,11 +3,11 @@ DROP TABLE IF EXISTS test_runs;
DROP TABLE IF EXISTS runs;
-- Drop existing enum if it exists
DROP TYPE IF EXISTS run_type;
-- -- 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 an ENUM type for RunType
-- CREATE TYPE run_type AS ENUM ('start', 'end');
-- Create Runs table
CREATE TABLE
@ -24,9 +24,13 @@ CREATE TABLE
run_id INTEGER NOT NULL,
test_name TEXT NOT NULL,
path TEXT NOT NULL,
run_type run_type NOT NULL,
duration bigint,
success BOOLEAN,
pending BOOLEAN default true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (run_id) REFERENCES runs (id)
);
);

View file

@ -1,104 +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 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),
-- -- 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, '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),
-- (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, '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, '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),
-- (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, '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);
-- (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 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 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;
-- -- 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;