chore: improve tooling, linting, testing, and CI
- Add LICENSE file (CC BY-NC-SA 4.0) - Add .nvmrc pinning Node 22 - Add Renovate config with daily schedule and Gitea Actions workflow - Reduce ESLint max-warnings from 1000 to 50 - Re-enable Biome a11y/security recommended rules - Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics) - Improve CI: run on pushes to feat/fix branches, add pnpm audit - Add Vitest coverage with v8 provider and thresholds - Add E2E tests (auth, admin, navigation specs) - Add admin-maintenance server action test - Install @vitest/coverage-v8 - Ignore coverage/ directory
This commit is contained in:
1 parent
0f5c190ab1
commit
423a33200e
28 files changed
+1016
-80
No files matched your search
@@ -1,5 +1,10 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "feat/**"
|
||||
- "fix/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -8,7 +13,7 @@ jobs:
|
||||
check:
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Typecheck, lint, and test
|
||||
- name: Typecheck, lint, security audit, and test
|
||||
run: |
|
||||
set -e
|
||||
WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)"
|
||||
@@ -35,6 +40,7 @@ jobs:
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm prisma:generate
|
||||
pnpm audit --audit-level=high || echo "WARNING: pnpm audit found high/critical vulnerabilities"
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Renovate
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 5 * * *" # every day at 05:00
|
||||
workflow_dispatch: # manual trigger
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Self-hosted Renovate
|
||||
run: |
|
||||
set -e
|
||||
docker run --rm \
|
||||
-e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \
|
||||
-e RENOVATE_AUTODISCOVER=false \
|
||||
-e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \
|
||||
-e RENOVATE_ONBOARDING=false \
|
||||
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||
-e LOG_LEVEL=info \
|
||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
@@ -24,3 +24,6 @@ gitea
|
||||
|
||||
# Runtime uploaded media (persistent, outside public/)
|
||||
storage/
|
||||
|
||||
# Test coverage reports
|
||||
coverage/
|
||||
+1
-4
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!setup", "!public", "!*.cjs"]
|
||||
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
@@ -23,9 +23,6 @@
|
||||
"performance": {
|
||||
"noImgElement": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"style": {
|
||||
"noDescendingSpecificity": "off"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Admin panel", () => {
|
||||
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page).toHaveURL(/login/);
|
||||
});
|
||||
|
||||
test("admin page has login form", async ({ page }) => {
|
||||
await page.goto("/admin");
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication flows", () => {
|
||||
test("login form validates required fields", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("login with invalid credentials shows error", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[name="username"]', "nonexistent");
|
||||
await page.fill('input[name="password"]', "wrongpassword");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("register page has password confirmation field", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("register form validates password match", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.fill('input[name="password"]', "Password123!");
|
||||
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Public navigation", () => {
|
||||
test("homepage loads with expected elements", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("nav")).toBeVisible();
|
||||
await expect(page.locator("footer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("community page loads", async ({ page }) => {
|
||||
await page.goto("/community");
|
||||
await expect(page).toHaveTitle(/community/i);
|
||||
});
|
||||
|
||||
test("shop page loads", async ({ page }) => {
|
||||
await page.goto("/shop");
|
||||
await expect(page.locator("h1, h2").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("404 page for unknown routes", async ({ page }) => {
|
||||
const response = await page.goto("/this-page-does-not-exist");
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -19,8 +19,8 @@
|
||||
"analyze": "ANALYZE=true pnpm build",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=1000",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=1000",
|
||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
||||
"lhci:collect": "lhci collect",
|
||||
"lhci:assert": "lhci assert",
|
||||
"lhci:server": "lhci server",
|
||||
@@ -32,7 +32,7 @@
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write",
|
||||
"eslint --fix --max-warnings=1000"
|
||||
"eslint --fix --max-warnings=50"
|
||||
],
|
||||
"*.{json,md,css,scss,html}": [
|
||||
"biome format --write"
|
||||
@@ -98,6 +98,7 @@
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"dotenv": "^17.0.0",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
|
||||
Generated
+110
-11
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -9,7 +9,9 @@ self.addEventListener("activate", (event) => {
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE && k !== API_CACHE).map((k) => caches.delete(k)),
|
||||
keys
|
||||
.filter((k) => k !== CACHE && k !== API_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
|
||||
+117
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -262,6 +262,7 @@ export function ImportFurniClient() {
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable
|
||||
}, [toggleSelectAll]);
|
||||
|
||||
// Derived: unique categories from loaded items
|
||||
|
||||
@@ -220,11 +220,13 @@ export function ClientView({
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
}, [dragging]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
|
||||
}, [dragging, snapPos]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pos ? (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className={`fixed z-[9999] backdrop-blur-xl bg-black/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
|
||||
+1
-1
@@ -1745,7 +1745,7 @@ details[open] > summary .details-open\:rotate-180 {
|
||||
/* Input focus glow */
|
||||
.input-glow:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary) !important;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent),
|
||||
0 0 20px color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
|
||||
@@ -144,6 +144,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
|
||||
}, [canEdit, isDirty, saving, page, handleSave]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
|
||||
@@ -187,7 +187,7 @@ export function SortableTree({
|
||||
setFilter("all");
|
||||
dispatch({ type: "COLLAPSE_ALL" });
|
||||
void loadTabTree();
|
||||
}, [activeTabId, loadTabTree]);
|
||||
}, [activeTabId, loadTabTree, dispatch]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
@@ -325,7 +325,6 @@ export function SortableTree({
|
||||
|
||||
// ── Keyboard navigation ─────────────────────────────────────
|
||||
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
|
||||
@@ -200,7 +200,6 @@ export function CatalogTree({
|
||||
}, []);
|
||||
|
||||
// Debounced search
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
|
||||
@@ -155,7 +155,6 @@ export function HomeLoginForm({
|
||||
placeholder="Enter your 2FA code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
@@ -195,6 +194,8 @@ export function HomeLoginForm({
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
|
||||
@@ -133,7 +133,6 @@ export function LoginForm({
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)",
|
||||
@@ -173,6 +172,8 @@ export function LoginForm({
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
|
||||
@@ -291,62 +291,28 @@ export function RegisterForm({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={termsAccepted}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
className="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
borderColor: termsAccepted
|
||||
? "var(--color-primary)"
|
||||
: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
|
||||
backgroundColor: termsAccepted
|
||||
? "var(--color-primary)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{termsAccepted && (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
style={{ color: "var(--color-primary-foreground)" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Terms accepted"
|
||||
>
|
||||
<title>Terms accepted</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="hidden"
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
name="terms"
|
||||
value={termsAccepted ? "1" : ""}
|
||||
checked={termsAccepted}
|
||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
|
||||
/>
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={termsAccepted}
|
||||
tabIndex={0}
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="font-semibold cursor-pointer select-none"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setTermsAccepted(!termsAccepted);
|
||||
}
|
||||
}}
|
||||
className="font-semibold cursor-pointer select-none"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
>
|
||||
{t("termsAccept", { hotel: hotelName })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -372,11 +338,13 @@ export function RegisterForm({
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
|
||||
@@ -15,7 +15,7 @@ export function PwaRegister() {
|
||||
}
|
||||
});
|
||||
|
||||
navigator.serviceWorker.register("/sw.js?" + SW_VERSION).catch(() => {});
|
||||
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -220,6 +220,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: textColor }}
|
||||
role="img"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
|
||||
@@ -4,10 +4,12 @@ import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
function Label({ className, htmlFor, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: reusable component, input is associated via htmlFor at usage site
|
||||
<label
|
||||
data-slot="label"
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
|
||||
+4
-1
@@ -60,7 +60,10 @@ export const { handlers, signOut, auth } = NextAuth({
|
||||
pages: { signIn: "/login", error: "/login" },
|
||||
logger: {
|
||||
error(error) {
|
||||
logger.error("NextAuth error", { error: error.message, stack: error.stack });
|
||||
logger.error("NextAuth error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
|
||||
@@ -19,5 +19,24 @@ export default defineConfig({
|
||||
// unit tests run without a populated .env.
|
||||
env: { SKIP_ENV_VALIDATION: "1" },
|
||||
exclude: ["e2e/**", "node_modules/**", ".next/**"],
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov", "html"],
|
||||
thresholds: {
|
||||
statements: 5,
|
||||
branches: 3,
|
||||
functions: 4,
|
||||
lines: 5,
|
||||
},
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"src/generated/**",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/types/**",
|
||||
"src/test/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user