diff --git a/.cursor/agents/devops.md b/.cursor/agents/devops.md new file mode 100644 index 00000000..4d9b6785 --- /dev/null +++ b/.cursor/agents/devops.md @@ -0,0 +1,30 @@ +--- +name: devops +description: >- + DevOps specialist. Use for Docker, GitHub Actions, dual-registry image publish (GHCR + Docker Hub), + semver/prerelease tags, keeping latest stable-only, CHANGELOG, production build checks, and + container runtime env (PORT, data volumes). Invoke after release-related or CI changes. +model: fast +readonly: false +--- + +You own **shipping**: containers, CI/CD, release tagging, and user-facing run instructions. + +## Scope + +- **Docker**: [`Dockerfile`](Dockerfile), [`.dockerignore`](.dockerignore); multi-stage build; `NODE_ENV=production`; non-root user; `HEALTHCHECK` on `GET /api/ping`; persist **`/app/data`** via volume. +- **Runtime**: `PORT` from environment (default 3000). `npx playwright merge-reports` does **not** require `playwright install` or browsers in the image (CLI from `@playwright/test` is enough — validated). +- **CI**: [`.github/workflows/pull_request.yml`](.github/workflows/pull_request.yml), [`.github/workflows/release.yml`](.github/workflows/release.yml). +- **Registries**: `ghcr.io/${{ github.repository }}` and `docker.io//playwright-reports-server` (see workflow `env`). +- **Tags**: semver from release tag; **`latest` only when** `github.event.release.prerelease == false`; prereleases also get a floating **`beta`** tag (no `latest`). +- **Changelog**: [CHANGELOG.md](CHANGELOG.md) ([Keep a Changelog](https://keepachangelog.com/)); bump [package.json](package.json) `version` when cutting releases. +- **Secrets**: `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN` (Docker Hub); GHCR uses `GITHUB_TOKEN`. + +## Checks before merging DevOps changes + +- `npm run lint`, `npm run build`, `npm run test:unit`. +- Local optional: `docker build -t prs:test .` and `docker run --rm -p 3000:3000 -v prs-data:/app/data prs:test`. + +## Report back + +- Files touched, required new secrets/vars, and any breaking changes for operators (ports, paths, env). diff --git a/.cursor/agents/docs-maintainer.md b/.cursor/agents/docs-maintainer.md new file mode 100644 index 00000000..c1564e4f --- /dev/null +++ b/.cursor/agents/docs-maintainer.md @@ -0,0 +1,43 @@ +--- +name: docs-maintainer +description: >- + Documentation maintainer. Use proactively after code changes that affect behavior, API, + env vars, auth, storage, or developer workflow. Syncs human and agent-facing docs with the + codebase. Invoke with a concise list of what changed (files, new endpoints, env keys). +model: fast +readonly: false +--- + +You maintain project documentation so it matches the **current** code. You do not invent features; if something is only partially implemented, say so explicitly (same policy as **AGENTS.md**). + +## When you are invoked + +The parent agent must pass: + +1. **Summary of code changes** — what behavior, APIs, schema, or UX changed (bullet list is enough). +2. **Touched paths** — key files (e.g. `server.ts`, `src/openapi.ts`, `src/db.ts`). + +If that context is missing, infer from `git diff` or by reading the files they name, then proceed. + +## Files to keep in sync (in order of priority) + +1. **[AGENTS.md](AGENTS.md)** — Stack, file map, auth, data dirs, “implemented vs not”, how to add routes. Update any section that is now wrong. +2. **[README.md](README.md)** — Quick start, scripts, links to `/api/docs`, prerequisites. No outdated stack or env instructions. +3. **[.env.example](.env.example)** — New/changed/removed env vars; keep NOTES honest (S3 / cron limitations if still accurate). +4. **[src/openapi.ts](src/openapi.ts)** — `info.description`, `securitySchemes`, route `summary`/`description`/`responses` when HTTP contract or auth story changes. +5. **Inline comments** — Only where a short comment prevents repeated agent confusion (e.g. non-obvious middleware). Do not spam comments. + +## Rules + +- Prefer **small, accurate edits** over rewriting entire documents. +- Preserve **Conventional Commits** if you are asked to commit: use `docs:` for doc-only changes. +- After OpenAPI edits, ensure `ROUTE_SPECS` and `handlers` in `server.ts` still align with documented paths and `operationId`s. +- If a change makes a doc claim false (e.g. “cron deletes files”), fix the doc immediately. + +## Output back to the parent + +Return a short report: + +- Which files you updated (or “none needed” with reason). +- What you changed in one sentence per file. +- Anything still inconsistent that needs a follow-up code change (not a doc fix). diff --git a/.cursor/agents/unit-test-writer.md b/.cursor/agents/unit-test-writer.md new file mode 100644 index 00000000..2ac09c5b --- /dev/null +++ b/.cursor/agents/unit-test-writer.md @@ -0,0 +1,41 @@ +--- +name: unit-test-writer +description: >- + Unit test author. Use proactively after implementing or changing pure logic, utilities, + OpenAPI helpers, parsers, or isolated modules. Adds or updates Vitest tests; keeps tests + fast and deterministic. Pass the feature, files changed, and edge cases to cover. +model: fast +readonly: false +--- + +You write and maintain **unit tests** with **Vitest** (this project uses `npm run test:unit`). + +## Preconditions + +- Tests live next to code as `*.test.ts` under `src/` (see `vitest.config.ts` `include`) unless the team agreed on another folder. +- Prefer **Node** environment for server-side pure functions. Use **jsdom** only when testing React components (add `environment: 'jsdom'` per file via `// @vitest-environment jsdom` or a separate vitest project if needed). +- Do **not** hit real network, real SQLite files, or real `data/` in unit tests — mock `fs`, `db`, or HTTP as needed. + +## When you are invoked + +The parent should pass: + +1. **What to cover** — functions, branches, or regression bugs. +2. **Files** — implementation paths (e.g. `src/openapi.ts`). +3. **Out of scope** — integration/E2E belongs to Playwright API tests under `tests/api` (or similar), not here. + +If context is missing, read the implementation first, then add tests. + +## Conventions + +- Use `import { describe, it, expect, vi, beforeEach } from 'vitest'`. +- One behavior per `it`; clear `describe` group names. +- Test **public exports** and behavior, not private implementation details when avoidable. +- For `server.ts` — extract testable pure helpers into `src/lib/*.ts` when logic is buried in handlers, **or** use `supertest` in a separate integration suite only if the user explicitly asks (default: unit-test pure modules first). +- After adding tests, run `npm run test:unit` and fix failures before returning. + +## Output back to the parent + +- List new/updated test files. +- Brief note of what scenarios are covered. +- Gaps that need integration tests or refactoring to be testable. diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 00000000..9b2707ec --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,18 @@ +--- +description: Project context for Playwright Reports Server — read AGENTS.md first; Conventional Commits for git. +alwaysApply: true +--- + +# Playwright Reports Server + +Before changing architecture, API routes, storage, or auth behavior, read **AGENTS.md** at the repository root. It maps `server.ts`, `src/openapi.ts`, `src/db.ts`, and documents what is implemented vs config-only (S3, background expiration). + +After behavior or API changes, use the **docs-maintainer** subagent (`.cursor/agents/docs-maintainer.md`) — proactively or via `/docs-maintainer` — so README, AGENTS.md, `.env.example`, and OpenAPI descriptions stay accurate. Pass a brief summary of edits and file paths. + +For new or changed **pure logic** (helpers, parsers, OpenAPI utilities), use **unit-test-writer** (`.cursor/agents/unit-test-writer.md`) or `/unit-test-writer`; run `npm run test:unit` after adding tests. + +For **Docker, CI/CD, releases, CHANGELOG**, and image publishing, use **devops** (`.cursor/agents/devops.md`) or `/devops`. + +## Commits + +Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for all commit messages (e.g. `feat:`, `fix:`, `docs:`). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..146799da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +data +.git +.github +.cursor +*.md +!README.md +.env +.env.* +!.env.example +coverage +testdata +*.log +.DS_Store diff --git a/.env.example b/.env.example index 10ee5100..b18529ad 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,102 @@ -# Next Auth -# You can generate a new secret on the command line with: -# `npm exec auth secret` -# OR -# `openssl rand -base64 32`` -# https://next-auth.js.org/configuration/options#secret -AUTH_SECRET= -AUTH_URL=http://localhost:3000 - -# API token details -API_TOKEN='my-api-token' -UI_AUTH_EXPIRE_HOURS='2' - -# Storage details -DATA_STORAGE=fs # could be s3 - -# S3 related configuration if DATA_STORAGE is "s3" -S3_ENDPOINT="s3.endpoint", -S3_ACCESS_KEY="some_access_key" -S3_SECRET_KEY="some_secret_key" -S3_PORT=9000 # optional -S3_REGION="us-east-1" -S3_BUCKET="bucket_name" # by default "playwright-reports-server" -S3_BATCH_SIZE=10 # by default 10 \ No newline at end of file +# ============================================================================= +# Playwright Reports Hub — environment template +# Copy to .env and set values. Do not commit .env (it is gitignored). +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Example: local dev (no auth, filesystem storage) +# ----------------------------------------------------------------------------- +# DATA_STORAGE=fs + +# ----------------------------------------------------------------------------- +# Example: local dev with auth +# ----------------------------------------------------------------------------- +# API_TOKEN=my-dev-token-12345 +# DATA_STORAGE=fs + +# ----------------------------------------------------------------------------- +# Example: expiration / cron (UI + config API only in this repo) +# ----------------------------------------------------------------------------- +# NOTE: RESULT_/REPORT_EXPIRE_* and cron schedules are stored and shown in the UI; +# this codebase does not run a background scheduler to delete files. See AGENTS.md. +# ----------------------------------------------------------------------------- +# API_TOKEN=my-secret-token +# DATA_STORAGE=fs +# RESULT_EXPIRE_DAYS=7 +# REPORT_EXPIRE_DAYS=30 +# RESULT_EXPIRE_CRON_SCHEDULE=33 3 * * * +# REPORT_EXPIRE_CRON_SCHEDULE=44 4 * * * + +# ----------------------------------------------------------------------------- +# Example: S3 (MinIO / AWS) — not wired in server code +# ----------------------------------------------------------------------------- +# NOTE: DATA_STORAGE=s3 and S3_* vars are not implemented for blob/report I/O here; +# storage is local filesystem under data/. Values may still appear in config responses. See AGENTS.md. +# ----------------------------------------------------------------------------- +# API_TOKEN=my-secret-token +# DATA_STORAGE=s3 +# S3_ENDPOINT=localhost +# S3_ACCESS_KEY=minioadmin +# S3_SECRET_KEY=minioadmin +# S3_PORT=9000 +# S3_REGION=us-east-1 +# S3_BUCKET=playwright-reports +# S3_BATCH_SIZE=10 + +# ============================================================================= +# All options (reference) +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Auth (optional) +# ----------------------------------------------------------------------------- +# When set, protected API routes and report serving require this token. +# Leave unset for local dev without auth. +# API_TOKEN=your-secret-token + +# UI session (if using session-based auth in future) +# AUTH_SECRET=random-secret-for-jwt +# UI_AUTH_EXPIRE_HOURS=2 + +# ----------------------------------------------------------------------------- +# Storage +# ----------------------------------------------------------------------------- +# fs = filesystem (default), s3 = S3-compatible (MinIO, etc.) +# DATA_STORAGE=fs + +# In-memory cache for lists/config (single-instance only) +# USE_SERVER_CACHE=false + +# ----------------------------------------------------------------------------- +# S3 (only when DATA_STORAGE=s3) — reserved / future; server uses data/ on disk today +# ----------------------------------------------------------------------------- +# S3_ENDPOINT=localhost +# S3_ACCESS_KEY= +# S3_SECRET_KEY= +# S3_PORT=9000 +# S3_REGION=auto +# S3_BUCKET=playwright-reports-server +# S3_BATCH_SIZE=10 + +# ----------------------------------------------------------------------------- +# Cron / expiration (optional) — persisted for UI; no deletion worker in this repo +# ----------------------------------------------------------------------------- +# Days to keep results/reports before cron deletes them (decimal allowed, e.g. 0.25 = 6h) +# RESULT_EXPIRE_DAYS=7 +# REPORT_EXPIRE_DAYS=30 + +# Cron schedules (defaults: 3:33 AM and 4:44 AM daily) +# RESULT_EXPIRE_CRON_SCHEDULE=33 3 * * * +# REPORT_EXPIRE_CRON_SCHEDULE=44 4 * * * + +# ----------------------------------------------------------------------------- +# Base path (optional, for subpath deployment) +# ----------------------------------------------------------------------------- +# API_BASE_PATH=/reports-hub +# ASSETS_BASE_PATH=/reports-hub + +# ----------------------------------------------------------------------------- +# Development +# ----------------------------------------------------------------------------- +# Set to true to disable Vite HMR (e.g. in some hosted editors) +# DISABLE_HMR=false diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index af6ab76f..00000000 --- a/.eslintignore +++ /dev/null @@ -1,20 +0,0 @@ -.now/* -*.css -.changeset -dist -esm/* -public/* -tests/* -scripts/* -*.config.js -.DS_Store -node_modules -coverage -.next -build -!.commitlintrc.cjs -!.lintstagedrc.cjs -!jest.config.js -!plopfile.js -!react-shim.js -!tsup.config.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d2fbabe5..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc.json", - "env": { - "browser": false, - "es2021": true, - "node": true - }, - "extends": [ - "plugin:react/recommended", - "plugin:prettier/recommended", - "plugin:react-hooks/recommended", - "plugin:jsx-a11y/recommended" - ], - "plugins": ["react", "unused-imports", "import", "@typescript-eslint", "jsx-a11y", "prettier"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 12, - "sourceType": "module" - }, - "settings": { - "react": { - "version": "detect" - } - }, - "rules": { - "no-console": "off", - "react/prop-types": "off", - "react/jsx-uses-react": "off", - "react/react-in-jsx-scope": "off", - "react-hooks/exhaustive-deps": "off", - "jsx-a11y/click-events-have-key-events": "warn", - "jsx-a11y/interactive-supports-focus": "warn", - "prettier/prettier": "warn", - "no-unused-vars": "off", - "unused-imports/no-unused-vars": "off", - "unused-imports/no-unused-imports": "warn", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "args": "after-used", - "ignoreRestSiblings": false, - "argsIgnorePattern": "^_.*?$" - } - ], - "import/order": [ - "warn", - { - "groups": ["type", "builtin", "object", "external", "internal", "parent", "sibling", "index"], - "pathGroups": [ - { - "pattern": "~/**", - "group": "external", - "position": "after" - } - ], - "newlines-between": "always" - } - ], - "react/self-closing-comp": "warn", - "react/jsx-sort-props": [ - "warn", - { - "callbacksLast": true, - "shorthandFirst": true, - "noSortAlphabetically": false, - "reservedFirst": true - } - ], - "padding-line-between-statements": [ - "warn", - { "blankLine": "always", "prev": "*", "next": "return" }, - { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, - { - "blankLine": "any", - "prev": ["const", "let", "var"], - "next": ["const", "let", "var"] - } - ] - } -} diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index dfe07704..00000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Auto detect text files and perform LF normalization -* text=auto diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index b701ccd2..aef41511 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -3,7 +3,7 @@ name: Pull Request CI on: pull_request jobs: - prettier: + eslint: runs-on: ubuntu-latest steps: - name: Checkout repository @@ -14,10 +14,11 @@ jobs: node-version: '20' - name: Install dependencies run: npm ci - - name: Run prettier - run: npm run format:check + - name: Run eslint + run: npm run lint - eslint: + unit-tests: + needs: [eslint] runs-on: ubuntu-latest steps: - name: Checkout repository @@ -28,31 +29,7 @@ jobs: node-version: '20' - name: Install dependencies run: npm ci - - name: Run eslint - run: npm run lint - - # TODO: Add unit tests - # unit-tests: - # runs-on: ubuntu-latest - # steps: - # - name: Checkout repository - # uses: actions/checkout@v6 - # - name: Set up Node.js - # uses: actions/setup-node@v6 - # with: - # node-version: '20' - # - name: Install dependencies - # run: npm ci - # - name: Run unit tests - # run: npm run test:unit + - name: Run unit tests + run: npm run test:unit - api-tests: - needs: [prettier, eslint] - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - name: Install dependencies - run: npm ci - - name: Execute API tests - run: npm run test:api + # API/integration tests: add npm script test:api and re-enable when ready. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bb11edf..24b07ade 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,48 +1,60 @@ -# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages -name: Create and publish a Docker image +# Publishes the same image digest to GHCR and Docker Hub on GitHub Release. +# Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN +# Stable releases (prerelease unchecked): semver tags + latest. +# Prereleases: semver tags + beta (no latest). +name: Create and publish Docker image on: release: types: [published] -# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. jobs: build-and-push-image: runs-on: ubuntu-latest - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + env: + IMAGE_GHCR: ghcr.io/${{ github.repository }} + IMAGE_DOCKERHUB: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playwright-reports-server permissions: contents: read packages: write attestations: write id-token: write - # steps: - name: Checkout repository uses: actions/checkout@v6 - # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - - name: Log in to the Container registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 with: - registry: ${{ env.REGISTRY }} + registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. - # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. - # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + images: | + ${{ env.IMAGE_GHCR }} + ${{ env.IMAGE_DOCKERHUB }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !github.event.release.prerelease }} + type=raw,value=beta,enable=${{ github.event.release.prerelease }} + - name: Build and push Docker image id: push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: context: . push: true @@ -50,10 +62,9 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." - - name: Generate artifact attestation + - name: Generate artifact attestation (GHCR) uses: actions/attest-build-provenance@v4 with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-name: ${{ env.IMAGE_GHCR }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true diff --git a/.gitignore b/.gitignore index 93df80f9..6e82310d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,18 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# data -/data -.tmp - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -.next/ -/out/ - -# production -/build - -# misc +node_modules/ +build/ +dist/ +coverage/ .DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local -.env +*.log +.env* +!.env.example -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts -data/reports -data/results +# data +data/ +testdata/ -# vscode -.vscode +# generated Playwright blob (see npm run generate:blob-sample) +scripts/blob-sample/out/ -# Playwright -node_modules/ -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ +# Playwright local run metadata +test-results/ \ No newline at end of file diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2bd5a0a9..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 746669b4..00000000 --- a/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.npm/ -node_modules/ -.eslintignore -.prettierignore -package.json -package-lock.json diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 957a15d6..00000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "semi": true, - "trailingComma": "all", - "singleQuote": true, - "printWidth": 120, - "tabWidth": 2, - "arrowParens": "always", - "endOfLine": "lf" -} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..71ac9002 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# Agent guide — Playwright Reports Server + +Use this file as the primary map of the repository before changing API, storage, or UI. + +## Cursor: custom subagents + +| Subagent | File | Use | +|----------|------|-----| +| **docs-maintainer** | [`.cursor/agents/docs-maintainer.md`](.cursor/agents/docs-maintainer.md) | After code changes: sync **AGENTS.md**, **README**, **.env.example**, OpenAPI copy. `/docs-maintainer` | +| **unit-test-writer** | [`.cursor/agents/unit-test-writer.md`](.cursor/agents/unit-test-writer.md) | After new logic: add **Vitest** unit tests (`src/**/*.test.ts`). `/unit-test-writer` | +| **devops** | [`.cursor/agents/devops.md`](.cursor/agents/devops.md) | Docker, GitHub Actions, GHCR + Docker Hub, `latest`/prerelease tags, **CHANGELOG**, production runbook. `/devops` | + +Delegate with a short summary of edits and file paths; run in parallel with the main task when useful. + +## Unit tests (Vitest) + +- **Run**: `npm run test:unit` +- **Config**: [`vitest.config.ts`](vitest.config.ts) — `include`: `src/**/*.test.ts`, environment `node` +- **Integration / HTTP**: keep in separate suites (e.g. Playwright API tests); unit tests stay offline and mock I/O + +## Stack + +- **Runtime**: Node.js (ES modules) +- **Server**: Express (`server.ts`) — JSON API, static report serving, optional Vite dev middleware +- **Frontend**: Vite 6, React 19, React Router, Tailwind 4 (`src/`) +- **Database**: SQLite via `better-sqlite3` (`src/db.ts`, file `data/database.sqlite`) + +**Dev entrypoint**: `npm run dev` → `tsx server.ts` (default port **3000**). + +**Production / Docker**: `npm run start` or container `CMD` from [`Dockerfile`](Dockerfile). Listen port: **`PORT`** env (default 3000). Persist **`/app/data`** (or `data/` locally). Container image publish: see [CHANGELOG.md](CHANGELOG.md) and [README.md](README.md) (GHCR + Docker Hub, `latest` vs prerelease). + +## Where things live + +| Area | Location | +|------|----------| +| HTTP handlers, report paths, Vite in dev | `server.ts` | +| Route list + OpenAPI (`ROUTE_SPECS`, `getOpenApiSpec`) | `src/openapi.ts` | +| SQLite schema and lightweight migrations | `src/db.ts` | +| Shared TS types | `src/types.ts` | +| SPA routes and pages | `src/App.tsx`, `src/pages/` | +| Reusable UI | `src/components/` | +| Client auth (token in `localStorage`) | `src/context/AuthContext.tsx` | +| Vite config (e.g. `VITE_REQUIRE_AUTH`) | `vite.config.ts` | + +## API documentation (machine-readable) + +- `GET /api/openapi.json` — OpenAPI 3.1 spec +- `GET /api/docs` — Swagger UI + +## Adding or changing an HTTP endpoint + +1. Add or edit a route in `ROUTE_SPECS` inside `src/openapi.ts` (method, path, `operationId`, OpenAPI metadata). +2. Implement the handler in the `handlers` object in `server.ts`, keyed by the same `operationId`. +3. If the route needs auth, uploads, or extra middleware, update `middlewareByOperationId` in `server.ts`. +4. Keep the OpenAPI `responses` / `requestBody` in sync with what the handler returns. + +Routes under `/api/serve/...` are registered separately (Express static) and are **not** driven by `ROUTE_SPECS`. + +## Authentication + +- If `API_TOKEN` is **unset**, protected routes do not require a token. +- If `API_TOKEN` is **set**, the server expects the **`Authorization` header value to equal the raw token** (same as the SPA: no `Bearer ` prefix unless your token string itself includes it). The same token may be sent in an HttpOnly cookie (`prs_api_token`) after `POST /api/session`, so `/api/serve/...` subresources (screenshots, traces) load in the browser without the `Authorization` header on every request. +- `GET /api/config` is intentionally reachable without auth (so the UI can read `authRequired`). + +## Data directories (filesystem) + +All under `data/` (created at runtime): + +- `data/results/` — uploaded result zips (legacy flows) +- `data/reports///` — generated HTML reports (`playwright merge-reports`) +- `data/public/` — uploaded logo/favicon from settings +- `data/temp/` — uploads and merge scratch (`temp/upload` for multer, `temp/merge-blobs//` as staging for `merge-reports` input — kept outside `data/reports/.../output` so attachment paths stay relative) +- `data/database.sqlite` — metadata + +## Implemented vs configuration-only + +**Implemented in this codebase** + +- Local filesystem storage under `data/` +- SQLite metadata +- Merging blob/sharded reports via `npx playwright merge-reports` (see `server.ts`) +- Optional API token gate +- Config and cron-related fields persisted and exposed for the UI + +**Not implemented (do not assume code exists)** + +- **S3 / `DATA_STORAGE=s3`**: env and config may mention S3; the server does not upload or read reports from S3 in the current implementation. +- **Background expiration jobs**: `RESULT_EXPIRE_*`, `REPORT_EXPIRE_*`, and cron schedule strings may appear in config/UI; there is **no** scheduled worker (e.g. node-cron) deleting old results/reports in this repo. + +When editing `.env.example` or docs, keep the above distinction clear. + +## Subagent / task split (suggested) + +- **API + OpenAPI**: `src/openapi.ts` + `server.ts` (`handlers`, `middlewareByOperationId`) +- **Schema / SQL**: `src/db.ts` + any new queries in `server.ts` +- **UI**: `src/pages/`, `src/components/`, `src/context/` + +## Commits + +Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..dbcb110d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Release tagging (Docker) + +- **Stable GitHub Release** (prerelease unchecked): images are tagged with semver (e.g. `1.2.3`, `1.2`) and **`latest`** on both GHCR and Docker Hub. +- **Prerelease** (e.g. beta / RC): images get semver prerelease tags and a floating **`beta`** tag; **`latest` is not updated** so `docker pull …:latest` stays on the last stable build. + +## [Unreleased] + +### Added + +- Multi-stage `Dockerfile`, `.dockerignore`, and release workflow publishing to **GHCR** and **Docker Hub**. +- `PORT` from environment for container deployments. +- Cursor **devops** subagent (`.cursor/agents/devops.md`) for CI/CD and release hygiene. diff --git a/Dockerfile b/Dockerfile index 009363db..1665c88f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,75 +1,40 @@ -FROM node:22-alpine AS base - -# Install dependencies only when needed -FROM base AS deps -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +# syntax=docker/dockerfile:1 +FROM node:22-bookworm-slim AS deps WORKDIR /app - -# Install dependencies based on the preferred package manager -COPY package.json package-lock.json* ./ +COPY package.json package-lock.json ./ RUN npm ci -# Rebuild the source code only when needed -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules +FROM deps AS builder COPY . . - -ARG API_BASE_PATH="" -ENV API_BASE_PATH=$API_BASE_PATH - -ARG ASSETS_BASE_PATH="" -ENV ASSETS_BASE_PATH=$ASSETS_BASE_PATH - -# Next.js collects completely anonymous telemetry data about general usage. -# Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry during the build. -ENV NEXT_TELEMETRY_DISABLED=1 - RUN npm run build -# Production image, copy all the files and run next -FROM base AS runner +FROM node:22-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production +ENV PORT=3000 -RUN apk add --no-cache curl - -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 --ingroup nodejs nextjs - -COPY --from=builder --chown=nextjs:nodejs /app/public ./public +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nodejs -# Set the correct permission for prerender cache -RUN mkdir .next && \ - chown nextjs:nodejs .next +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/server.ts ./ +COPY --from=builder /app/src ./src -# Create folders required for storing results and reports -ARG DATA_DIR=/app/data -ARG RESULTS_DIR=${DATA_DIR}/results -ARG REPORTS_DIR=${DATA_DIR}/reports -ARG TEMP_DIR=/app/.tmp -RUN mkdir -p ${DATA_DIR} ${RESULTS_DIR} ${REPORTS_DIR} ${TEMP_DIR} && \ - chown -R nextjs:nodejs ${DATA_DIR} ${TEMP_DIR} +RUN mkdir -p data/results data/reports data/public data/temp/upload \ + && chown -R nodejs:nodejs /app -USER nextjs +USER nodejs EXPOSE 3000 -ENV PORT=3000 - -# server.js is created by next build from the standalone output -# https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD ["sh", "-c", "HOSTNAME=0.0.0.0 node server.js"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -sf "http://127.0.0.1:${PORT}/api/ping" || exit 1 -HEALTHCHECK --interval=3m --timeout=3s CMD curl -f http://localhost:$PORT/api/ping || exit 1 +CMD ["npm", "run", "start"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 93ac33f9..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Oleksandr Khotemskyi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/app/api/[...nextauth]/route.ts b/app/api/[...nextauth]/route.ts deleted file mode 100644 index bfc6e673..00000000 --- a/app/api/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from '@/app/auth'; - -export const { GET, POST } = handlers; diff --git a/app/api/config/route.ts b/app/api/config/route.ts deleted file mode 100644 index 48125044..00000000 --- a/app/api/config/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { revalidatePath } from 'next/cache'; - -import { withError } from '@/app/lib/withError'; -import { DATA_FOLDER } from '@/app/lib/storage/constants'; -import { service } from '@/app/lib/service'; -import { env } from '@/app/config/env'; -import { cronService } from '@/app/lib/service/cron'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -const saveFile = async (file: File) => { - const arrayBuffer = await file.arrayBuffer(); - - const buffer = Buffer.from(arrayBuffer); - - await fs.writeFile(path.join(DATA_FOLDER, file.name), buffer, { encoding: 'binary' }); -}; - -const parseHeaderLinks = async (headerLinks: string): Promise> => { - return JSON.parse(headerLinks); -}; - -export async function PATCH(request: Request) { - const { result: formData, error: formParseError } = await withError(request.formData()); - - if (formParseError) { - return Response.json({ error: formParseError.message }, { status: 400 }); - } - - if (!formData) { - return Response.json({ error: 'Form data is missing' }, { status: 400 }); - } - - const logo = formData.get('logo') as File; - - if (logo) { - const { error: logoError } = await withError(saveFile(logo)); - - if (logoError) { - return Response.json({ error: `failed to save logo: ${logoError?.message}` }, { status: 500 }); - } - } - - const favicon = formData.get('favicon') as File; - - if (favicon) { - const { error: faviconError } = await withError(saveFile(favicon)); - - if (faviconError) { - return Response.json({ error: `failed to save favicon: ${faviconError?.message}` }, { status: 500 }); - } - } - - const title = formData.get('title'); - const logoPath = formData.get('logoPath'); - const faviconPath = formData.get('faviconPath'); - const reporterPaths = formData.get('reporterPaths'); - const headerLinks = formData.get('headerLinks'); - const resultExpireDays = formData.get('resultExpireDays'); - const resultExpireCronSchedule = formData.get('resultExpireCronSchedule'); - const reportExpireDays = formData.get('reportExpireDays'); - const reportExpireCronSchedule = formData.get('reportExpireCronSchedule'); - - const config = await service.getConfig(); - - if (!config) { - return Response.json({ error: `failed to get config` }, { status: 500 }); - } - - if (title !== null) { - config.title = title.toString(); - } - - if (logo) { - config.logoPath = `/${logo.name}`; - } else if (logoPath !== null) { - config.logoPath = logoPath.toString(); - } - - if (favicon) { - config.faviconPath = `/${favicon.name}`; - } else if (faviconPath !== null) { - config.faviconPath = faviconPath.toString(); - } - - if (reporterPaths !== null) { - try { - config.reporterPaths = JSON.parse(reporterPaths.toString()); - } catch { - config.reporterPaths = [reporterPaths.toString()]; - } - } - - if (headerLinks) { - const { result: parsedHeaderLinks, error: parseHeaderLinksError } = await withError( - parseHeaderLinks(headerLinks.toString()), - ); - - if (parseHeaderLinksError) { - return Response.json( - { error: `failed to parse header links: ${parseHeaderLinksError.message}` }, - { status: 400 }, - ); - } - - if (parsedHeaderLinks) config.headerLinks = parsedHeaderLinks; - } - - if (!config.cron) { - config.cron = {}; - } - - if (resultExpireDays || resultExpireCronSchedule || reportExpireDays || reportExpireCronSchedule) { - if (resultExpireDays !== null) { - config.cron.resultExpireDays = parseInt(resultExpireDays.toString()); - } - if (resultExpireCronSchedule !== null) { - config.cron.resultExpireCronSchedule = resultExpireCronSchedule.toString(); - } - if (reportExpireDays !== null) { - config.cron.reportExpireDays = parseInt(reportExpireDays.toString()); - } - if (reportExpireCronSchedule !== null) { - config.cron.reportExpireCronSchedule = reportExpireCronSchedule.toString(); - } - } - - const { error: saveConfigError } = await withError(service.updateConfig(config)); - - if (saveConfigError) { - return Response.json({ error: `failed to save config: ${saveConfigError.message}` }, { status: 500 }); - } - - if ( - config.cron?.resultExpireDays || - config.cron?.resultExpireCronSchedule || - config.cron?.reportExpireDays || - config.cron?.reportExpireCronSchedule - ) { - await cronService.restart(); - } - - revalidatePath('/', 'layout'); - revalidatePath('/login', 'layout'); - - return Response.json({ message: 'config saved' }); -} - -export async function GET() { - const config = await service.getConfig(); - - if (!config) { - return Response.json({ error: 'Config not found' }, { status: 404 }); - } - - // Add environment info to config response - const envInfo = { - authRequired: !!env.API_TOKEN, - serverCache: env.USE_SERVER_CACHE, - dataStorage: env.DATA_STORAGE, - s3Endpoint: env.S3_ENDPOINT, - s3Bucket: env.S3_BUCKET, - }; - - return Response.json({ ...config, ...envInfo }, { status: 200 }); -} diff --git a/app/api/info/route.ts b/app/api/info/route.ts deleted file mode 100644 index c1f42c77..00000000 --- a/app/api/info/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - const { result, error } = await withError(service.getServerInfo()); - - if (error) { - return Response.json({ error: error.message }, { status: 500 }); - } - - return Response.json(result); -} diff --git a/app/api/ping/route.ts b/app/api/ping/route.ts deleted file mode 100644 index 24d07811..00000000 --- a/app/api/ping/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - return new Response('pong'); -} diff --git a/app/api/report/[id]/route.ts b/app/api/report/[id]/route.ts deleted file mode 100644 index 427d9296..00000000 --- a/app/api/report/[id]/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { withError } from '@/app/lib/withError'; -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET( - req: NextRequest, - { - params, - }: { - params: { - id: string; - }; - }, -) { - const { id } = params; - - if (!id) { - return new Response('report ID is required', { status: 400 }); - } - - const { result: report, error } = await withError(service.getReport(id)); - - if (error) { - return new Response(`failed to get report: ${error?.message ?? 'unknown error'}`, { status: 400 }); - } - - return Response.json(report); -} diff --git a/app/api/report/delete/route.ts b/app/api/report/delete/route.ts deleted file mode 100644 index e9656991..00000000 --- a/app/api/report/delete/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto -export async function DELETE(request: Request) { - const { result: reqData, error: reqError } = await withError(request.json()); - - if (reqError) { - return new Response(reqError.message, { status: 400 }); - } - - const { error } = await withError(service.deleteReports(reqData.reportsIds)); - - if (error) { - return new Response(error.message, { status: 404 }); - } - - return Response.json({ - message: `Reports deleted successfully`, - reportsIds: reqData.reportsIds, - }); -} diff --git a/app/api/report/generate/route.ts b/app/api/report/generate/route.ts deleted file mode 100644 index afb8c379..00000000 --- a/app/api/report/generate/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function POST(request: Request) { - const { result: reqBody, error: reqError } = await withError(request.json()); - - if (reqError) { - return new Response(reqError.message, { status: 400 }); - } - const { resultsIds, project, playwrightVersion, ...rest } = reqBody; - - try { - const result = await service.generateReport(resultsIds, { project, playwrightVersion, ...rest }); - - if (!result?.reportId) { - return new Response('failed to generate report', { status: 400 }); - } - - return Response.json(result); - } catch (error) { - console.error(`[report/generate] error: ${error}`); - if (error instanceof Error && error.message.includes('ENOENT: no such file or directory')) { - return Response.json({ error: `ResultID with not found: ${error.message}` }, { status: 404 }); - } - - return Response.json({ error: (error as Error).message }, { status: 500 }); - } -} diff --git a/app/api/report/list/route.ts b/app/api/report/list/route.ts deleted file mode 100644 index ae2b1327..00000000 --- a/app/api/report/list/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { withError } from '@/app/lib/withError'; -import { parseFromRequest } from '@/app/lib/storage/pagination'; -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const pagination = parseFromRequest(searchParams); - const project = searchParams.get('project') ?? ''; - const search = searchParams.get('search') ?? ''; - const dateFrom = searchParams.get('dateFrom') ?? undefined; - const dateTo = searchParams.get('dateTo') ?? undefined; - - const { result: reports, error } = await withError( - service.getReports({ pagination, project, search, dateFrom, dateTo }), - ); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(reports!); -} diff --git a/app/api/report/projects/route.ts b/app/api/report/projects/route.ts deleted file mode 100644 index 836cd7a1..00000000 --- a/app/api/report/projects/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - const { result: projects, error } = await withError(service.getReportsProjects()); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(projects); -} diff --git a/app/api/report/trend/route.ts b/app/api/report/trend/route.ts deleted file mode 100644 index ea367aa5..00000000 --- a/app/api/report/trend/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const project = searchParams.get('project') ?? ''; - const { reports: latestReports } = await service.getReports({ project, pagination: { offset: 0, limit: 20 } }); - - return Response.json(latestReports); -} diff --git a/app/api/result/delete/route.ts b/app/api/result/delete/route.ts deleted file mode 100644 index 46eda0d4..00000000 --- a/app/api/result/delete/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function DELETE(request: Request) { - const { result: reqData, error: reqError } = await withError(request.json()); - - if (reqError) { - return new Response(reqError.message, { status: 400 }); - } - - const { error } = await withError(service.deleteResults(reqData.resultsIds)); - - if (error) { - return new Response(error.message, { status: 404 }); - } - - return Response.json({ - message: `Results files deleted successfully`, - resultsIds: reqData.resultsIds, - }); -} diff --git a/app/api/result/list/route.ts b/app/api/result/list/route.ts deleted file mode 100644 index 2af78e0d..00000000 --- a/app/api/result/list/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type NextRequest } from 'next/server'; - -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; -import { parseFromRequest } from '@/app/lib/storage/pagination'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const pagination = parseFromRequest(searchParams); - const project = searchParams.get('project') ?? ''; - const tags = searchParams.get('tags')?.split(',').filter(Boolean) ?? []; - const search = searchParams.get('search') ?? ''; - const dateFrom = searchParams.get('dateFrom') ?? undefined; - const dateTo = searchParams.get('dateTo') ?? undefined; - - const { result, error } = await withError( - service.getResults({ pagination, project, tags, search, dateFrom, dateTo }), - ); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(result); -} diff --git a/app/api/result/projects/route.ts b/app/api/result/projects/route.ts deleted file mode 100644 index 1548a20e..00000000 --- a/app/api/result/projects/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from '@/app/lib/service'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -export async function GET() { - const { result: projects, error } = await withError(service.getResultsProjects()); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(projects); -} diff --git a/app/api/result/tags/route.ts b/app/api/result/tags/route.ts deleted file mode 100644 index 1439896e..00000000 --- a/app/api/result/tags/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest } from 'next/server'; - -import { withError } from '@/app/lib/withError'; -import { service } from '@/app/lib/service'; - -export const dynamic = 'force-dynamic'; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const project = searchParams.get('project') ?? ''; - - const { result: tags, error } = await withError(service.getResultsTags(project)); - - if (error) { - return new Response(error.message, { status: 400 }); - } - - return Response.json(tags); -} diff --git a/app/api/serve/[[...filePath]]/route.ts b/app/api/serve/[[...filePath]]/route.ts deleted file mode 100644 index ad7e4332..00000000 --- a/app/api/serve/[[...filePath]]/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import path from 'path'; - -import mime from 'mime'; -import { type NextRequest, NextResponse } from 'next/server'; -import { redirect } from 'next/navigation'; - -import { withError } from '@/app/lib/withError'; -import { storage } from '@/app/lib/storage'; -import { auth } from '@/app/auth'; -import { env } from '@/app/config/env'; -import { withBase } from '@/app/lib/url'; - -interface ReportParams { - reportId: string; - filePath?: string[]; -} - -export async function GET( - req: NextRequest, - { - params, - }: { - params: ReportParams; - }, -) { - // is not protected by the middleware - // as we want to have callbackUrl in the query - - const authRequired = !!env.API_TOKEN; - const session = await auth(); - - const { filePath } = params; - - const uriPath = Array.isArray(filePath) ? filePath.join('/') : (filePath ?? ''); - - const targetPath = decodeURI(uriPath); - - // Only check for session if auth is required - if (authRequired && !session?.user?.jwtToken) { - redirect(withBase(`/login?callbackUrl=${encodeURI(req.nextUrl.pathname)}`)); - } - - const contentType = mime.getType(path.basename(targetPath)); - - if (!contentType && !path.extname(targetPath)) { - return NextResponse.next(); - } - - const { result: content, error } = await withError(storage.readFile(targetPath, contentType)); - - if (error ?? !content) { - return NextResponse.json({ error: `Could not read file ${error?.message ?? ''}` }, { status: 404 }); - } - - const headers = { - headers: { - 'Content-Type': contentType ?? 'application/octet-stream', - Authorization: `Bearer ${session?.user?.apiToken}`, - }, - }; - - return new Response(content, headers); -} diff --git a/app/api/static/[[...filePath]]/route.ts b/app/api/static/[[...filePath]]/route.ts deleted file mode 100644 index 28ea2306..00000000 --- a/app/api/static/[[...filePath]]/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs/promises'; - -import mime from 'mime'; -import { type NextRequest, NextResponse } from 'next/server'; - -import { DATA_FOLDER } from '@/app/lib/storage/constants'; -import { withError } from '@/app/lib/withError'; - -export const dynamic = 'force-dynamic'; // defaults to auto - -interface ServeParams { - filePath?: string[]; -} - -export async function GET( - _: NextRequest, - { - params, - }: { - params: ServeParams; - }, -) { - const { filePath } = params; - - const uriPath = Array.isArray(filePath) ? filePath.join('/') : (filePath ?? ''); - - const targetPath = decodeURI(uriPath); - - const contentType = mime.getType(path.basename(targetPath)); - - if (!contentType && !path.extname(targetPath)) { - return NextResponse.next(); - } - - const imageDataPath = path.join(DATA_FOLDER, targetPath); - const imagePublicPath = path.join('public', targetPath); - - const { error: dataAccessError } = await withError(fs.access(imageDataPath)); - - const imagePath = dataAccessError ? imagePublicPath : imageDataPath; - - const imageBuffer = await fs.readFile(imagePath); - - const headers = { - headers: { - 'Content-Type': contentType ?? 'image/*', - }, - }; - - return new Response(imageBuffer, headers); -} diff --git a/app/auth.ts b/app/auth.ts deleted file mode 100644 index a4427e6d..00000000 --- a/app/auth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import NextAuth from 'next-auth'; -import { NextAuthConfig } from 'next-auth'; -import { type User } from 'next-auth'; -import CredentialsProvider from 'next-auth/providers/credentials'; -import jwt from 'jsonwebtoken'; - -import { env } from './config/env'; - -const useAuth = !!env.API_TOKEN; - -// strictly recommended to specify via env var -// Use a stable default secret when AUTH_SECRET is not set to avoid JWT decryption errors -// This is only acceptable when auth is disabled (no API_TOKEN) -const secret = env.AUTH_SECRET ?? 'default-secret-for-non-auth-mode'; - -// session expiration for api token auth -const expirationHours = env.UI_AUTH_EXPIRE_HOURS ? parseInt(env.UI_AUTH_EXPIRE_HOURS) : 2; -const expirationSeconds = expirationHours * 60 * 60; - -export const authConfig: NextAuthConfig = { - secret, - providers: [ - CredentialsProvider({ - name: 'API Token', - credentials: { - apiToken: { label: 'API Token', type: 'password' }, - }, - async authorize(credentials): Promise { - if (credentials?.apiToken === env.API_TOKEN) { - const token = jwt.sign({ authorized: true }, secret); - - return { - apiToken: credentials.apiToken as string, - jwtToken: token, - }; - } - - return null; - }, - }), - ], - callbacks: { - async jwt({ token, user }) { - if (user) { - token.apiToken = user.apiToken; - token.jwtToken = user.jwtToken; - } - - return token; - }, - async session({ session, token }) { - session.user.apiToken = token.apiToken as string; - session.user.jwtToken = token.jwtToken as string; - - return session; - }, - }, - session: { - strategy: 'jwt', - maxAge: expirationSeconds, - }, - trustHost: true, - pages: { - signIn: '/login', - }, -}; - -const getJwtStubToken = () => { - return jwt.sign({ authorized: true }, secret); -}; - -const noAuth = { - providers: [ - CredentialsProvider({ - name: 'No Auth', - credentials: {}, - async authorize() { - const token = getJwtStubToken(); - - return { apiToken: token, jwtToken: token }; - }, - }), - ], - callbacks: { - authorized: async () => { - return true; - }, - async jwt({ token, user }) { - if (user) { - token.apiToken = user.apiToken; - token.jwtToken = user.jwtToken; - } - - return token; - }, - async session({ session, token }) { - session.sessionToken = getJwtStubToken(); - session.user.jwtToken = session.sessionToken; - session.user.apiToken = token.apiToken as string; - - return session; - }, - }, - trustHost: true, - session: { - strategy: 'jwt', - maxAge: expirationSeconds, - }, - secret, -} satisfies NextAuthConfig; - -export const { handlers, auth, signIn, signOut } = NextAuth(useAuth ? authConfig : noAuth); diff --git a/app/components/aside.tsx b/app/components/aside.tsx deleted file mode 100644 index 0ca2d14f..00000000 --- a/app/components/aside.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { Card, CardBody, Link, Badge } from '@heroui/react'; -import NextLink from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useSession } from 'next-auth/react'; - -import { useAuthConfig } from '../hooks/useAuthConfig'; - -import { ReportIcon, ResultIcon, SettingsIcon, TrendIcon } from '@/app/components/icons'; -import { siteConfig } from '@/app/config/site'; -import useQuery from '@/app/hooks/useQuery'; - -interface ServerInfo { - numOfReports: number; - numOfResults: number; -} - -const iconst = [ - { href: '/reports', icon: ReportIcon }, - { href: '/results', icon: ResultIcon }, - { href: '/trends', icon: TrendIcon }, - { href: '/settings', icon: SettingsIcon }, -]; - -export const Aside: React.FC = () => { - const pathname = usePathname(); - const session = useSession(); - const { authRequired } = useAuthConfig(); - const isAuthenticated = authRequired === false || session.status === 'authenticated'; - - const { data: serverInfo } = useQuery('/api/info', { - enabled: isAuthenticated, - }); - - return ( - - -
- {siteConfig.navItems.map((item) => { - const isActive = pathname === item.href; - const Icon = iconst.find((icon) => icon.href === item.href)?.icon; - const count = - item.href === '/reports' - ? serverInfo?.numOfReports - : item.href === '/results' - ? serverInfo?.numOfResults - : 0; - - return ( - - {count !== undefined && count > 0 ? ( - - {Icon && } - - ) : ( - Icon && - )} - - ); - })} -
-
-
- ); -}; diff --git a/app/components/date-format.tsx b/app/components/date-format.tsx deleted file mode 100644 index c6b54a8b..00000000 --- a/app/components/date-format.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; -import { useState, useEffect } from 'react'; - -/** - * Specific method for date formatting on the client - * as server locale and client locale may not match - */ -export default function FormattedDate({ date }: { date: Date }) { - const [formattedDate, setFormattedDate] = useState(''); - - useEffect(() => { - setFormattedDate(new Date(date).toLocaleString()); - }, [date]); - - return {formattedDate}; -} diff --git a/app/components/date-range-picker.tsx b/app/components/date-range-picker.tsx deleted file mode 100644 index f2be3a49..00000000 --- a/app/components/date-range-picker.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import { DateRangePicker as HeroUIDateRangePicker } from '@heroui/react'; -import { useCallback, useMemo } from 'react'; -import { CalendarDateTime } from '@internationalized/date'; -import { I18nProvider } from '@react-aria/i18n'; - -interface DateRangePickerProps { - dateFrom?: string; - dateTo?: string; - label?: string; - onDateFromChange?: (date: string) => void; - onDateToChange?: (date: string) => void; -} - -export default function DateRangePicker({ - dateFrom, - dateTo, - label = 'Date Range', - onDateFromChange, - onDateToChange, -}: Readonly) { - // Convert ISO strings to CalendarDateTime for HeroUI DateRangePicker (includes time fields) - const defaultValue = useMemo(() => { - if (!dateFrom || !dateTo) return undefined; - - try { - // Parse ISO strings and convert to CalendarDateTime (includes time) - const startDate = new Date(dateFrom); - const endDate = new Date(dateTo); - - // Create CalendarDateTime objects with time - const start = new CalendarDateTime( - startDate.getFullYear(), - startDate.getMonth() + 1, - startDate.getDate(), - startDate.getHours(), - startDate.getMinutes(), - ); - const end = new CalendarDateTime( - endDate.getFullYear(), - endDate.getMonth() + 1, - endDate.getDate(), - endDate.getHours(), - endDate.getMinutes(), - ); - - return { start, end }; - } catch { - return undefined; - } - }, [dateFrom, dateTo]); - - const handleChange = useCallback( - (range: { start: any; end: any } | null) => { - if (!range) { - onDateFromChange?.(''); - onDateToChange?.(''); - - return; - } - - if (range.start && onDateFromChange) { - // Convert CalendarDateTime to ISO string - const year = range.start.year; - const month = String(range.start.month).padStart(2, '0'); - const day = String(range.start.day).padStart(2, '0'); - const hour = String(range.start.hour).padStart(2, '0'); - const minute = String(range.start.minute).padStart(2, '0'); - const isoString = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; - - onDateFromChange(isoString); - } else if (!range.start && onDateFromChange) { - onDateFromChange(''); - } - - if (range.end && onDateToChange) { - // Convert CalendarDateTime to ISO string - const year = range.end.year; - const month = String(range.end.month).padStart(2, '0'); - const day = String(range.end.day).padStart(2, '0'); - const hour = String(range.end.hour).padStart(2, '0'); - const minute = String(range.end.minute).padStart(2, '0'); - const isoString = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; - - onDateToChange(isoString); - } else if (!range.end && onDateToChange) { - onDateToChange(''); - } - }, - [onDateFromChange, onDateToChange], - ); - - return ( - - - - ); -} diff --git a/app/components/delete-report-button.tsx b/app/components/delete-report-button.tsx deleted file mode 100644 index 624596c5..00000000 --- a/app/components/delete-report-button.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, useDisclosure, Button } from '@heroui/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import useMutation from '@/app/hooks/useMutation'; -import { DeleteIcon } from '@/app/components/icons'; -import { invalidateCache } from '@/app/lib/query-cache'; - -interface DeleteProjectButtonProps { - reportId?: string; - reportIds?: string[]; - onDeleted: () => void; - label?: string; -} - -export default function DeleteReportButton({ reportId, reportIds, onDeleted, label }: DeleteProjectButtonProps) { - const queryClient = useQueryClient(); - const ids = reportIds ?? (reportId ? [reportId] : []); - - const { - mutate: deleteReport, - isPending, - error, - } = useMutation('/api/report/delete', { - method: 'DELETE', - onSuccess: () => { - invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/report' }); - toast.success(`report${ids.length > 1 ? 's' : ''} deleted`); - }, - }); - - const { isOpen, onOpen, onOpenChange } = useDisclosure(); - - const DeleteReport = async () => { - if (!ids.length) { - return; - } - - deleteReport({ body: { reportsIds: ids } }); - - onDeleted?.(); - }; - - error && toast.error(error.message); - - return ( - <> - - - - {(onClose) => ( - <> - Are you sure? - -

This will permanently delete your report{ids.length > 1 ? 's' : ''}.

-
- - - - - - )} -
-
- - ); -} diff --git a/app/components/delete-results-button.tsx b/app/components/delete-results-button.tsx deleted file mode 100644 index 3ed0ce85..00000000 --- a/app/components/delete-results-button.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; - -import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, useDisclosure, Button } from '@heroui/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import useMutation from '@/app/hooks/useMutation'; -import { invalidateCache } from '@/app/lib/query-cache'; -import { DeleteIcon } from '@/app/components/icons'; - -interface DeleteProjectButtonProps { - resultIds: string[]; - onDeletedResult?: () => void; - label?: string; -} - -export default function DeleteResultsButton({ resultIds, onDeletedResult, label }: Readonly) { - const queryClient = useQueryClient(); - const { - mutate: deleteResult, - isPending, - error, - } = useMutation('/api/result/delete', { - method: 'DELETE', - onSuccess: () => { - invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/result' }); - toast.success(`result${resultIds.length ? '' : 's'} ${resultIds ?? 'are'} deleted`); - }, - }); - - const { isOpen, onOpen, onOpenChange } = useDisclosure(); - - const DeleteResult = async () => { - if (!resultIds?.length) { - return; - } - - deleteResult({ body: { resultsIds: resultIds } }); - - onDeletedResult?.(); - }; - - error && toast.error(error.message); - - return ( - <> - - - - {(onClose) => ( - <> - Are you sure? - -

This will permanently delete your results files.

-
- - - - - - )} -
-
- - ); -} diff --git a/app/components/generate-report-button.tsx b/app/components/generate-report-button.tsx deleted file mode 100644 index f93e4219..00000000 --- a/app/components/generate-report-button.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client'; - -import { - Button, - Modal, - ModalContent, - ModalHeader, - ModalBody, - useDisclosure, - ModalFooter, - Autocomplete, - AutocompleteItem, - Input, -} from '@heroui/react'; -import { useEffect, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import { type Result } from '../lib/storage'; - -import useQuery from '@/app/hooks/useQuery'; -import useMutation from '@/app/hooks/useMutation'; -import { invalidateCache } from '@/app/lib/query-cache'; - -interface DeleteProjectButtonProps { - results: Result[]; - projects: string[]; - onGeneratedReport?: () => void; -} - -export default function GenerateReportButton({ - results, - projects, - onGeneratedReport, -}: Readonly) { - const queryClient = useQueryClient(); - const [generationError, setGenerationError] = useState(null); - - const { mutate: generateReport, isPending } = useMutation('/api/report/generate', { - method: 'POST', - onSuccess: (data: { reportId: string }) => { - invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/report' }); - toast.success(`report ${data?.reportId} is generated`); - setProjectName(''); - setCustomName(''); - setGenerationError(null); - onClose(); - onGeneratedReport?.(); - }, - onError: (err: Error) => { - setGenerationError(err.message); - }, - }); - - const { - data: resultProjects, - error: resultProjectsError, - isLoading: isResultProjectsLoading, - } = useQuery(`/api/result/projects`); - - const [projectName, setProjectName] = useState(''); - const [customName, setCustomName] = useState(''); - - useEffect(() => { - !projectName && setProjectName(projects?.at(0) ?? ''); - }, [projects]); - - const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure(); - - const handleModalOpen = () => { - setGenerationError(null); - onOpen(); - }; - - const GenerateReport = async () => { - if (!results?.length) { - return; - } - - setGenerationError(null); - generateReport({ body: { resultsIds: results.map((r) => r.resultID), project: projectName, title: customName } }); - }; - - return ( - <> - - - - {(onClose) => ( - <> - Generate report - - {generationError ? ( -
-

Report generation failed:

-
-                      {generationError}
-                    
-
- ) : ( - <> - ({ - label: project, - value: project, - }))} - label="Project name" - labelPlacement="outside" - placeholder="leave empty if not required" - variant="bordered" - onInputChange={(value) => setProjectName(value)} - onSelectionChange={(value) => value && setProjectName(value?.toString() ?? '')} - > - {(item) => {item.label}} - - setCustomName(e.target.value ?? '')} - onClear={() => setCustomName('')} - /> - - )} -
- - - {!generationError && ( - - )} - - - )} -
-
- - ); -} diff --git a/app/components/header-links.tsx b/app/components/header-links.tsx deleted file mode 100644 index 3386e636..00000000 --- a/app/components/header-links.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Link } from '@heroui/link'; - -import { - GithubIcon, - DiscordIcon, - TelegramIcon, - LinkIcon, - BitbucketIcon, - CyborgTestIcon, - SlackIcon, -} from '@/app/components/icons'; -import { SiteWhiteLabelConfig } from '@/app/types'; - -interface HeaderLinksProps { - config: SiteWhiteLabelConfig; - withTitle?: boolean; -} - -export const HeaderLinks: React.FC = ({ config, withTitle = false }) => { - const links = config?.headerLinks; - - const availableSocialLinkIcons = [ - { name: 'telegram', Icon: TelegramIcon, title: 'Telegram' }, - { name: 'discord', Icon: DiscordIcon, title: 'Discord' }, - { name: 'github', Icon: GithubIcon, title: 'GitHub' }, - { name: 'cyborgTest', Icon: CyborgTestIcon, title: 'Cyborg Test' }, - { name: 'bitbucket', Icon: BitbucketIcon, title: 'Bitbucket' }, - { name: 'slack', Icon: SlackIcon, title: 'Slack' }, - ]; - - const socialLinks = Object.entries(links).map(([name, href]) => { - const availableLink = availableSocialLinkIcons.find((available) => available.name === name); - - const Icon = availableLink?.Icon ?? LinkIcon; - const title = availableLink?.title ?? name; - - return href ? ( - - - {withTitle &&

{title}

} - - ) : null; - }); - - return socialLinks; -}; diff --git a/app/components/icons.tsx b/app/components/icons.tsx deleted file mode 100644 index 6ecfa719..00000000 --- a/app/components/icons.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { FC } from 'react'; - -import { IconSvgProps } from '@/app/types'; - -export const DiscordIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - ); -}; - -export const GithubIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - ); -}; - -export const BitbucketIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - - - ); -}; - -export const CyborgTestIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const TelegramIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - ); -}; - -export const MoonFilledIcon = ({ size = 40, width, height, ...props }: IconSvgProps) => ( - -); - -export const SunFilledIcon = ({ size = 40, width, height, ...props }: IconSvgProps) => ( - -); - -export const LinkIcon: FC = ({ width, height, ...props }) => { - return ( - - - - - ); -}; - -export const ReportIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const ResultIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const TrendIcon: FC = ({ size = 40, width, height, ...props }) => { - return ( - - - - - - ); -}; - -export const DeleteIcon: FC = (props) => ( - -); - -export const EyeIcon: FC = (props) => ( - -); - -export const SearchIcon: FC = (props) => ( - -); - -export const BranchIcon: FC = ({ width, height, ...props }) => { - return ( - - - - ); -}; - -export const FolderIcon: FC = ({ width, height, ...props }) => { - return ( - - - - ); -}; - -export const SettingsIcon: FC = ({ size = 24, width, height, ...props }) => { - return ( - - - - - ); -}; diff --git a/app/components/login-form.tsx b/app/components/login-form.tsx deleted file mode 100644 index 2f97a1ec..00000000 --- a/app/components/login-form.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client'; - -import { type FormEvent, useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Button, Card, CardBody, CardFooter, CardHeader, Input, Spinner } from '@heroui/react'; -import { getProviders, signIn, useSession } from 'next-auth/react'; - -import { title } from '@/app/components/primitives'; - -export default function LoginForm() { - const [input, setInput] = useState(''); - const [error, setError] = useState(''); - const [isAutoSigningIn, setIsAutoSigningIn] = useState(true); - const router = useRouter(); - const session = useSession(); - const searchParams = useSearchParams(); - - const target = searchParams?.get('callbackUrl') ?? '/'; - const callbackUrl = decodeURI(target); - - useEffect(() => { - // redirect if already authenticated - if (session.status === 'authenticated') { - router.replace(callbackUrl); - - return; - } - - // check if we can sign in automatically - getProviders() - .then((providers) => { - // if no api token required we can automatically sign user in - if (providers?.credentials.name === 'No Auth') { - return signIn('credentials', { - redirect: false, - }).then((response) => { - if (!response?.error && response?.ok) { - router.replace(callbackUrl); - } else { - setIsAutoSigningIn(false); - } - }); - } else { - setIsAutoSigningIn(false); - } - }) - .catch(() => { - setIsAutoSigningIn(false); - }); - }, []); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - const result = await signIn('credentials', { - apiToken: input, - redirect: false, - }); - - result?.error ? setError('invalid API key') : router.replace(callbackUrl); - }; - - // Show spinner while session is loading or while auto-signing in - if (session.status === 'loading' || isAutoSigningIn) { - return ; - } - - return ( -
-

