chore(web): каркас веб-приложения — Vite, TypeScript, ESLint, Vitest, make web/check (#21)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 20:08:07 +04:00
parent bc610a7734
commit bad6c73371
15 changed files with 4071 additions and 0 deletions

3
.gitignore vendored
View File

@ -12,3 +12,6 @@ __pycache__/
*.sqlite* *.sqlite*
.pytest_cache/ .pytest_cache/
.wt/ .wt/
src/jira_hub/web/app/
web/test-results/
web/playwright-report/

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
.PHONY: web check
# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос)
web:
cd web && npm ci && npm run build
# весь набор проверок: хаб и фронт; зелёным считается только всё вместе
check:
uv run pytest -q
cd web && npm run lint && npm test

16
web/eslint.config.js Normal file
View File

@ -0,0 +1,16 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["node_modules", "playwright-report", "test-results"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx,js}"],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { "react-hooks": reactHooks },
rules: { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error" },
},
);

14
web/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Хаб Jira</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3892
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "jira-hub-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p . && vite build",
"lint": "tsc --noEmit -p . && eslint .",
"test": "vitest run",
"e2e": "playwright test",
"golden": "node scripts/golden-from-links.ts"
},
"dependencies": {
"@tanstack/react-query": "5.104.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.63.0",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@types/node": "26.6.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"eslint": "10.11.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.12.0",
"jsdom": "29.1.1",
"typescript": "6.0.3",
"typescript-eslint": "8.71.0",
"vite": "8.3.1",
"vitest": "5.0.2"
}
}

8
web/src/App.test.tsx Normal file
View File

@ -0,0 +1,8 @@
import { render, screen } from "@testing-library/react";
import { expect, it } from "vitest";
import { App } from "./App.tsx";
it("показывает заголовок", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "Хаб Jira" })).toBeInTheDocument();
});

3
web/src/App.tsx Normal file
View File

@ -0,0 +1,3 @@
export function App() {
return <h1>Хаб Jira</h1>;
}

9
web/src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,14 @@
import vm from "node:vm";
import { describe, expect, it } from "vitest";
import { canon } from "./canon.ts";
describe("canon", () => {
it("сортирует ключи объектов и выбрасывает undefined", () => {
expect(JSON.stringify(canon({ b: 1, a: { d: undefined, c: [2, 1] } }))).toBe('{"a":{"c":[2,1]},"b":1}');
});
it("Set — отсортированный массив, в том числе из другого realm", () => {
const foreign = vm.runInNewContext('new Set(["b", "a"])');
expect(canon(foreign)).toEqual(["a", "b"]);
expect(canon(new Set([{ k: 2 }, { k: 1 }]))).toEqual([{ k: 1 }, { k: 2 }]);
});
});

13
web/src/test/canon.ts Normal file
View File

@ -0,0 +1,13 @@
// нормализация значений для сравнения с golden-эталоном: ключи объектов по алфавиту, Set — отсортированный массив,
// undefined-поля выбрасываются (как в JSON). Set распознаётся по тегу: объекты из node:vm — из другого realm
const isSet = (v: unknown): v is Set<unknown> => Object.prototype.toString.call(v) === "[object Set]";
export function canon(v: unknown): unknown {
if (isSet(v)) return [...v].map(canon).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
if (Array.isArray(v)) return v.map(canon);
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return Object.fromEntries(Object.keys(o).sort().filter((k) => o[k] !== undefined).map((k) => [k, canon(o[k])]));
}
return v;
}

8
web/src/test/setup.ts Normal file
View File

@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
localStorage.clear();
});

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
web/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node", "vite/client", "@testing-library/jest-dom"]
},
"include": ["src", "scripts", "e2e", "vite.config.ts", "playwright.config.ts"]
}

20
web/vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5)
const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791";
const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"];
export default defineConfig({
base: "/app/",
plugins: [react()],
build: { outDir: "../src/jira_hub/web/app", emptyOutDir: true },
server: { host: "127.0.0.1", port: 5173, proxy: Object.fromEntries(API.map((p) => [p, { target: hub }])) },
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
restoreMocks: true,
unstubGlobals: true,
},
});