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

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)
})
})