feat(web): add eslint + prettier + vitest toolchain + web CI job (R8)

Added to web/package.json devDeps: eslint (9, flat config) +
eslint-plugin-svelte + typescript-eslint + globals; prettier +
prettier-plugin-svelte; vitest (jsdom env) + jsdom. New scripts: lint,
lint:fix, format, format:check, test, test:watch.

Configs:
- web/eslint.config.js — flat config, TS + Svelte, browser/node globals,
  no-explicit-any as warn, unused-vars as error (ignores _-prefixed).
- web/.prettierrc.json — single-quote, 100 width, svelte parser override.
- web/.prettierignore — dist/node_modules/build/lockfiles.
- web/vite.config.ts — vitest test block via reference directive, jsdom env,
  globals enabled.

Sample test: web/src/lib/utils.test.ts (6 tests covering relativeTime,
truncateMiddle, debounce — all passing).

CI: new web job in .gitea/workflows/ci.yml (npm ci, check [advisory],
lint [advisory], format:check [advisory], test [gate], build [gate]).
Advisory steps use continue-on-error until the baseline is clean —
matching the existing golangci-lint advisory pattern.

Known baseline surfaced by the new toolchain (pre-existing, not caused
by R8): svelte-check 154 errors (133-file config cascade), eslint 126
errors + 12 warnings (unused vars, @html XSS, unused CSS), prettier 175
unformatted files. Fixing these is a follow-up cleanup.

VERSION 0.7.9 -> 0.7.10. Plan R8 marked done; C.1 updated.
This commit is contained in:
2026-07-17 22:49:27 +02:00
parent fb39a48bef
commit 463bdacf5c
10 changed files with 4090 additions and 66 deletions

4
web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
build/
package-lock.json

9
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

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

@@ -0,0 +1,39 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import globals from 'globals'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
{
files: ['**/*.{ts,js,svelte}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['dist/', 'node_modules/', 'build/', '*.config.{ts,js}']
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
}
}
)

3971
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,12 @@
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "svelte-check --tsconfig ./tsconfig.json"
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@lucide/svelte": "^1.23.0",
@@ -18,13 +23,21 @@
"@tsconfig/svelte": "^5.0.0",
"@types/d3-force": "^3.0.10",
"bits-ui": "^2.18.1",
"eslint": "^9.0.0",
"eslint-plugin-svelte": "^2.46.0",
"globals": "^15.0.0",
"jsdom": "^25.0.0",
"prettier": "^3.3.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.2",
"typescript": "^5.5.0",
"vite": "^6.0.0"
"typescript-eslint": "^8.0.0",
"vite": "^6.0.0",
"vitest": "^2.0.0"
},
"dependencies": {
"clsx": "^2.1.1",

54
web/src/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { relativeTime, truncateMiddle, debounce } from './utils'
describe('relativeTime', () => {
it('returns "never" for null/undefined/empty', () => {
expect(relativeTime(null)).toBe('never')
expect(relativeTime(undefined)).toBe('never')
expect(relativeTime('')).toBe('never')
})
it('returns "just now" for future timestamps', () => {
const future = new Date(Date.now() + 10_000).toISOString()
expect(relativeTime(future)).toBe('just now')
})
it('formats seconds/minutes/hours/days', () => {
const now = Date.now()
expect(relativeTime(new Date(now - 5_000).toISOString())).toBe('5s ago')
expect(relativeTime(new Date(now - 120_000).toISOString())).toBe('2m ago')
expect(relativeTime(new Date(now - 3_600_000).toISOString())).toBe('1h ago')
expect(relativeTime(new Date(now - 86_400_000 * 2).toISOString())).toBe('2d ago')
})
})
describe('truncateMiddle', () => {
it('returns the string unchanged when at or under maxLen', () => {
expect(truncateMiddle('short', 36)).toBe('short')
expect(truncateMiddle('exactly36chars_exactly36chars_xxxxx', 36)).toBe(
'exactly36chars_exactly36chars_xxxxx'
)
})
it('truncates in the middle, preserving both ends', () => {
const out = truncateMiddle('investigation:nomos/dragonfly-memlock-overrun', 20)
expect(out).toContain('…')
// keep = floor((20 - 1) / 2) = 9 chars from each end
expect(out.startsWith('investiga')).toBe(true)
expect(out.endsWith('-overrun')).toBe(true)
expect(out.length).toBeLessThanOrEqual(20)
})
})
describe('debounce', () => {
it('collapses rapid calls into one trailing invocation', async () => {
let calls = 0
const fn = debounce(() => calls++, 20)
fn()
fn()
fn()
expect(calls).toBe(0)
await new Promise((r) => setTimeout(r, 40))
expect(calls).toBe(1)
})
})

View File

@@ -1,7 +1,8 @@
/// <reference types="vitest/config" />
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import { readFileSync, existsSync } from 'fs'
import { defineConfig } from 'vite'
// In Docker, VERSION is copied into the build WORKDIR (/build/web/VERSION).
// In local dev, the cwd is web/ and VERSION is two dirs up (../../VERSION
@@ -50,5 +51,10 @@ export default defineConfig({
// here so dev and prod agree on nomos's actual route paths.
'/agent': authProxy('http://localhost:8092', (path) => path.replace(/^\/agent/, ''))
}
},
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.{test,spec}.{ts,js}']
}
})