mass
This commit is contained in:
parent
8bb5fee1c0
commit
26375a13b1
19 changed files with 499 additions and 296 deletions
21
.github/workflows/frontend-build.yml
vendored
Normal file
21
.github/workflows/frontend-build.yml
vendored
Normal 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
71
.github/workflows/test-environment.yml
vendored
Normal 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
17
.vscode/settings.json
vendored
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -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="">
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}>;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
34
Backend/src/utils/handleCSVExport.ts
Normal file
34
Backend/src/utils/handleCSVExport.ts
Normal 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);
|
||||
};
|
|
@ -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
|
||||
])
|
||||
);
|
||||
|
||||
|
|
143
Client/cypress/e2e/1-getting-started/todo2.cy.js
Normal file
143
Client/cypress/e2e/1-getting-started/todo2.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", 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 |
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue