test: add frontend test suite
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 43s
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 43s
This commit is contained in:
@@ -22,6 +22,9 @@ jobs:
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
|
||||
@@ -48,6 +48,7 @@ For code changes, run:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npm run format:check
|
||||
npm test
|
||||
```
|
||||
|
||||
Run `npm run build` when changes affect routes, config, build, deploy, env vars, or dependencies.
|
||||
|
||||
@@ -24,6 +24,7 @@ Before proposing a push or deploy, run:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npm run format:check
|
||||
npm test
|
||||
```
|
||||
|
||||
If code formatting is needed, run:
|
||||
@@ -64,9 +65,10 @@ VITE_DISABLE_ADMIN=true
|
||||
1. `npm ci`
|
||||
2. `npx tsc --noEmit`
|
||||
3. `npm run format:check`
|
||||
4. `npm run build` with production public env
|
||||
5. rsync `dist/` to both frontend servers
|
||||
6. compare remote `index.html` checksums
|
||||
4. `npm test`
|
||||
5. `npm run build` with production public env
|
||||
6. rsync `dist/` to both frontend servers
|
||||
7. compare remote `index.html` checksums
|
||||
|
||||
## Agent behavior preferences
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ npm run dev # start Vite dev server
|
||||
npx tsc --noEmit # TypeScript check; CI requires this
|
||||
npm run format:check # Prettier check; CI requires this
|
||||
npm run format # format source files
|
||||
npm test # run Vitest test suite
|
||||
npm run build # production build to dist/
|
||||
npm run preview # preview built app locally
|
||||
```
|
||||
@@ -43,6 +44,7 @@ Before pushing, run at least:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npm run format:check
|
||||
npm test
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
@@ -86,7 +88,7 @@ Important config files:
|
||||
|
||||
- `main` is the deploy branch. Pushing to `main` triggers `.gitea/workflows/deploy.yml`.
|
||||
- `terry-staging` exists as a staging/work branch for later work.
|
||||
- The deploy workflow runs `npm ci`, `npx tsc --noEmit`, `npm run format:check`, `npm run build`, then rsyncs `dist/` to both frontend servers and verifies matching checksums.
|
||||
- The deploy workflow runs `npm ci`, `npx tsc --noEmit`, `npm run format:check`, `npm test`, `npm run build`, then rsyncs `dist/` to both frontend servers and verifies matching checksums.
|
||||
|
||||
See also:
|
||||
|
||||
|
||||
@@ -19,23 +19,24 @@ on:
|
||||
2. Install dependencies with `npm ci`.
|
||||
3. Type check with `npx tsc --noEmit`.
|
||||
4. Check formatting with `npm run format:check`.
|
||||
5. Build with `npm run build` using:
|
||||
5. Run tests with `npm test`.
|
||||
6. Build with `npm run build` using:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=https://api.ark-library.com
|
||||
VITE_DISABLE_ADMIN=true
|
||||
```
|
||||
|
||||
6. Configure SSH key from `DEPLOY_KEY` secret.
|
||||
7. `rsync --delete` built `dist/` to both frontend servers:
|
||||
7. Configure SSH key from `DEPLOY_KEY` secret.
|
||||
8. `rsync --delete` built `dist/` to both frontend servers:
|
||||
|
||||
```text
|
||||
ec2-user@FRONTEND_1_HOST:/var/www/ark-library/
|
||||
ec2-user@FRONTEND_2_HOST:/var/www/ark-library/
|
||||
```
|
||||
|
||||
8. Verify both servers have matching `index.html` SHA-256 checksums.
|
||||
9. Remove temporary SSH key.
|
||||
9. Verify both servers have matching `index.html` SHA-256 checksums.
|
||||
10. Remove temporary SSH key.
|
||||
|
||||
## Required repository secrets
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Run:
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
npm run format:check
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
|
||||
1215
package-lock.json
generated
1215
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -8,7 +8,9 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\""
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rainbow-me/rainbowkit": "^2.2.11",
|
||||
@@ -21,14 +23,19 @@
|
||||
"wagmi": "^2.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
71
src/api.test.ts
Normal file
71
src/api.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadApi(apiUrl = "") {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("VITE_API_URL", apiUrl);
|
||||
return import("./api");
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe("api helpers", () => {
|
||||
it("normalizes nullish API arrays", async () => {
|
||||
const { itemsOrEmpty } = await loadApi();
|
||||
|
||||
expect(itemsOrEmpty(null)).toEqual([]);
|
||||
expect(itemsOrEmpty(undefined)).toEqual([]);
|
||||
expect(itemsOrEmpty(["a"])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("builds asset URLs from API base while preserving absolute URLs", async () => {
|
||||
const { assetUrl } = await loadApi("https://api.example.com");
|
||||
|
||||
expect(assetUrl("/uploads/file.png")).toBe(
|
||||
"https://api.example.com/uploads/file.png",
|
||||
);
|
||||
expect(assetUrl("https://cdn.example.com/file.png")).toBe(
|
||||
"https://cdn.example.com/file.png",
|
||||
);
|
||||
expect(assetUrl(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("fetches JSON with API base and throws response text on failure", async () => {
|
||||
const { getJSON } = await loadApi("https://api.example.com");
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }))
|
||||
.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getJSON("/api/resources")).resolves.toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/api/resources",
|
||||
);
|
||||
await expect(getJSON("/api/fail")).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("posts JSON with optional bearer token", async () => {
|
||||
const { postJSON } = await loadApi();
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
postJSON("/api/admin/resources", { title: "Demo" }, "token-123"),
|
||||
).resolves.toEqual({ id: 1 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/admin/resources", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer token-123",
|
||||
},
|
||||
body: JSON.stringify({ title: "Demo" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
100
src/components/ResourceCard.test.tsx
Normal file
100
src/components/ResourceCard.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Resource } from "../api";
|
||||
import { I18nProvider } from "../i18n";
|
||||
import { ResourceCard } from "./ResourceCard";
|
||||
|
||||
const resource: Resource = {
|
||||
id: "resource-1",
|
||||
title: "Demo Resource",
|
||||
description: "Short description",
|
||||
type: "pdf",
|
||||
language: "zh-TW",
|
||||
categoryId: 1,
|
||||
categorySlug: "docs",
|
||||
categoryName: "文件",
|
||||
coverImage: "/uploads/cover.png",
|
||||
fileUrl: "/uploads/demo.pdf",
|
||||
previewUrl: "/uploads/preview.pdf",
|
||||
badgeLabel: "官方",
|
||||
isDownloadable: true,
|
||||
isRecommended: true,
|
||||
updatedAt: "2026-05-16T00:00:00Z",
|
||||
tags: ["ark"],
|
||||
};
|
||||
|
||||
function renderCard(onFavoriteToggle = vi.fn()) {
|
||||
return {
|
||||
user: userEvent.setup(),
|
||||
onFavoriteToggle,
|
||||
...render(
|
||||
<MemoryRouter>
|
||||
<I18nProvider>
|
||||
<ResourceCard r={resource} onFavoriteToggle={onFavoriteToggle} />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function okResponse() {
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ResourceCard", () => {
|
||||
it("renders resource summary and detail link", () => {
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByText("Demo Resource")).toBeInTheDocument();
|
||||
expect(screen.getByText("文件")).toBeInTheDocument();
|
||||
expect(screen.getByText("官方")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /預覽/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/resource/resource-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles local favorite state and syncs best-effort API delta", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(okResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { user, onFavoriteToggle } = renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /收藏/ }));
|
||||
|
||||
expect(JSON.parse(localStorage.getItem("ark_favorites") || "[]")).toEqual([
|
||||
"resource-1",
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/resources/resource-1/favorite",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ add: true }),
|
||||
}),
|
||||
);
|
||||
expect(onFavoriteToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tracks downloads then opens the downloadable file", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(okResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const { user } = renderCard();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /下載/ }));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/resources/resource-1/download",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
"/uploads/demo.pdf",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
52
src/components/ResourceListFooter.test.tsx
Normal file
52
src/components/ResourceListFooter.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ResourceListFooter } from "./ResourceListFooter";
|
||||
|
||||
const t = (key: string) =>
|
||||
({
|
||||
listRange: "顯示 {{from}}–{{to}},共 {{total}} 筆",
|
||||
paginationPrev: "上一頁",
|
||||
paginationNext: "下一頁",
|
||||
pageIndicator: "{{c}} / {{p}} 頁",
|
||||
})[key] ?? key;
|
||||
|
||||
describe("ResourceListFooter", () => {
|
||||
it("renders range, page count, and disabled prev on first page", () => {
|
||||
render(
|
||||
<ResourceListFooter
|
||||
page={1}
|
||||
limit={24}
|
||||
total={50}
|
||||
t={t}
|
||||
onPrev={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("顯示 1–24,共 50 筆")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 / 3 頁")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "上一頁" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "下一頁" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("calls navigation handlers when buttons are enabled", () => {
|
||||
const onPrev = vi.fn();
|
||||
const onNext = vi.fn();
|
||||
render(
|
||||
<ResourceListFooter
|
||||
page={2}
|
||||
limit={24}
|
||||
total={50}
|
||||
t={t}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "上一頁" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "下一頁" }));
|
||||
|
||||
expect(onPrev).toHaveBeenCalledTimes(1);
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
27
src/favorites.test.ts
Normal file
27
src/favorites.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isFavorite, readFavorites, toggleFavorite } from "./favorites";
|
||||
|
||||
describe("favorites localStorage helpers", () => {
|
||||
it("returns an empty list for missing, invalid, or non-array values", () => {
|
||||
expect(readFavorites()).toEqual([]);
|
||||
|
||||
localStorage.setItem("ark_favorites", "not-json");
|
||||
expect(readFavorites()).toEqual([]);
|
||||
|
||||
localStorage.setItem("ark_favorites", JSON.stringify({ id: "r1" }));
|
||||
expect(readFavorites()).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters non-string entries and toggles favorite ids", () => {
|
||||
localStorage.setItem("ark_favorites", JSON.stringify(["r1", 123, "r2"]));
|
||||
expect(readFavorites()).toEqual(["r1", "r2"]);
|
||||
|
||||
expect(toggleFavorite("r3")).toBe(true);
|
||||
expect(isFavorite("r3")).toBe(true);
|
||||
expect(readFavorites()).toEqual(["r1", "r2", "r3"]);
|
||||
|
||||
expect(toggleFavorite("r1")).toBe(false);
|
||||
expect(isFavorite("r1")).toBe(false);
|
||||
expect(readFavorites()).toEqual(["r2", "r3"]);
|
||||
});
|
||||
});
|
||||
33
src/resourceTypeLabels.test.ts
Normal file
33
src/resourceTypeLabels.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resourceLanguageLabel,
|
||||
resourceTypeDisplay,
|
||||
resourceTypeLabel,
|
||||
typeFilterLabel,
|
||||
} from "./resourceTypeLabels";
|
||||
|
||||
const t = (key: string) =>
|
||||
({
|
||||
filterAll: "全部",
|
||||
type_image: "圖片",
|
||||
type_video: "影片",
|
||||
lang_zh_TW: "繁中",
|
||||
lang_zh_CN: "簡中",
|
||||
lang_en: "英文",
|
||||
})[key] ?? key;
|
||||
|
||||
describe("resource labels", () => {
|
||||
it("localizes known resource types and falls back to raw type", () => {
|
||||
expect(typeFilterLabel(t, "all")).toBe("全部");
|
||||
expect(resourceTypeLabel(t, "image")).toBe("圖片");
|
||||
expect(resourceTypeDisplay(t, "video")).toBe("影片");
|
||||
expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("normalizes resource language codes", () => {
|
||||
expect(resourceLanguageLabel(t, "zh-TW")).toBe("繁中");
|
||||
expect(resourceLanguageLabel(t, "zh-hans")).toBe("簡中");
|
||||
expect(resourceLanguageLabel(t, "EN")).toBe("英文");
|
||||
expect(resourceLanguageLabel(t, "ja")).toBe("ja");
|
||||
});
|
||||
});
|
||||
49
src/test/setup.ts
Normal file
49
src/test/setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
function createLocalStorageMock(): Storage {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
clear() {
|
||||
store = {};
|
||||
},
|
||||
getItem(key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(store, key)
|
||||
? store[key]
|
||||
: null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Object.keys(store)[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
delete store[key];
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store[key] = String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const storage = createLocalStorageMock();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
23
src/utils/categoryDisplay.test.ts
Normal file
23
src/utils/categoryDisplay.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { categoryCardLines } from "./categoryDisplay";
|
||||
|
||||
describe("categoryCardLines", () => {
|
||||
it("splits Chinese and ASCII parenthetical subtitles", () => {
|
||||
expect(categoryCardLines("官方公告(繁中)")).toEqual({
|
||||
line1: "官方公告",
|
||||
line2: "(繁中)",
|
||||
});
|
||||
expect(categoryCardLines("Tutorials (EN)")).toEqual({
|
||||
line1: "Tutorials",
|
||||
line2: "(EN)",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses trimmed description as subtitle when name has no parentheses", () => {
|
||||
expect(categoryCardLines("影片", " 官方教學 ")).toEqual({
|
||||
line1: "影片",
|
||||
line2: "官方教學",
|
||||
});
|
||||
expect(categoryCardLines("文件")).toEqual({ line1: "文件" });
|
||||
});
|
||||
});
|
||||
11
src/video.test.ts
Normal file
11
src/video.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLikelyVideoPath } from "./video";
|
||||
|
||||
describe("isLikelyVideoPath", () => {
|
||||
it("detects common video extensions and ignores query strings", () => {
|
||||
expect(isLikelyVideoPath("/uploads/intro.MP4?token=1")).toBe(true);
|
||||
expect(isLikelyVideoPath("https://cdn.example.com/demo.webm")).toBe(true);
|
||||
expect(isLikelyVideoPath("/uploads/file.pdf")).toBe(false);
|
||||
expect(isLikelyVideoPath(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
environmentOptions: {
|
||||
jsdom: {
|
||||
url: "http://localhost/",
|
||||
},
|
||||
},
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user