Login

- - -

Please provide API key to sign in

-
-
- - { - const newValue = e.target.value; - - if (!newValue && error) { - setError(''); - } - setInput(newValue); - }} - /> - - - - -
-
-
- ); -} diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx deleted file mode 100644 index 344b60ee..00000000 --- a/app/components/navbar.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; -import { - Navbar as NextUINavbar, - NavbarContent, - NavbarMenu, - NavbarMenuToggle, - NavbarBrand, - NavbarItem, -} from '@heroui/navbar'; -import Image from 'next/image'; -import NextLink from 'next/link'; -import { toast } from 'sonner'; -import { Skeleton } from '@heroui/skeleton'; - -import { withBase } from '../lib/url'; - -import { subtitle } from './primitives'; - -import { defaultConfig } from '@/app/lib/config'; -import { HeaderLinks } from '@/app/components/header-links'; -import { ThemeSwitch } from '@/app/components/theme-switch'; -import { SiteWhiteLabelConfig } from '@/app/types'; -import useQuery from '@/app/hooks/useQuery'; - -export const Navbar: React.FC = () => { - const { data: config, error, isLoading } = useQuery('/api/config'); - - const isCustomLogo = config?.logoPath !== defaultConfig.logoPath; - const isCustomTitle = config?.title !== defaultConfig.title; - - if (error) { - toast.error(error.message); - } - - return ( - - - - - - {config && ( - Logo - )} - - - - {isCustomTitle &&

{config?.title}

} -
-
- - - - {config && !isLoading ? ( - - ) : ( - - )} - - - - - {/* mobile view fallback */} - - - {!!config && } - - - -
- {config && !isLoading ? : } -
-
-
- ); -}; diff --git a/app/components/page-layout.tsx b/app/components/page-layout.tsx deleted file mode 100644 index 4978e7db..00000000 --- a/app/components/page-layout.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { useLayoutEffect, useState, useEffect } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { useSession } from 'next-auth/react'; -import { Spinner } from '@heroui/react'; -import { toast } from 'sonner'; - -import useQuery from '@/app/hooks/useQuery'; -import { useAuthConfig } from '@/app/hooks/useAuthConfig'; -import { type ServerDataInfo } from '@/app/lib/storage'; - -interface PageLayoutProps { - render: (props: { info: ServerDataInfo; onUpdate: () => void }) => React.ReactNode; -} - -export default function PageLayout({ render }: Readonly) { - const { data: session, status } = useSession(); - const authIsLoading = status === 'loading'; - const { authRequired } = useAuthConfig(); - const isAuthenticated = authRequired === false || status === 'authenticated'; - - const { - data: info, - error, - refetch, - isLoading: isInfoLoading, - } = useQuery('/api/info', { - enabled: isAuthenticated, - }); - const [refreshId, setRefreshId] = useState(uuidv4()); - - useEffect(() => { - // Only show error if auth is required - if (authRequired === false) { - return; - } - - if (!authIsLoading && !session && authRequired === true) { - toast.error('You are not authenticated'); - } - }, [authIsLoading, session, authRequired]); - - useLayoutEffect(() => { - // skip refetch is not authorized - if (authRequired && (authIsLoading || !session)) { - return; - } - - refetch({ cancelRefetch: false }); - }, [refreshId, session, authRequired]); - - if (authIsLoading || isInfoLoading) { - return ; - } - - const updateRefreshId = () => { - setRefreshId(uuidv4()); - }; - - if (error) { - toast.error(error.message); - - return
Error loading data: {error.message}
; - } - - return ( - <> - {!!info && ( -
-
{render({ info, onUpdate: updateRefreshId })}
-
- )} - - ); -} diff --git a/app/components/primitives.ts b/app/components/primitives.ts deleted file mode 100644 index c1386e4c..00000000 --- a/app/components/primitives.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { tv } from 'tailwind-variants'; - -export const title = tv({ - base: 'tracking-tight inline font-semibold', - variants: { - color: { - violet: 'from-[#FF1CF7] to-[#b249f8]', - yellow: 'from-[#FF705B] to-[#FFB457]', - blue: 'from-[#5EA2EF] to-[#0072F5]', - cyan: 'from-[#00b7fa] to-[#01cfea]', - green: 'from-[#6FEE8D] to-[#17c964]', - pink: 'from-[#FF72E1] to-[#F54C7A]', - foreground: 'dark:from-[#FFFFFF] dark:to-[#4B4B4B]', - }, - size: { - sm: 'text-3xl lg:text-4xl', - md: 'text-[2.3rem] lg:text-5xl leading-9', - lg: 'text-4xl lg:text-6xl', - }, - fullWidth: { - true: 'w-full block', - }, - }, - defaultVariants: { - size: 'md', - }, - compoundVariants: [ - { - color: ['violet', 'yellow', 'blue', 'cyan', 'green', 'pink', 'foreground'], - class: 'bg-clip-text text-transparent bg-gradient-to-b', - }, - ], -}); - -export const subtitle = tv({ - base: 'w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full', - variants: { - fullWidth: { - true: '!w-full', - }, - }, - defaultVariants: { - fullWidth: true, - }, -}); diff --git a/app/components/project-select.tsx b/app/components/project-select.tsx deleted file mode 100644 index 98fc9c78..00000000 --- a/app/components/project-select.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import { Select, SelectItem, SharedSelection } from '@heroui/react'; -import { toast } from 'sonner'; - -import useQuery from '../hooks/useQuery'; -import { defaultProjectName } from '../lib/constants'; - -interface ProjectSelectProps { - onSelect: (project: string) => void; - refreshId?: string; - entity: 'result' | 'report'; -} - -export default function ProjectSelect({ refreshId, onSelect, entity }: Readonly) { - const { - data: projects, - error, - isLoading, - } = useQuery(`/api/${entity}/projects`, { - dependencies: [refreshId], - }); - - const items = [defaultProjectName, ...(projects ?? [])]; - - const onChange = (keys: SharedSelection) => { - if (keys === defaultProjectName.toString()) { - onSelect?.(defaultProjectName); - - return; - } - - if (!keys.currentKey) { - return; - } - - onSelect?.(keys.currentKey); - }; - - error && toast.error(error.message); - - return ( - - ); -} diff --git a/app/components/report-details/file-list.tsx b/app/components/report-details/file-list.tsx deleted file mode 100644 index 91631b40..00000000 --- a/app/components/report-details/file-list.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { FC, useEffect, useState } from 'react'; -import { Accordion, AccordionItem, Spinner } from '@heroui/react'; -import { toast } from 'sonner'; - -import { subtitle } from '../primitives'; -import { StatChart } from '../stat-chart'; - -import FileSuitesTree from './suite-tree'; -import ReportFilters from './tests-filters'; - -import { type ReportHistory } from '@/app/lib/storage'; -import useQuery from '@/app/hooks/useQuery'; -import { pluralize } from '@/app/lib/transformers'; - -interface FileListProps { - report?: ReportHistory | null; -} - -const FileList: FC = ({ report }) => { - const { - data: history, - isLoading: isHistoryLoading, - error: historyError, - } = useQuery(`/api/report/trend?limit=10&project=${report?.project ?? ''}`, { - callback: `/report/${report?.reportID}`, - dependencies: [report?.reportID], - }); - - const [filteredTests, setFilteredTests] = useState(report!); - - useEffect(() => { - if (historyError) { - toast.error(historyError.message); - } - }, [historyError]); - - if (!report) { - return ; - } - - return isHistoryLoading ? ( - - ) : ( -
-
-

File list

- -
- {!filteredTests?.files?.length ? ( -

No files found

- ) : ( - - {(filteredTests?.files ?? []).map((file) => ( - - {file.fileName} - - {file.tests.length} {pluralize(file.tests.length, 'test', 'tests')} - -

- } - > -
- -
-

Tests

- -
-
-
- ))} -
- )} -
- ); -}; - -export default FileList; diff --git a/app/components/report-details/report-stats.tsx b/app/components/report-details/report-stats.tsx deleted file mode 100644 index b37d0883..00000000 --- a/app/components/report-details/report-stats.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FC } from 'react'; - -import { StatChart } from '../stat-chart'; - -import { type ReportStats } from '@/app/lib/parser'; -import { pluralize } from '@/app/lib/transformers'; - -interface StatisticsProps { - stats?: ReportStats; -} - -const ReportStatistics: FC = ({ stats }) => { - if (!stats || Object.keys(stats).length === 0) { - return
No statistics available
; - } - - return ( -
-

- Total: {stats.total} {pluralize(stats.total, 'test', 'tests')} -

- -
- ); -}; - -export default ReportStatistics; diff --git a/app/components/report-details/suite-tree.tsx b/app/components/report-details/suite-tree.tsx deleted file mode 100644 index 0fdc9956..00000000 --- a/app/components/report-details/suite-tree.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Accordion, AccordionItem, Chip } from '@heroui/react'; - -import TestInfo from './test-info'; - -import { type ReportFile, type ReportTest } from '@/app/lib/parser'; -import { type ReportHistory } from '@/app/lib/storage'; -import { testStatusToColor } from '@/app/lib/tailwind'; - -interface SuiteNode { - name: string; - children: SuiteNode[]; - tests: ReportTest[]; -} - -function buildTestTree(rootName: string, tests: ReportTest[]): SuiteNode { - const root: SuiteNode = { name: rootName, children: [], tests: [] }; - - tests.forEach((test) => { - const { path } = test; - - const noSuites = path.length === 0; - - if (noSuites) { - root.tests.push(test); - - return; - } - - const lastNodeIndex = path.length - 1; - - path.reduce((currentNode, suiteName, index) => { - const existingSuite = currentNode.children.find((child) => child.name === suiteName); - - const noMoreSuites = index === lastNodeIndex; - - if (noMoreSuites && existingSuite) { - existingSuite.tests.push(test); - } - - if (existingSuite) { - return existingSuite; - } - - const newSuite: SuiteNode = { name: suiteName, children: [], tests: [] }; - - currentNode.children.push(newSuite); - - if (noMoreSuites) { - newSuite.tests.push(test); - } - - return newSuite; - }, root); - }); - - return root; -} - -interface SuiteNodeComponentProps { - suite: SuiteNode; - history: ReportHistory[]; -} - -const SuiteNodeComponent = ({ suite, history }: SuiteNodeComponentProps) => { - return ( - - {[ - ...suite.children.map((child) => ( - - - - )), - ...suite.tests.map((test) => { - const status = testStatusToColor(test.outcome); - - return ( - - {`· ${test.title}`} - - {status.title} - - - {test.projectName} - - - } - > - - - ); - }), - ]} - - ); -}; - -interface FileSuitesTreeProps { - file: ReportFile; - history: ReportHistory[]; - reportId?: string; -} - -const FileSuitesTree = ({ file, history }: FileSuitesTreeProps) => { - const suiteTree = buildTestTree(file.fileName, file.tests); - - return ; -}; - -export default FileSuitesTree; diff --git a/app/components/report-details/test-info.tsx b/app/components/report-details/test-info.tsx deleted file mode 100644 index dd312c65..00000000 --- a/app/components/report-details/test-info.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { FC } from 'react'; -import { Link, LinkIcon, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'; - -import FormattedDate from '../date-format'; - -import { subtitle } from '@/app/components/primitives'; -import { parseMilliseconds } from '@/app/lib/time'; -import { type TestHistory, type ReportHistory } from '@/app/lib/storage'; -import { type ReportTest } from '@/app/lib/parser/types'; -import { testStatusToColor } from '@/app/lib/tailwind'; -import { withBase } from '@/app/lib/url'; - -interface TestInfoProps { - history: ReportHistory[]; - test: ReportTest; -} - -const getTestHistory = (testId: string, history: ReportHistory[]) => { - return history - .map((report) => { - const file = report?.files?.find((file) => file.tests.some((test) => test.testId === testId)); - - if (!file) { - return; - } - - const test = file.tests.find((test) => test.testId === testId); - - if (!test) { - return; - } - - return { - ...test, - createdAt: report.createdAt, - reportID: report.reportID, - reportUrl: report.reportUrl, - }; - }) - .filter(Boolean) as unknown as TestHistory[]; -}; - -const TestInfo: FC = ({ test, history }: TestInfoProps) => { - const formatted = testStatusToColor(test.outcome); - - const testHistory = getTestHistory(test.testId, history); - - return ( -
-
-

- Outcome: {formatted.title} -

-

Location: {`${test.location.file}:${test.location.line}:${test.location.column}`}

-

Duration: {parseMilliseconds(test.duration)}

- {test.annotations.length > 0 &&

Annotations: {test.annotations.map((a) => JSON.stringify(a)).join(', ')}

} - {test.tags.length > 0 &&

Tags: {test.tags.join(', ')}

} -
- {!!testHistory?.length && ( -
-

Results:

- - - Created At - Status - Duration - Actions - - - {(item) => { - const itemOutcome = testStatusToColor(item?.outcome); - - return ( - - - - - - {itemOutcome.title} - - {parseMilliseconds(item.duration)} - - - - - - - ); - }} - -
-
- )} -
- ); -}; - -export default TestInfo; diff --git a/app/components/report-details/tests-filters.tsx b/app/components/report-details/tests-filters.tsx deleted file mode 100644 index f44b72a7..00000000 --- a/app/components/report-details/tests-filters.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client'; - -import { FC, useCallback, useEffect, useState } from 'react'; -import { Accordion, AccordionItem, Checkbox, CheckboxGroup, Input } from '@heroui/react'; - -import { ReportTestOutcome } from '@/app/lib/parser/types'; -import { type ReportHistory } from '@/app/lib/storage/types'; -import { testStatusToColor } from '@/app/lib/tailwind'; -import { filterReportHistory, pluralize } from '@/app/lib/transformers'; - -type ReportFiltersProps = { - report: ReportHistory; - onChangeFilters: (report: ReportHistory) => void; -}; - -const testOutcomes = [ - ReportTestOutcome.Expected, - ReportTestOutcome.Unexpected, - ReportTestOutcome.Skipped, - ReportTestOutcome.Flaky, -]; - -const ReportFilters: FC = ({ report, onChangeFilters }) => { - const [byName, setByName] = useState(''); - const [byOutcomes, setByOutcomes] = useState(testOutcomes); - - const onNameChange = (name: string) => { - setByName(name); - }; - - const onOutcomeChange = (outcomes?: ReportTestOutcome[]) => { - setByOutcomes(!outcomes ? [] : outcomes); - }; - - useEffect(() => { - onChangeFilters(currentFilterState()); - }, [byOutcomes, byName]); - - const currentFilterState = useCallback(() => { - const filtered = filterReportHistory(report, { - name: byName, - outcomes: byOutcomes, - }); - - return filtered; - }, [byName, byOutcomes]); - - const currentState = currentFilterState(); - - return ( - - -

Showing

- - {currentState.testCount}/{currentState.totalTestCount}{' '} - {pluralize(currentState.testCount, 'test', 'tests')} - - - } - > - onOutcomeChange(values as ReportTestOutcome[])} - > - {testOutcomes.map((outcome) => { - const status = testStatusToColor(outcome); - - return ( - - {status.title} - - ); - })} - - onNameChange(e.target.value)} /> -
-
- ); -}; - -export default ReportFilters; diff --git a/app/components/report-trends.tsx b/app/components/report-trends.tsx deleted file mode 100644 index 3874f1a4..00000000 --- a/app/components/report-trends.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import { Spinner } from '@heroui/react'; -import { useCallback, useState } from 'react'; -import { toast } from 'sonner'; - -import { defaultProjectName } from '../lib/constants'; - -import ProjectSelect from './project-select'; - -import { TrendChart } from '@/app/components/trend-chart'; -import { title } from '@/app/components/primitives'; -import useQuery from '@/app/hooks/useQuery'; -import { type ReportHistory } from '@/app/lib/storage'; -import { withQueryParams } from '@/app/lib/network'; - -export default function ReportTrends() { - const [project, setProject] = useState(defaultProjectName); - - const { - data: reports, - error, - isFetching, - isPending, - } = useQuery( - withQueryParams('/api/report/trend', { - project, - }), - { dependencies: [project] }, - ); - - const onProjectChange = useCallback((project: string) => { - setProject(project); - }, []); - - error && toast.error(error.message); - - return ( -
-
-

Trends

-
- -
- {(isFetching || isPending) && } -
- -
- {!!reports?.length && } -
-
- ); -} diff --git a/app/components/reports-table.tsx b/app/components/reports-table.tsx deleted file mode 100644 index c6289087..00000000 --- a/app/components/reports-table.tsx +++ /dev/null @@ -1,312 +0,0 @@ -'use client'; - -import { useCallback, useState, useMemo } from 'react'; -import { - Table, - TableHeader, - TableColumn, - TableBody, - TableRow, - TableCell, - Button, - Spinner, - Pagination, - LinkIcon, - Chip, - type Selection, -} from '@heroui/react'; -import Link from 'next/link'; -import { keepPreviousData } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import { withBase } from '../lib/url'; - -import TablePaginationOptions from './table-pagination-options'; - -import { withQueryParams } from '@/app/lib/network'; -import { defaultProjectName } from '@/app/lib/constants'; -import useQuery from '@/app/hooks/useQuery'; -import DeleteReportButton from '@/app/components/delete-report-button'; -import FormattedDate from '@/app/components/date-format'; -import { BranchIcon, FolderIcon } from '@/app/components/icons'; -import { ReadReportsHistory, ReportHistory } from '@/app/lib/storage'; - -const columns = [ - { name: 'Title', uid: 'title' }, - { name: 'Project', uid: 'project' }, - { name: 'Created at', uid: 'createdAt' }, - { name: 'Size', uid: 'size' }, - { name: '', uid: 'actions' }, -]; - -const coreFields = [ - 'reportID', - 'title', - 'project', - 'createdAt', - 'size', - 'sizeBytes', - 'reportUrl', - 'metadata', - 'startTime', - 'duration', - 'files', - 'projectNames', - 'stats', - 'errors', -]; - -const formatMetadataValue = (value: any): string => { - if (value === null || value === undefined) { - return String(value); - } - if (typeof value === 'object') { - return JSON.stringify(value); - } - - return String(value); -}; - -const getMetadataItems = (item: ReportHistory) => { - const metadata: Array<{ key: string; value: any; icon?: React.ReactNode }> = []; - - // Cast to any to access dynamic properties that come from resultDetails - const itemWithMetadata = item as any; - - // Add specific fields in preferred order - if (itemWithMetadata.environment) { - metadata.push({ key: 'environment', value: itemWithMetadata.environment }); - } - if (itemWithMetadata.workingDir) { - const dirName = itemWithMetadata.workingDir.split('/').pop() || itemWithMetadata.workingDir; - - metadata.push({ key: 'workingDir', value: dirName, icon: }); - } - if (itemWithMetadata.branch) { - metadata.push({ key: 'branch', value: itemWithMetadata.branch, icon: }); - } - - // Add any other metadata fields - Object.entries(itemWithMetadata).forEach(([key, value]) => { - if (!coreFields.includes(key) && !['environment', 'workingDir', 'branch'].includes(key)) { - // Skip empty objects - if (value !== null && typeof value === 'object' && Object.keys(value).length === 0) { - return; - } - metadata.push({ key, value }); - } - }); - - return metadata; -}; - -interface ReportsTableProps { - onChange: () => void; - selected?: string[]; - onSelect?: (reports: ReportHistory[]) => void; - onDeleted?: () => void; -} - -export default function ReportsTable({ onChange, selected, onSelect, onDeleted }: Readonly) { - const reportListEndpoint = '/api/report/list'; - const [project, setProject] = useState(defaultProjectName); - const [search, setSearch] = useState(''); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - const [page, setPage] = useState(1); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const getQueryParams = () => ({ - limit: rowsPerPage.toString(), - offset: ((page - 1) * rowsPerPage).toString(), - project, - ...(search.trim() && { search: search.trim() }), - ...(dateFrom && { dateFrom }), - ...(dateTo && { dateTo }), - }); - - const { - data: reportResponse, - isFetching, - isPending, - error, - refetch, - } = useQuery(withQueryParams(reportListEndpoint, getQueryParams()), { - dependencies: [project, search, dateFrom, dateTo, rowsPerPage, page], - placeholderData: keepPreviousData, - }); - - const { reports, total } = reportResponse ?? {}; - - const handleDeleted = () => { - onDeleted?.(); - onChange?.(); - refetch(); - }; - - const onChangeSelect = (keys: Selection) => { - if (keys === 'all') { - const all = reports ?? []; - - onSelect?.(all); - } - - if (typeof keys === 'string') { - return; - } - - const selectedKeys = Array.from(keys); - const selectedReports = reports?.filter((r) => selectedKeys.includes(r.reportID)) ?? []; - - onSelect?.(selectedReports); - }; - - const onPageChange = useCallback( - (page: number) => { - setPage(page); - }, - [page, rowsPerPage], - ); - - const onProjectChange = useCallback( - (project: string) => { - setProject(project); - setPage(1); - }, - [page, rowsPerPage], - ); - - const onSearchChange = useCallback((searchTerm: string) => { - setSearch(searchTerm); - setPage(1); - }, []); - - const onDateFromChange = useCallback((date: string) => { - setDateFrom(date); - setPage(1); - }, []); - - const onDateToChange = useCallback((date: string) => { - setDateTo(date); - setPage(1); - }, []); - - const pages = useMemo(() => { - return total ? Math.ceil(total / rowsPerPage) : 0; - }, [project, total, rowsPerPage]); - - error && toast.error(error.message); - console.log('reports', reports); - - return ( - <> - - 1 ? ( -
- -
- ) : null - } - classNames={{ - wrapper: 'p-0 border-none shadow-none', - tr: 'border-b-1 rounded-0', - }} - radius="none" - selectedKeys={selected} - selectionMode="multiple" - onSelectionChange={onChangeSelect} - > - - {(column) => ( - - {column.name} - - )} - - } - > - {(item) => ( - - -
- {/* Main title and link */} - -
- {item.title || item.reportID} -
- - - {/* Metadata chips below title */} -
- {getMetadataItems(item).map(({ key, value, icon }, index) => { - const formattedValue = formatMetadataValue(value); - const displayValue = - key === 'branch' || key === 'workingDir' ? formattedValue : `${key}: ${formattedValue}`; - - return ( - - {displayValue} - - ); - })} -
-
-
- {item.project} - - - - {item.size} - -
- - - - -
-
-
- )} -
-
- - ); -} diff --git a/app/components/reports.tsx b/app/components/reports.tsx deleted file mode 100644 index 83dcfd94..00000000 --- a/app/components/reports.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import ReportsTable from '@/app/components/reports-table'; -import { title } from '@/app/components/primitives'; -import DeleteReportButton from '@/app/components/delete-report-button'; -import { type ReportHistory } from '@/app/lib/storage'; - -interface ReportsProps { - onChange: () => void; -} - -export default function Reports({ onChange }: ReportsProps) { - const [selectedReports, setSelectedReports] = useState([]); - - const selectedReportIds = selectedReports.map((r) => r.reportID); - - const onListUpdate = () => { - setSelectedReports([]); - onChange?.(); - }; - - return ( - <> -
-
-

Reports

-
-
- {selectedReports.length > 0 && ( -
Reports selected: {selectedReports.length}
- )} - -
-
-
- - - ); -} diff --git a/app/components/results-table.tsx b/app/components/results-table.tsx deleted file mode 100644 index 88b8b430..00000000 --- a/app/components/results-table.tsx +++ /dev/null @@ -1,242 +0,0 @@ -'use client'; - -import { useCallback, useState, useMemo } from 'react'; -import { - Table, - TableHeader, - TableColumn, - TableBody, - TableRow, - TableCell, - Chip, - type Selection, - Spinner, - Pagination, -} from '@heroui/react'; -import { keepPreviousData } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -import { withQueryParams } from '@/app/lib/network'; -import { defaultProjectName } from '@/app/lib/constants'; -import TablePaginationOptions from '@/app/components/table-pagination-options'; -import useQuery from '@/app/hooks/useQuery'; -import FormattedDate from '@/app/components/date-format'; -import { ReadResultsOutput, type Result } from '@/app/lib/storage'; -import DeleteResultsButton from '@/app/components/delete-results-button'; - -const columns = [ - { name: 'Result ID', uid: 'title' }, - { name: 'Project', uid: 'project' }, - { name: 'Created at', uid: 'createdAt' }, - { name: 'Tags', uid: 'tags' }, - { name: 'Size', uid: 'size' }, - { name: '', uid: 'actions' }, -]; - -const notMetadataKeys = ['resultID', 'title', 'createdAt', 'size', 'sizeBytes', 'project']; - -const getTags = (item: Result) => { - return Object.entries(item).filter(([key]) => !notMetadataKeys.includes(key)); -}; - -interface ResultsTableProps { - selected?: string[]; - onSelect?: (results: Result[]) => void; - onDeleted?: () => void; -} - -export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly) { - const resultListEndpoint = '/api/result/list'; - const [project, setProject] = useState(defaultProjectName); - const [selectedTags, setSelectedTags] = useState([]); - const [search, setSearch] = useState(''); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - const [page, setPage] = useState(1); - const [rowsPerPage, setRowsPerPage] = useState(10); - - const getQueryParams = () => ({ - limit: rowsPerPage.toString(), - offset: ((page - 1) * rowsPerPage).toString(), - project, - ...(selectedTags.length > 0 && { tags: selectedTags.join(',') }), - ...(search.trim() && { search: search.trim() }), - ...(dateFrom && { dateFrom }), - ...(dateTo && { dateTo }), - }); - - const { - data: resultsResponse, - isFetching, - isPending, - error, - refetch, - } = useQuery(withQueryParams(resultListEndpoint, getQueryParams()), { - dependencies: [project, selectedTags, search, dateFrom, dateTo, rowsPerPage, page], - placeholderData: keepPreviousData, - }); - - const { results, total } = resultsResponse ?? {}; - - const shouldRefetch = () => { - onDeleted?.(); - refetch(); - }; - - const onPageChange = useCallback( - (page: number) => { - setPage(page); - }, - [page, rowsPerPage], - ); - - const onProjectChange = useCallback( - (project: string) => { - setProject(project); - setPage(1); - }, - [page, rowsPerPage], - ); - - const onTagsChange = useCallback((tags: string[]) => { - setSelectedTags(tags); - setPage(1); - }, []); - - const onSearchChange = useCallback((searchTerm: string) => { - setSearch(searchTerm); - setPage(1); - }, []); - - const onDateFromChange = useCallback((date: string) => { - setDateFrom(date); - setPage(1); - }, []); - - const onDateToChange = useCallback((date: string) => { - setDateTo(date); - setPage(1); - }, []); - - const pages = useMemo(() => { - return total ? Math.ceil(total / rowsPerPage) : 0; - }, [project, total, rowsPerPage]); - - const onChangeSelect = (keys: Selection) => { - if (keys === 'all') { - const all = results ?? []; - - onSelect?.(all); - } - - if (typeof keys === 'string') { - return; - } - - const selectedKeys = Array.from(keys); - const selectedResults = results?.filter((r) => selectedKeys.includes(r.resultID)) ?? []; - - onSelect?.(selectedResults); - }; - - error && toast.error(error.message); - - return ( - <> - - 1 ? ( -
- -
- ) : null - } - classNames={{ - wrapper: 'p-0 border-none shadow-none', - tr: 'border-b-1 rounded-0', - }} - radius="none" - selectedKeys={selected} - selectionMode="multiple" - onSelectionChange={onChangeSelect} - > - - {(column) => ( - - {column.name} - - )} - - } - > - {(item) => ( - - {item.title ?? item.resultID} - {item.project} - - - - - {getTags(item).map(([key, value], index) => ( - {`${key}: ${value}`} - ))} - - {item.size} - -
- -
-
-
- )} -
-
- - ); -} diff --git a/app/components/results.tsx b/app/components/results.tsx deleted file mode 100644 index 88bdf081..00000000 --- a/app/components/results.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { type Result } from '@/app/lib/storage'; -import ResultsTable from '@/app/components/results-table'; -import { title } from '@/app/components/primitives'; -import GenerateReportButton from '@/app/components/generate-report-button'; -import DeleteResultsButton from '@/app/components/delete-results-button'; -import UploadResultsButton from '@/app/components/upload-results-button'; -import { getUniqueProjectsList } from '@/app/lib/storage/format'; - -interface ResultsProps { - onChange: () => void; -} - -export default function Results({ onChange }: Readonly) { - const [selectedResults, setSelectedResults] = useState([]); - - const selectedResultIds = selectedResults.map((r) => r.resultID); - - const projects = getUniqueProjectsList(selectedResults); - - const onListUpdate = () => { - setSelectedResults([]); - onChange?.(); - }; - - return ( - <> -
-
-

Results

-
-
- {selectedResults.length > 0 && ( -
Results selected: {selectedResults.length}
- )} - - - -
-
-
- - - ); -} diff --git a/app/components/stat-chart.tsx b/app/components/stat-chart.tsx deleted file mode 100644 index d5766dd7..00000000 --- a/app/components/stat-chart.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Label, Pie, PieChart } from 'recharts'; - -import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/app/components/ui/chart'; - -const chartConfig = { - count: { - label: 'Count', - }, - expected: { - label: 'Passed', - color: 'hsl(var(--chart-1))', - }, - unexpected: { - label: 'Failed', - color: 'hsl(var(--chart-2))', - }, - flaky: { - label: 'Flaky', - color: 'hsl(var(--chart-3))', - }, - skipped: { - label: 'Skipped', - color: 'hsl(var(--chart-4))', - }, -} satisfies ChartConfig; - -interface StatChartProps { - stats: { - total: number; - expected: number; - unexpected: number; - flaky: number; - skipped: number; - ok: boolean; - }; -} - -export function StatChart({ stats }: Readonly) { - const chartData = [ - { - count: stats.expected, - status: 'Passed', - fill: 'hsl(var(--chart-1))', - }, - { - count: stats.unexpected, - status: 'Failed', - fill: 'hsl(var(--chart-2))', - }, - { count: stats.flaky, status: 'Flaky', fill: 'hsl(var(--chart-4))' }, - { - count: stats.skipped, - status: 'Skipped', - fill: 'hsl(var(--chart-3))', - }, - ]; - - return ( - - - } cursor={false} /> - - - - - ); -} diff --git a/app/components/table-pagination-options.tsx b/app/components/table-pagination-options.tsx deleted file mode 100644 index 23fc1468..00000000 --- a/app/components/table-pagination-options.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { type ChangeEvent, useCallback } from 'react'; -import { Select, SelectItem, Input } from '@heroui/react'; - -import ProjectSelect from '@/app/components/project-select'; -import TagSelect from '@/app/components/tag-select'; -import DateRangePicker from '@/app/components/date-range-picker'; -import { SearchIcon } from '@/app/components/icons'; - -interface TablePaginationRowProps { - total?: number; - rowsPerPage: number; - setRowsPerPage: (rows: number) => void; - setPage: (page: number) => void; - onProjectChange: (project: string) => void; - onSearchChange?: (search: string) => void; - onTagsChange?: (tags: string[]) => void; - onDateFromChange?: (date: string) => void; - onDateToChange?: (date: string) => void; - dateFrom?: string; - dateTo?: string; - rowPerPageOptions?: number[]; - entity: 'report' | 'result'; -} - -const defaultRowPerPageOptions = [10, 20, 40]; - -export default function TablePaginationOptions({ - // total, - rowsPerPage, - entity, - rowPerPageOptions, - setRowsPerPage, - setPage, - onProjectChange, - onSearchChange, - onTagsChange, - onDateFromChange, - onDateToChange, - dateFrom, - dateTo, -}: TablePaginationRowProps) { - const rowPerPageItems = rowPerPageOptions ?? defaultRowPerPageOptions; - - const onRowsPerPageChange = useCallback( - (e: ChangeEvent) => { - const rows = Number(e.target.value); - - setRowsPerPage(rows); - setPage(1); - }, - [rowsPerPage], - ); - - return ( -
- {/* Total {total ?? 0} */} -
- } - placeholder="Search..." - variant="bordered" - onChange={(e) => onSearchChange?.(e.target.value)} - /> - - {entity === 'result' && } - {(onDateFromChange || onDateToChange) && ( - - )} - -
-
- ); -} diff --git a/app/components/tag-select.tsx b/app/components/tag-select.tsx deleted file mode 100644 index 0e35e297..00000000 --- a/app/components/tag-select.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { Select, SelectItem, SharedSelection } from '@heroui/react'; -import { toast } from 'sonner'; - -import useQuery from '../hooks/useQuery'; - -interface TagSelectProps { - onSelect?: (tags: string[]) => void; - refreshId?: string; - entity: 'result' | 'report'; - project?: string; -} - -export default function TagSelect({ refreshId, onSelect, entity, project }: Readonly) { - const { - data: tags, - error, - isLoading, - } = useQuery(`/api/${entity}/tags${project ? `?project=${encodeURIComponent(project)}` : ''}`, { - dependencies: [refreshId, project], - }); - - const onChange = (keys: SharedSelection) => { - if (typeof keys === 'string') { - return; - } - - const selectedTags = Array.from(keys) as string[]; - - onSelect?.(selectedTags); - }; - - error && toast.error(error.message); - - return ( - - ); -} diff --git a/app/components/theme-switch.tsx b/app/components/theme-switch.tsx deleted file mode 100644 index 262745a0..00000000 --- a/app/components/theme-switch.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import { FC } from 'react'; -import { VisuallyHidden } from '@react-aria/visually-hidden'; -import { SwitchProps, useSwitch } from '@heroui/switch'; -import { useTheme } from 'next-themes'; -import { useIsSSR } from '@react-aria/ssr'; -import clsx from 'clsx'; - -import { SunFilledIcon, MoonFilledIcon } from '@/app/components/icons'; - -export interface ThemeSwitchProps { - className?: string; - classNames?: SwitchProps['classNames']; -} - -export const ThemeSwitch: FC = ({ className, classNames }) => { - const { theme: themeName, setTheme } = useTheme(); - const isSSR = useIsSSR(); - - // normalize theme name for compatibility with theme picker from playwright trace view - const theme = themeName?.replace('-mode', ''); - - const onChange = () => { - theme === 'light' ? setTheme('dark-mode') : setTheme('light-mode'); - }; - - const { Component, slots, isSelected, getBaseProps, getInputProps, getWrapperProps } = useSwitch({ - isSelected: theme === 'light' || isSSR, - 'aria-label': `Switch to ${theme === 'light' || isSSR ? 'dark' : 'light'} mode`, - onChange, - }); - - return ( - - - - -
- {!isSelected || isSSR ? : } -
-
- ); -}; diff --git a/app/components/trend-chart.tsx b/app/components/trend-chart.tsx deleted file mode 100644 index 5f2a321e..00000000 --- a/app/components/trend-chart.tsx +++ /dev/null @@ -1,168 +0,0 @@ -'use client'; -import { Area, AreaChart, XAxis } from 'recharts'; -import Link from 'next/link'; -import { Alert } from '@heroui/react'; - -import { type ReportHistory } from '@/app/lib/storage'; -import { - type ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from '@/app/components/ui/chart'; - -const chartConfig = { - failed: { - label: 'Failed', - color: 'hsl(var(--chart-2))', - }, - flaky: { - label: 'Flaky', - color: 'hsl(var(--chart-4))', - }, - passed: { - label: 'Passed', - color: 'hsl(var(--chart-1))', - }, - skipped: { - label: 'Skipped', - color: 'hsl(var(--chart-3))', - }, -} satisfies ChartConfig; - -interface WithTotal { - total: number; -} - -interface TrendChartProps { - reportHistory: ReportHistory[]; -} - -export function TrendChart({ reportHistory }: Readonly) { - const getPercentage = (value: number, total: number) => (value / total) * 100; - - const openInNewTab = (url: string) => { - typeof window !== 'undefined' && window.open(url, '_blank', 'noopener,noreferrer'); - }; - - const chartData = reportHistory.map((r) => ({ - date: new Date(r.createdAt).getTime(), - passed: getPercentage(r.stats.expected, r.stats.total), - passedCount: r.stats.expected, - failed: getPercentage(r.stats.unexpected, r.stats.total), - failedCount: r.stats.unexpected, - skipped: getPercentage(r.stats.skipped, r.stats.total), - skippedCount: r.stats.skipped, - flaky: getPercentage(r.stats.flaky, r.stats.total), - flakyCount: r.stats.flaky, - total: r.stats.total, - reportUrl: `/report/${r.reportID}`, - })); - - return ( - - {reportHistory.length <= 1 ? ( -
-
- -
-
- ) : ( - { - const url = e.activePayload?.at(0)?.payload?.reportUrl; - - url && openInNewTab(url); - }} - > - { - return new Date(value).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - }} - tickLine={false} - tickMargin={10} - /> - ( - <> -
- {chartConfig[name as keyof typeof chartConfig]?.label || name} -
- { - item.payload[ - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - `${name}Count` - ] - }{' '} - ({Math.round(value as number)}%) -
- {/* Add this after the last item */} - {index === 3 && ( - <> - -
- Total -
- {(item.payload as WithTotal).total} - tests -
-
-
- Created At -
- {new Date( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - item.payload.date, - ).toLocaleString()} -
-
- - )} - - )} - /> - } - cursor={true} - /> - {Object.keys(chartConfig).map((key) => ( - - ))} - } /> - - )} - - ); -} diff --git a/app/components/ui/chart.tsx b/app/components/ui/chart.tsx deleted file mode 100644 index 440133f2..00000000 --- a/app/components/ui/chart.tsx +++ /dev/null @@ -1,316 +0,0 @@ -'use client'; - -import { ReactNode, ComponentType, createContext, useContext, forwardRef, useId, useMemo } from 'react'; -import * as RechartsPrimitive from 'recharts'; - -import { cn } from '@/app/lib/tailwind'; - -const THEMES = { light: '', dark: '.dark' } as const; - -export type ChartConfig = { - [k in string]: { - label?: ReactNode; - icon?: ComponentType; - } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = createContext(null); - -function useChart() { - const context = useContext(ChartContext); - - if (!context) { - throw new Error('useChart must be used within a '); - } - - return context; -} - -const ChartContainer = forwardRef< - HTMLDivElement, - React.ComponentProps<'div'> & { - config: ChartConfig; - children: React.ComponentProps['children']; - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId: string = useId(); - const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; - - return ( - -
- - {children} -
-
- ); -}); - -ChartContainer.displayName = 'Chart'; - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([_, config]) => config.theme ?? config.color); - - if (!colorConfig.length) { - return null; - } - - return ( -