chore: v1.7.0 -- vibe galaxy, CI pipeline, enrichment hardening, PWA, preprod sweep

- Bump frontend and backend to 1.7.0
- Update CHANGELOG with full 1.7.0 release notes
- Remove vibe-test dev prototype page and unused R3F components
  (VibeUniverse, TrackCloud, TrackTooltip, universeUtils)
- Fix stale audio.completed counter: flush live DB count at isFullyComplete
  transition -- counter was frozen at last audioQueued > 0 cycle value
- Add GitHub Actions CI pipeline: lint/typecheck, unit tests, security scan,
  E2E predeploy, nightly Docker build and push to Hub + GHCR
- Add E2E enrichment cycle spec with 55-min timeout and memory monitoring script
- Add E2E vibe spec covering map, song path, search, alchemy, similar tracks
- PWA hardening: offline fallback, update banner, WCO, manifest fixes
- Production readiness: OOM memory caps in both compose files, DoS/SSRF/auth fixes
- Remove double-auth in systemSettings (requireAdmin already enforces auth)
- Fix mobile vibe page full-height rendering, vibe map timer leak, abort signal wiring
- Fix E2E test helpers: graceful skip with waitFor + try/catch for empty-library CI
- Fix create-e2e-user.sh: admin role, bcrypt shell expansion, psql heredoc quoting
This commit is contained in:
Your Name
2026-03-16 18:25:08 -05:00
parent 1d2f0ed25f
commit 9083835bfd
22 changed files with 692 additions and 846 deletions
+5 -2
View File
@@ -50,7 +50,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -68,13 +68,16 @@ jobs:
KIMA_TEST_PASSWORD: ${{ secrets.KIMA_TEST_PASSWORD }}
KIMA_UI_BASE_URL: http://127.0.0.1:3030
- name: Run queue, playlist, and security tests
- name: Run functional tests
working-directory: frontend
run: |
npx playwright test \
tests/e2e/smoke.spec.ts \
tests/e2e/queue.spec.ts \
tests/e2e/playlists.spec.ts \
tests/e2e/security.spec.ts \
tests/e2e/vibe.spec.ts \
tests/e2e/full-ux-audit.spec.ts \
--reporter=list
env:
KIMA_TEST_USERNAME: ${{ secrets.KIMA_TEST_USERNAME }}
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
+4 -26
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -34,7 +34,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -46,7 +46,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: backend/package-lock.json
@@ -64,7 +64,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: backend/package-lock.json
@@ -78,28 +78,6 @@ jobs:
# a live PostgreSQL database. It runs in integration.yml instead.
run: npm test -- --passWithNoTests --testPathIgnorePatterns="webhookEventStore"
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Audit frontend
working-directory: frontend
run: |
npm ci --ignore-scripts
npm audit --audit-level=critical || true
- name: Audit backend
working-directory: backend
run: |
npm ci --ignore-scripts
npm audit --audit-level=critical || true
build-docker:
name: Docker Build Check
runs-on: ubuntu-latest
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
- name: Audit frontend (critical only)
working-directory: frontend
@@ -70,7 +70,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
+33
View File
@@ -5,6 +5,39 @@ All notable changes to Kima will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.0] - 2026-03-16
### Added
- **Vibe 3D galaxy view**: New immersive "galaxy" toggle on the Vibe page renders your library as a navigable 3D star field using React Three Fiber + Three.js. Tracks are positioned by their vibe embeddings, lit by a dynamic space grid with depth lines, and colored by mood. Double-click any point to play. Camera uses map controls with smooth zoom and pan. Rendered in a dedicated `GravityGridScene` with cluster label billboards, optimized point instancing, and post-processing disabled for GPU performance.
- **Vibe map pre-compute and KNN acceleration**: Map data is now pre-computed on the backend at enrichment completion rather than on first page load. KNN graph is built incrementally (100-at-a-time) against a pre-sorted index, cutting cold-start time from seconds to milliseconds on large libraries.
- **GitHub Actions CI pipeline**: Full multi-stage CI across all PRs and pushes to main. Stages: lint + typecheck (frontend + backend), unit tests (Jest), security scan (CodeQL + secrets), E2E predeploy tests (Playwright against a real Docker build), nightly build + push to Docker Hub and GHCR.
- **E2E enrichment cycle spec**: Playwright test that wipes all enrichment data, triggers a full re-enrich, polls until completion, then asserts track success rate >= 80%, vibe map populated, vibe search returning results, and failure rate < 20%. Skips gracefully when the library has fewer than 10 tracks (CI containers). Companion shell script (`scripts/run-enrichment-memory-test.sh`) captures host SUnreclaim and container RSS before/after and fails the run if slab growth exceeds 1 GB.
- **E2E vibe spec**: Playwright test covering vibe map load, track selection, song path generation, vibe search, alchemy (track blending), and similar-tracks suggestions.
- **PWA offline fallback**: Service worker now serves a cached offline page when navigation requests fail. Cached on install alongside the app shell.
- **PWA update banner**: App detects when a new service worker is waiting and shows a non-blocking "Update available" banner. Clicking it activates the new worker and reloads.
- **Web app manifest fixes**: `display_override` with `window-controls-overlay` for desktop PWA title bar space. Corrected `scope`, `start_url`, and `id` fields. Added `edge_side_panel` and screenshot metadata.
### Fixed
- **Enrichment: audio counter stale at completion**: `audio.completed` in the Redis state snapshot was frozen at the value from the last `audioQueued > 0` cycle. Once all tracks were queued, the update block was skipped while Essentia continued processing in the background, leaving the counter stuck (e.g. 171/232). The counter is now flushed from the live DB count at the `isFullyComplete` transition before going idle.
- **Security: double authentication in system settings**: `requireAdmin` already enforces authentication, so the redundant `requireAuth` call before it was removed.
- **Security: OOM from Next.js VMA accumulation**: The App Router's map-style routes allocate ~15 TB of virtual memory, causing `anon_vma_chain` slab exhaustion on the host kernel. Mitigations: Docker memory cap (`mem_limit: 6g`, `memswap_limit: 8g`) in both compose files; Node 22 in the Dockerfile (V8 improvements reduce anonymous VMAs); `MALLOC_ARENA_MAX=1` for the frontend process (fewer glibc arenas); `NODE_OPTIONS=--max-old-space-size=512` for the frontend (bounds heap to reduce GC-driven VMAs).
- **Security: DoS vectors patched**: Unbounded enrichment queue, unguarded bulk endpoints, and routes missing rate limits addressed in the production hardening pass.
- **Security: SSRF vector in webhook callback validation**: Callback URLs are now validated against an allowlist of configured hosts before being followed.
- **Vibe map accuracy**: SLERP interpolation corrected, similarity formula made consistent between search and map. Distinct mood colors assigned per cluster. ARIA labels added to interactive elements.
- **Vibe map timer leak**: `setInterval` polling the map data endpoint was not cleared on unmount. Replaced with React Query cache with explicit `staleTime`.
- **Vibe request ref cleanup**: `vibeRequestRef` was not cleaned up on component unmount, causing stale abort signals on re-mount.
- **Mobile: full-height vibe page rendering**: CSS height chain corrected from `html`/`body` through layout to vibe page so the map fills the viewport without overflow or clipping on mobile.
- **Subsonic: `.view` suffix normalization**: Requests from clients that append `.view` to endpoint names (e.g. `getCoverArt.view`) are now normalized before routing. Expanded OpenSubsonic endpoint compatibility.
- **Service worker: stale styles after rebuild**: SW was caching HTML and CSS documents, causing users to see outdated styles until manual cache clear. HTML and CSS are now excluded from the SW cache entirely.
- **dead `sourceTrack` variable**: Removed dead variable from vibe request path.
- **Abort signal not wired to vibe fetch**: Cancelling a vibe search now properly aborts the in-flight fetch.
### Changed
- **vibe-test prototype page removed**: The `/vibe-test` dev sandbox page and its associated R3F prototype components (`VibeUniverse`, `TrackCloud`, `TrackTooltip`, `universeUtils`) have been removed. The production galaxy view lives in `GravityGridScene`.
## [1.6.4] - 2026-03-12
Fixes #152. Addresses #145.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-backend",
"version": "1.6.4",
"version": "1.7.0",
"description": "Kima backend API server",
"license": "GPL-3.0",
"repository": {
+10
View File
@@ -713,9 +713,19 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{
}
if (progress.isFullyComplete) {
// Flush final audio counter before going idle -- it may be stale because
// the update block above only runs when audioQueued > 0. Once all tracks
// are already queued, that block is skipped while Essentia finishes in
// the background, leaving the counter frozen at a mid-run snapshot.
await enrichmentStateService.updateState({
status: "idle",
currentPhase: null,
audio: {
total: progress.audioAnalysis.total,
completed: progress.audioAnalysis.completed,
failed: progress.audioAnalysis.failed,
processing: 0,
},
});
// Pre-compute vibe map projection so it's cached before first page visit
+5
View File
@@ -33,6 +33,11 @@ services:
# Makes host.docker.internal work on Linux (already works on Docker Desktop)
extra_hosts:
- "host.docker.internal:host-gateway"
# Cap memory to prevent host kernel OOM cascade from anon_vma_chain slab exhaustion.
# Without this, the host kernel OOM killer fires when Next.js VMA chains accumulate.
# 6g is enough for frontend + backend + embeddings + Redis + Postgres.
mem_limit: 6g
memswap_limit: 8g
# Fix Redis memory overcommit warning
sysctls:
- vm.overcommit_memory=1
+5
View File
@@ -31,6 +31,11 @@ services:
# Makes host.docker.internal work on Linux (already works on Docker Desktop)
extra_hosts:
- "host.docker.internal:host-gateway"
# Cap memory to prevent host kernel OOM cascade from anon_vma_chain slab exhaustion.
# Without this, the host kernel OOM killer fires when Next.js VMA chains accumulate.
# 6g is enough for frontend + backend + embeddings + Redis + Postgres.
mem_limit: 6g
memswap_limit: 8g
# Fix Redis memory overcommit warning
sysctls:
- vm.overcommit_memory=1
-110
View File
@@ -1,110 +0,0 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
import type { MapTrack } from "@/features/vibe/types";
const VibeUniverse = dynamic(
() => import("@/features/vibe/VibeUniverse").then(m => ({ default: m.VibeUniverse })),
{ ssr: false }
);
export default function VibeTestPage() {
const { data: mapData, isLoading, error, refetch } = useQuery({
queryKey: ["vibe-map"],
queryFn: () => api.getVibeMap(),
staleTime: 1000 * 60 * 60,
gcTime: 1000 * 60 * 60 * 24,
retry: 1,
});
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null);
const [highlightedIds] = useState<Set<string>>(new Set());
const tracks = mapData?.tracks;
const trackMap = useMemo(() => {
if (!tracks) return new Map<string, MapTrack>();
const map = new Map<string, MapTrack>();
for (const track of tracks) {
map.set(track.id, track);
}
return map;
}, [tracks]);
const selectedTrack = selectedTrackId ? trackMap.get(selectedTrackId) ?? null : null;
const handleTrackClick = useCallback((trackId: string) => {
setSelectedTrackId(trackId);
}, []);
const handleBackgroundClick = useCallback(() => {
setSelectedTrackId(null);
}, []);
if (isLoading) {
return (
<div className="w-full h-full bg-black flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-6 h-6 text-[var(--color-ai)] animate-spin mx-auto mb-3 opacity-60" />
<p className="text-white/40 text-sm tracking-wide">Loading universe data</p>
</div>
</div>
);
}
if (error) {
return (
<div className="w-full h-full bg-black flex items-center justify-center">
<div className="text-center">
<p className="text-white/40 text-sm">Failed to load universe data</p>
<p className="text-white/20 text-xs mt-1">
{error instanceof Error ? error.message : "Unknown error"}
</p>
<button
onClick={() => refetch()}
className="mt-3 px-4 py-1.5 bg-white/10 hover:bg-white/15 rounded text-xs text-white/60 hover:text-white"
>
Retry
</button>
</div>
</div>
);
}
if (!mapData || mapData.tracks.length === 0) {
return (
<div className="w-full h-full bg-black flex items-center justify-center">
<div className="text-center">
<p className="text-white/40 text-sm">No tracks with vibe analysis yet</p>
<p className="text-white/20 text-xs mt-1">Run enrichment to generate embeddings</p>
</div>
</div>
);
}
return (
<div className="w-full h-full relative overflow-hidden">
<VibeUniverse
tracks={mapData.tracks}
highlightedIds={highlightedIds}
selectedTrackId={selectedTrackId}
onTrackClick={handleTrackClick}
onBackgroundClick={handleBackgroundClick}
/>
{selectedTrack && (
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 z-10 bg-black/70 backdrop-blur-md border border-white/10 rounded-lg px-4 py-2 text-center">
<div className="text-white text-sm font-medium truncate max-w-64">
{selectedTrack.title}
</div>
<div className="text-white/50 text-xs truncate max-w-64">
{selectedTrack.artist}
</div>
</div>
)}
</div>
);
}
-1
View File
@@ -255,7 +255,6 @@ export function TopBar() {
title="Notifications"
>
<Bell className="w-5 h-5" />
{/* TODO: Add notification badge in Phase 3 */}
</button>
</>
) : (
-243
View File
@@ -1,243 +0,0 @@
"use client";
import { useRef, useEffect, useMemo, useCallback } from "react";
import * as THREE from "three";
import { ThreeEvent } from "@react-three/fiber";
import type { MapTrack } from "./types";
import { getTrackColor, getTrackHighlightColor, computeEdges } from "./universeUtils";
function hashToFloat(str: string): number {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
return ((h & 0x7fffffff) / 0x7fffffff) * 2 - 1;
}
interface TrackCloudProps {
tracks: MapTrack[];
highlightedIds: Set<string>;
selectedTrackId: string | null;
onTrackClick: (trackId: string) => void;
onTrackHover: (track: MapTrack | null, point: THREE.Vector3 | null) => void;
}
const WORLD_SCALE = 400;
const vertexShader = `
attribute float size;
attribute vec3 customColor;
attribute float opacity;
varying vec3 vColor;
varying float vOpacity;
void main() {
vColor = customColor;
vOpacity = opacity;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (800.0 / -mvPosition.z);
gl_PointSize = clamp(gl_PointSize, 3.0, 64.0);
gl_Position = projectionMatrix * mvPosition;
}
`;
const fragmentShader = `
varying vec3 vColor;
varying float vOpacity;
void main() {
float d = length(gl_PointCoord - vec2(0.5));
if (d > 0.5) discard;
// Solid digital planet -- depth shading with crisp rim
float edge = 1.0 - smoothstep(0.44, 0.5, d);
float shade = 1.0 - d * 0.4;
float rim = smoothstep(0.35, 0.46, d) * edge;
vec3 color = vColor * (shade + rim * 0.3);
float alpha = edge * vOpacity;
gl_FragColor = vec4(color, alpha);
}
`;
export function TrackCloud({
tracks,
highlightedIds,
selectedTrackId,
onTrackClick,
onTrackHover,
}: TrackCloudProps) {
const pointsRef = useRef<THREE.Points>(null!);
const linesRef = useRef<THREE.LineSegments>(null!);
const hasHighlights = highlightedIds.size > 0;
// Compute positions once (shared between points and lines)
const positions = useMemo(() => {
const pos = new Float32Array(tracks.length * 3);
for (let i = 0; i < tracks.length; i++) {
pos[i * 3] = tracks[i].x * WORLD_SCALE;
pos[i * 3 + 1] = tracks[i].y * WORLD_SCALE;
pos[i * 3 + 2] = hashToFloat(tracks[i].id) * WORLD_SCALE * 0.2;
}
return pos;
}, [tracks]);
// Points geometry and material
const { pointGeo, pointMat } = useMemo(() => {
const geo = new THREE.BufferGeometry();
const colors = new Float32Array(tracks.length * 3);
const sizes = new Float32Array(tracks.length);
const opacities = new Float32Array(tracks.length);
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
const color = getTrackColor(track);
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
const energy = track.energy ?? 0.5;
sizes[i] = 8.0 + energy * 12.0;
opacities[i] = 0.85;
}
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geo.setAttribute("customColor", new THREE.BufferAttribute(colors, 3));
geo.setAttribute("size", new THREE.BufferAttribute(sizes, 1));
geo.setAttribute("opacity", new THREE.BufferAttribute(opacities, 1));
const mat = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.NormalBlending,
});
return { pointGeo: geo, pointMat: mat };
}, [tracks, positions]);
// Connection lines geometry
const { lineGeo, lineMat } = useMemo(() => {
const edges = computeEdges(tracks, 3);
const linePositions = new Float32Array(edges.length * 6);
const lineColors = new Float32Array(edges.length * 6);
for (let e = 0; e < edges.length; e++) {
const [i, j] = edges[e];
linePositions[e * 6] = positions[i * 3];
linePositions[e * 6 + 1] = positions[i * 3 + 1];
linePositions[e * 6 + 2] = positions[i * 3 + 2];
linePositions[e * 6 + 3] = positions[j * 3];
linePositions[e * 6 + 4] = positions[j * 3 + 1];
linePositions[e * 6 + 5] = positions[j * 3 + 2];
// Blend colors of both endpoints, very dim
const ci = getTrackColor(tracks[i]);
const cj = getTrackColor(tracks[j]);
const lr = (ci.r + cj.r) * 0.5;
const lg = (ci.g + cj.g) * 0.5;
const lb = (ci.b + cj.b) * 0.5;
lineColors[e * 6] = lr;
lineColors[e * 6 + 1] = lg;
lineColors[e * 6 + 2] = lb;
lineColors[e * 6 + 3] = lr;
lineColors[e * 6 + 4] = lg;
lineColors[e * 6 + 5] = lb;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(linePositions, 3));
geo.setAttribute("color", new THREE.BufferAttribute(lineColors, 3));
const mat = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.25,
blending: THREE.NormalBlending,
});
return { lineGeo: geo, lineMat: mat };
}, [tracks, positions]);
// Update colors/opacity when highlights or selection change
useEffect(() => {
if (!pointGeo || tracks.length === 0) return;
const colors = pointGeo.getAttribute("customColor") as THREE.BufferAttribute;
const opacities = pointGeo.getAttribute("opacity") as THREE.BufferAttribute;
const sizes = pointGeo.getAttribute("size") as THREE.BufferAttribute;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
const isHighlighted = !hasHighlights || highlightedIds.has(track.id);
const isSelected = track.id === selectedTrackId;
const energy = track.energy ?? 0.5;
if (isSelected) {
colors.setXYZ(i, 0.9, 0.9, 0.9);
opacities.setX(i, 1.0);
sizes.setX(i, (8.0 + energy * 12.0) * 1.8);
} else if (isHighlighted) {
const c = getTrackHighlightColor(track);
colors.setXYZ(i, c.r, c.g, c.b);
opacities.setX(i, 0.9);
sizes.setX(i, 8.0 + energy * 12.0);
} else {
const c = getTrackColor(track);
colors.setXYZ(i, c.r * 0.4, c.g * 0.4, c.b * 0.4);
opacities.setX(i, 0.25);
sizes.setX(i, (8.0 + energy * 12.0) * 0.6);
}
}
colors.needsUpdate = true;
opacities.needsUpdate = true;
sizes.needsUpdate = true;
}, [tracks, highlightedIds, selectedTrackId, hasHighlights, pointGeo]);
const handleClick = useCallback((e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
if (e.index !== undefined && e.index < tracks.length) {
onTrackClick(tracks[e.index].id);
}
}, [tracks, onTrackClick]);
const handlePointerOver = useCallback((e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
if (e.index !== undefined && e.index < tracks.length) {
const track = tracks[e.index];
const point = new THREE.Vector3(
track.x * WORLD_SCALE,
track.y * WORLD_SCALE,
hashToFloat(track.id) * WORLD_SCALE * 0.2
);
onTrackHover(track, point);
}
}, [tracks, onTrackHover]);
const handlePointerOut = useCallback(() => {
onTrackHover(null, null);
}, [onTrackHover]);
if (tracks.length === 0) return null;
return (
<group>
{/* Connection lines (rendered behind points) */}
<lineSegments
ref={linesRef}
geometry={lineGeo}
material={lineMat}
/>
{/* Track points */}
<points
ref={pointsRef}
geometry={pointGeo}
material={pointMat}
onClick={handleClick}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
/>
</group>
);
}
export { WORLD_SCALE };
-30
View File
@@ -1,30 +0,0 @@
"use client";
import { Html } from "@react-three/drei";
import * as THREE from "three";
import type { MapTrack } from "./types";
interface TrackTooltipProps {
track: MapTrack;
position: THREE.Vector3;
}
export function TrackTooltip({ track, position }: TrackTooltipProps) {
return (
<Html
position={position}
center
style={{ pointerEvents: "none" }}
zIndexRange={[50, 0]}
>
<div className="bg-black/80 backdrop-blur-sm border border-white/10 rounded-lg px-3 py-1.5 text-center whitespace-nowrap">
<div className="text-white text-xs font-medium truncate max-w-48">
{track.title}
</div>
<div className="text-white/50 text-[10px] truncate max-w-48">
{track.artist}
</div>
</div>
</Html>
);
}
-313
View File
@@ -1,313 +0,0 @@
"use client";
import { useState, useCallback, useMemo, useRef, useEffect, Suspense } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
OrthographicCamera,
PerspectiveCamera,
OrbitControls,
PointerLockControls,
} from "@react-three/drei";
import * as THREE from "three";
import type { MapTrack } from "./types";
import { TrackCloud, WORLD_SCALE } from "./TrackCloud";
import { TrackTooltip } from "./TrackTooltip";
interface VibeUniverseProps {
tracks: MapTrack[];
highlightedIds: Set<string>;
selectedTrackId: string | null;
onTrackClick: (trackId: string) => void;
onBackgroundClick: () => void;
}
function useIsMobile(): boolean {
if (typeof window === "undefined") return false;
return window.innerWidth < 768;
}
function FlyMovement({ speed = 30 }: { speed?: number }) {
const { camera } = useThree();
const keys = useRef<Set<string>>(new Set());
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
keys.current.add(e.code);
};
const handleKeyUp = (e: KeyboardEvent) => {
keys.current.delete(e.code);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
useFrame((_, delta) => {
const velocity = new THREE.Vector3();
const boost = keys.current.has("KeyR") ? 3 : 1;
const actualSpeed = speed * boost;
if (keys.current.has("KeyW") || keys.current.has("ArrowUp")) velocity.z -= 1;
if (keys.current.has("KeyS") || keys.current.has("ArrowDown")) velocity.z += 1;
if (keys.current.has("KeyA") || keys.current.has("ArrowLeft")) velocity.x -= 1;
if (keys.current.has("KeyD") || keys.current.has("ArrowRight")) velocity.x += 1;
if (keys.current.has("Space")) velocity.y += 1;
if (keys.current.has("ShiftLeft") || keys.current.has("ShiftRight")) velocity.y -= 1;
if (velocity.length() > 0) {
velocity.normalize().multiplyScalar(actualSpeed * delta);
velocity.applyQuaternion(camera.quaternion);
camera.position.add(velocity);
}
});
return null;
}
function TronGrid({ worldCenter }: { worldCenter: readonly [number, number, number] }) {
const material = useMemo(() => {
const halfSize = WORLD_SCALE * 2.0;
return new THREE.ShaderMaterial({
uniforms: {
uCenter: { value: new THREE.Vector3(worldCenter[0], worldCenter[1], 0) },
uGridSpacing: { value: WORLD_SCALE * 0.06 },
uHalfSize: { value: halfSize },
uColorA: { value: new THREE.Color(168 / 255, 85 / 255, 247 / 255) },
uColorB: { value: new THREE.Color(252 / 255, 162 / 255, 0) },
},
vertexShader: `
varying vec3 vWorldPos;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPos = worldPos.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uCenter;
uniform float uGridSpacing;
uniform float uHalfSize;
uniform vec3 uColorA;
uniform vec3 uColorB;
varying vec3 vWorldPos;
void main() {
vec2 cXY = vWorldPos.xy / uGridSpacing;
vec2 gXY = abs(fract(cXY - 0.5) - 0.5) / fwidth(cXY);
float lXY = min(gXY.x, gXY.y);
vec2 cXZ = vWorldPos.xz / uGridSpacing;
vec2 gXZ = abs(fract(cXZ - 0.5) - 0.5) / fwidth(cXZ);
float lXZ = min(gXZ.x, gXZ.y);
vec2 cYZ = vWorldPos.yz / uGridSpacing;
vec2 gYZ = abs(fract(cYZ - 0.5) - 0.5) / fwidth(cYZ);
float lYZ = min(gYZ.x, gYZ.y);
float line = min(lXY, min(lXZ, lYZ));
float alpha = 1.0 - min(line, 1.0);
float t = smoothstep(-uHalfSize, uHalfSize, vWorldPos.y - uCenter.y);
vec3 color = mix(uColorA, uColorB, t);
float dist = length(vWorldPos - uCenter) / (uHalfSize * 1.2);
alpha *= smoothstep(1.0, 0.3, dist);
alpha *= 0.08;
gl_FragColor = vec4(color, alpha);
}
`,
transparent: true,
depthWrite: false,
side: THREE.BackSide,
});
}, [worldCenter]);
const boxSize = WORLD_SCALE * 4;
return (
<mesh
position={[worldCenter[0], worldCenter[1], 0]}
material={material}
>
<boxGeometry args={[boxSize, boxSize, boxSize]} />
</mesh>
);
}
function SceneContent({
tracks,
highlightedIds,
selectedTrackId,
is3D,
isMobile: _isMobile,
isLocked,
onLockChange,
onTrackClick,
onBackgroundClick: _onBackgroundClick,
}: VibeUniverseProps & {
is3D: boolean;
isMobile: boolean;
isLocked: boolean;
onLockChange: (locked: boolean) => void;
}) {
const [hoveredTrack, setHoveredTrack] = useState<MapTrack | null>(null);
const [hoverPosition, setHoverPosition] = useState<THREE.Vector3 | null>(null);
const handleTrackHover = useCallback((track: MapTrack | null, point: THREE.Vector3 | null) => {
setHoveredTrack(track);
setHoverPosition(point);
}, []);
const { center, span } = useMemo(() => {
if (tracks.length === 0) {
return { center: [0.5, 0.5] as const, span: 1 };
}
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const t of tracks) {
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
return {
center: [(minX + maxX) / 2, (minY + maxY) / 2] as const,
span: Math.max(maxX - minX, maxY - minY) || 1,
};
}, [tracks]);
const worldCenter = useMemo(
() => [center[0] * WORLD_SCALE, center[1] * WORLD_SCALE, 0] as const,
[center]
);
const handleLock = useCallback(() => onLockChange(true), [onLockChange]);
const handleUnlock = useCallback(() => onLockChange(false), [onLockChange]);
// 2D zoom: fit all tracks in view with padding
const orthoZoom = useMemo(() => {
if (typeof window === "undefined") return 2;
const viewportMin = Math.min(window.innerWidth, window.innerHeight);
const worldSpan = span * WORLD_SCALE;
return viewportMin / (worldSpan * 1.3);
}, [span]);
return (
<>
{is3D ? (
<>
<PerspectiveCamera
makeDefault
position={[worldCenter[0], worldCenter[1], WORLD_SCALE * span * 0.6]}
fov={60}
near={0.1}
far={WORLD_SCALE * 5}
/>
<PointerLockControls onLock={handleLock} onUnlock={handleUnlock} />
<FlyMovement speed={WORLD_SCALE * 0.08} />
</>
) : (
<>
<OrthographicCamera
makeDefault
position={[worldCenter[0], worldCenter[1], 100]}
zoom={orthoZoom}
near={0.1}
far={WORLD_SCALE * 5}
/>
<OrbitControls
enableRotate={false}
enableDamping
dampingFactor={0.12}
target={[worldCenter[0], worldCenter[1], 0]}
/>
</>
)}
{/* Tron-style background grid */}
<TronGrid worldCenter={worldCenter} />
<TrackCloud
tracks={tracks}
highlightedIds={highlightedIds}
selectedTrackId={selectedTrackId}
onTrackClick={onTrackClick}
onTrackHover={handleTrackHover}
/>
{hoveredTrack && hoverPosition && !isLocked && (
<TrackTooltip track={hoveredTrack} position={hoverPosition} />
)}
</>
);
}
export function VibeUniverse({
tracks,
highlightedIds,
selectedTrackId,
onTrackClick,
onBackgroundClick,
}: VibeUniverseProps) {
const [is3D, setIs3D] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const isMobile = useIsMobile();
return (
<div className="w-full h-full relative">
<Canvas
dpr={[1, 1.5]}
gl={{
antialias: true,
toneMapping: THREE.NoToneMapping,
outputColorSpace: THREE.SRGBColorSpace,
powerPreference: "high-performance",
}}
style={{ background: "#050508" }}
onPointerMissed={onBackgroundClick}
>
<Suspense fallback={null}>
<SceneContent
tracks={tracks}
highlightedIds={highlightedIds}
selectedTrackId={selectedTrackId}
is3D={is3D}
isMobile={isMobile}
isLocked={isLocked}
onLockChange={setIsLocked}
onTrackClick={onTrackClick}
onBackgroundClick={onBackgroundClick}
/>
</Suspense>
</Canvas>
{/* 2D / 3D toggle */}
<div className="absolute top-4 right-4 z-10 flex gap-2">
<button
onClick={() => setIs3D(!is3D)}
className="px-3 py-1.5 rounded-lg backdrop-blur-md border text-xs font-medium transition-colors bg-white/10 border-white/10 text-white/70 hover:text-white hover:bg-white/15"
>
{is3D ? "2D" : "3D"}
</button>
</div>
{/* 3D mode instructions */}
{is3D && !isLocked && (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<div className="text-center pointer-events-auto">
<p className="text-white/60 text-sm mb-1">Click anywhere to explore</p>
<p className="text-white/30 text-xs">WASD to move -- Mouse to look -- R for boost -- ESC to exit</p>
</div>
</div>
)}
{/* Track count */}
<div className="absolute bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-[max(0.75rem,env(safe-area-inset-left))] z-10 text-white/15 text-[10px] tracking-widest uppercase font-medium">
{tracks.length} tracks
</div>
</div>
);
}
-96
View File
@@ -1,96 +0,0 @@
import * as THREE from "three";
import type { MapTrack } from "./types";
import { blendMoodColorRGB } from "./mapUtils";
// Three.js renders in linear light -- use a higher saturation boost (2.0) than
// the Deck.gl sRGB path (1.6) to compensate for the linearization difference.
function blendMoodColor(track: MapTrack): [number, number, number] {
return blendMoodColorRGB(track, 2.0);
}
/** Returns a Three.js Color for a track, normalized to 0-1 range. */
export function getTrackThreeColor(track: MapTrack): THREE.Color {
const [r, g, b] = blendMoodColor(track);
return new THREE.Color(r / 255, g / 255, b / 255);
}
/**
* Returns a subdued color for the Tron aesthetic. Base brightness 0.15-0.35,
* with energy adding a subtle lift. No HDR -- bloom is handled separately.
*/
export function getTrackColor(track: MapTrack): THREE.Color {
const [r, g, b] = blendMoodColor(track);
const energy = track.energy ?? 0.5;
const brightness = 0.25 + energy * 0.3;
return new THREE.Color(
(r / 255) * brightness,
(g / 255) * brightness,
(b / 255) * brightness
);
}
/** Returns a brighter variant for selected/highlighted tracks. */
export function getTrackHighlightColor(track: MapTrack): THREE.Color {
const [r, g, b] = blendMoodColor(track);
return new THREE.Color(r / 255 * 0.7, g / 255 * 0.7, b / 255 * 0.7);
}
/** Compute edges connecting each track to its K spatially nearest neighbors. */
export function computeEdges(
tracks: MapTrack[],
k = 3
): Array<[number, number]> {
const edges = new Set<string>();
const result: Array<[number, number]> = [];
for (let i = 0; i < tracks.length; i++) {
const dists: Array<{ j: number; d: number }> = [];
for (let j = 0; j < tracks.length; j++) {
if (i === j) continue;
const dx = tracks[i].x - tracks[j].x;
const dy = tracks[i].y - tracks[j].y;
dists.push({ j, d: dx * dx + dy * dy });
}
dists.sort((a, b) => a.d - b.d);
for (let n = 0; n < Math.min(k, dists.length); n++) {
const j = dists[n].j;
const key = i < j ? `${i}-${j}` : `${j}-${i}`;
if (!edges.has(key)) {
edges.add(key);
result.push([i, j]);
}
}
}
return result;
}
/**
* Computes bounding sphere for a set of tracks (for zoom-to-cluster).
* Coordinates are in raw 0-1 space -- caller must scale if needed.
*/
export function computeClusterBounds(
tracks: MapTrack[],
trackIds: Set<string>
): { center: THREE.Vector3; radius: number } {
const points: THREE.Vector3[] = [];
for (const t of tracks) {
if (trackIds.has(t.id)) {
points.push(new THREE.Vector3(t.x, t.y, 0));
}
}
if (points.length === 0) {
return { center: new THREE.Vector3(0.5, 0.5, 0), radius: 0.5 };
}
const center = new THREE.Vector3();
for (const p of points) center.add(p);
center.divideScalar(points.length);
let maxDist = 0;
for (const p of points) {
const d = center.distanceTo(p);
if (d > maxDist) maxDist = d;
}
return { center, radius: Math.max(maxDist * 1.3, 0.05) };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-frontend",
"version": "1.6.4",
"version": "1.7.0",
"description": "Kima web frontend",
"license": "GPL-3.0",
"repository": {
+185
View File
@@ -0,0 +1,185 @@
import { test, expect } from "@playwright/test";
import { loginAsTestUser, getAuthToken } from "./fixtures/test-helpers";
// ---------------------------------------------------------------------------
// Enrichment cycle test
//
// Wipes all enrichment data, triggers a full re-enrich, and verifies the
// system is functional afterward. Intended to catch:
// - Enrichment correctness regressions (wrong counts, missing embeddings)
// - Memory leaks introduced by the enrichment pipeline
//
// Skips gracefully when the library has fewer than 10 tracks (CI containers
// have no music mount).
//
// For memory monitoring run this spec via the companion shell script:
// bash scripts/run-enrichment-memory-test.sh
// ---------------------------------------------------------------------------
const POLL_INTERVAL_MS = 10_000;
const ENRICHMENT_TIMEOUT_MS = 45 * 60 * 1000; // 45 minutes
type EnrichmentStatus = {
status: string;
currentPhase: string | null;
completionNotificationSent: boolean;
tracks: { total: number; completed: number; failed: number };
artists: { total: number; completed: number; failed: number };
audio: { total: number; completed: number; failed: number; processing: number };
};
/** Poll /api/enrichment/status until idle+complete or timeout.
* Returns the final status object. */
async function waitForEnrichment(
page: Parameters<typeof loginAsTestUser>[0],
token: string,
timeoutMs: number,
): Promise<EnrichmentStatus> {
const deadline = Date.now() + timeoutMs;
let last: EnrichmentStatus | null = null;
while (Date.now() < deadline) {
await page.waitForTimeout(POLL_INTERVAL_MS);
try {
const res = await page.request.get("/api/enrichment/status", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) continue;
const s = (await res.json()) as EnrichmentStatus;
last = s;
const elapsed = Math.round((Date.now() - (deadline - timeoutMs)) / 1000);
const pct =
s.tracks.total > 0
? Math.round((s.tracks.completed / s.tracks.total) * 100)
: 0;
const phase = s.currentPhase ? ` phase=${s.currentPhase}` : "";
console.log(
`[${elapsed}s] status=${s.status}${phase} | ` +
`tracks=${s.tracks.completed}/${s.tracks.total} (${pct}%) | ` +
`artists=${s.artists.completed}/${s.artists.total} | ` +
`audio=${s.audio.completed}/${s.audio.total}`,
);
if (s.status === "idle" && s.completionNotificationSent) {
return s;
}
} catch {
// transient error -- keep polling
}
}
throw new Error(
`Enrichment did not complete within ${timeoutMs / 60_000} minutes. ` +
`Last status: ${JSON.stringify(last)}`,
);
}
// ---------------------------------------------------------------------------
test.describe("Enrichment Cycle", () => {
test.beforeEach(async ({ page }) => {
await loginAsTestUser(page);
});
test("wipe and re-enrich: system is functional after full enrichment cycle", async ({ page }) => {
test.setTimeout(55 * 60 * 1000); // 55-minute per-test timeout
const token = await getAuthToken(page);
// Stop any currently running enrichment before wiping
await page.request.post("/api/enrichment/stop", {
headers: { Authorization: `Bearer ${token}` },
});
await page.waitForTimeout(2_000);
// Wipe all enrichment data
const resetRes = await page.request.post("/api/enrichment/reset-all", {
headers: { Authorization: `Bearer ${token}` },
});
expect(resetRes.ok()).toBe(true);
const resetData = (await resetRes.json()) as { tracksReset: number; artistsReset: number };
if (resetData.tracksReset < 10) {
test.skip(
true,
`Library too small (${resetData.tracksReset} tracks) -- skipping enrichment cycle (empty container)`,
);
return;
}
console.log(
`Reset complete: ${resetData.tracksReset} tracks, ${resetData.artistsReset} artists cleared`,
);
// Confirm completionNotificationSent is now falsy (null or false after reset)
const statusAfterReset = await page.request.get("/api/enrichment/status", {
headers: { Authorization: `Bearer ${token}` },
});
const resetStatus = (await statusAfterReset.json()) as EnrichmentStatus;
expect(resetStatus.completionNotificationSent).toBeFalsy();
// Start full enrichment
const startRes = await page.request.post("/api/enrichment/full", {
headers: { Authorization: `Bearer ${token}` },
});
expect(startRes.ok()).toBe(true);
console.log("Full enrichment started");
// Poll until enrichment completes
const finalStatus = await waitForEnrichment(page, token, ENRICHMENT_TIMEOUT_MS);
console.log(
`Enrichment complete: tracks ${finalStatus.tracks.completed}/${finalStatus.tracks.total} | ` +
`audio ${finalStatus.audio.completed}/${finalStatus.audio.total} | ` +
`failures ${finalStatus.tracks.failed + finalStatus.artists.failed}`,
);
// ---- Functional assertions after enrichment -------------------------
// 1. At least 80% of tracks should have completed enrichment
const trackSuccessRate =
finalStatus.tracks.total > 0
? finalStatus.tracks.completed / finalStatus.tracks.total
: 0;
expect(trackSuccessRate).toBeGreaterThanOrEqual(0.8);
// 2. Vibe map should return embedded tracks
const vibeRes = await page.request.get("/api/vibe/map", {
headers: { Authorization: `Bearer ${token}` },
});
expect(vibeRes.ok()).toBe(true);
const vibeData = (await vibeRes.json()) as { tracks: unknown[]; trackCount: number };
expect(Array.isArray(vibeData.tracks)).toBe(true);
expect(vibeData.tracks.length).toBeGreaterThan(0);
// 3. Vibe search should return results for at least one music descriptor.
// "music" is too generic (below the 0.4 similarity threshold), so probe
// several descriptors and require at least one to return results.
const searchCandidates = ["rock", "pop", "electronic", "loud", "bright", "guitar", "fast", "sad", "piano"];
let searchHit = false;
for (const q of searchCandidates) {
const r = await page.request.post("/api/vibe/search", {
data: { query: q, limit: 3 },
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok()) continue;
const d = (await r.json()) as { tracks: unknown[] };
if (d.tracks.length > 0) { searchHit = true; break; }
}
expect(searchHit).toBe(true);
// 4. Failure rate should be below 20%
const failRes = await page.request.get("/api/enrichment/failures/counts", {
headers: { Authorization: `Bearer ${token}` },
});
if (failRes.ok()) {
const fails = (await failRes.json()) as Record<string, number>;
const totalFails = Object.values(fails).reduce((a, b) => a + b, 0);
const failRate = resetData.tracksReset > 0 ? totalFails / resetData.tracksReset : 0;
expect(failRate).toBeLessThan(0.2);
}
});
});
+9 -3
View File
@@ -1,4 +1,4 @@
import { Page, TestInfo } from "@playwright/test";
import { Page, TestInfo, test } from "@playwright/test";
function requireEnv(name: string): string {
const value = process.env[name];
@@ -43,11 +43,17 @@ export async function waitForApiHealth(page: Page, timeoutMs = 30000): Promise<v
throw new Error("API health check timed out");
}
/** Navigate to the first available album and start playing all tracks. */
/** Navigate to the first available album and start playing all tracks.
* Skips gracefully if the library has no music (e.g., bare CI container). */
export async function startPlayingFirstAlbum(page: Page): Promise<void> {
await page.goto("/collection?tab=albums");
const firstAlbum = page.locator('a[href^="/album/"]').first();
await firstAlbum.waitFor({ timeout: 10_000 });
try {
await firstAlbum.waitFor({ timeout: 10_000 });
} catch {
test.skip(true, "No music in library -- skipping (empty container)");
return;
}
await firstAlbum.click();
await page.waitForURL(/\/album\//);
await page.getByLabel("Play all").click();
+18 -6
View File
@@ -16,27 +16,39 @@ test.describe("Library", () => {
await page.goto("/collection?tab=albums");
await expect(page.getByRole("heading", { name: /collection/i })).toBeVisible();
// Should have at least one album link
const albumLinks = page.locator('a[href^="/album/"]');
await expect(albumLinks.first()).toBeVisible({ timeout: 10000 });
try {
await albumLinks.first().waitFor({ timeout: 8_000 });
} catch {
test.skip(true, "No albums in library -- skipping"); return;
}
await expect(albumLinks.first()).toBeVisible();
});
test("artists tab shows artist list", async ({ page }) => {
await page.goto("/collection?tab=artists");
await expect(page.getByRole("heading", { name: /collection/i })).toBeVisible();
// Should have at least one artist link
const artistLinks = page.locator('a[href^="/artist/"]');
await expect(artistLinks.first()).toBeVisible({ timeout: 10000 });
try {
await artistLinks.first().waitFor({ timeout: 8_000 });
} catch {
test.skip(true, "No artists in library -- skipping"); return;
}
await expect(artistLinks.first()).toBeVisible();
});
test("tracks tab shows track list", async ({ page }) => {
await page.goto("/collection?tab=tracks");
await expect(page.getByRole("heading", { name: /collection/i })).toBeVisible();
// Should have at least one track in the list
const trackRows = page.locator('[data-track-id], [class*="track"]');
await expect(trackRows.first()).toBeVisible({ timeout: 10000 });
try {
await trackRows.first().waitFor({ timeout: 8_000 });
} catch {
test.skip(true, "No tracks in library -- skipping"); return;
}
await expect(trackRows.first()).toBeVisible();
});
test("search page accessible", async ({ page }) => {
+285
View File
@@ -0,0 +1,285 @@
import { test, expect } from "@playwright/test";
import { loginAsTestUser, getAuthToken } from "./fixtures/test-helpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Returns the first two track IDs that have vibe embeddings, or null if none. */
async function getVibeTrackIds(page: Parameters<typeof loginAsTestUser>[0]): Promise<[string, string] | null> {
try {
const token = await getAuthToken(page);
const res = await page.request.get("/api/vibe/map", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) return null;
const data = await res.json() as { tracks?: Array<{ id: string; title: string; artist: string }> };
const tracks = data.tracks ?? [];
if (tracks.length < 2) return null;
return [tracks[0].id, tracks[tracks.length - 1].id];
} catch {
return null;
}
}
/**
* Finds two distinct vibe search queries (music descriptors) that each return
* at least one result. Returns null if the library lacks sufficient embeddings.
*/
async function getTwoVibeSearchQueries(page: Parameters<typeof loginAsTestUser>[0]): Promise<[string, string] | null> {
const candidates = ["rock", "pop", "electronic", "bright", "run", "soft", "dark", "sad", "piano"];
const token = await getAuthToken(page);
const working: string[] = [];
for (const q of candidates) {
if (working.length >= 2) break;
try {
const r = await page.request.post("/api/vibe/search", {
data: { query: q, limit: 5 },
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok()) continue;
const d = await r.json() as { tracks: unknown[] };
if (d.tracks.length > 0) working.push(q);
} catch {
// skip
}
}
return working.length >= 2 ? [working[0], working[1]] : null;
}
// ---------------------------------------------------------------------------
test.describe("Vibe", () => {
test.beforeEach(async ({ page }) => {
await loginAsTestUser(page);
});
// ---- Page load ----------------------------------------------------------
test("vibe page renders canvas or no-data state", async ({ page }) => {
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
// Either a canvas (map rendered) or the no-data placeholder must appear
const canvas = page.locator("canvas");
const noData = page.locator("text=/No tracks with vibe|Computing music map/i");
await Promise.race([
canvas.waitFor({ timeout: 35_000 }),
noData.waitFor({ timeout: 35_000 }),
]);
const hasCanvas = (await canvas.count()) > 0;
const hasNoData = (await noData.count()) > 0;
expect(hasCanvas || hasNoData).toBe(true);
});
test("toolbar buttons are present when map loads", async ({ page }) => {
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
const canvas = page.locator("canvas").first();
const noData = page.locator("text=/No tracks with vibe/i").first();
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
if ((await noData.count()) > 0) {
test.skip();
return;
}
await expect(page.locator('[title="Drift -- journey between two tracks"]')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('[title="Blend -- mix tracks to find new vibes"]')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('[aria-label="Search tracks or artists"]')).toBeVisible({ timeout: 5_000 });
});
// ---- API contract -------------------------------------------------------
test("GET /api/vibe/map returns valid structure", async ({ page }) => {
const token = await getAuthToken(page);
const res = await page.request.get("/api/vibe/map", {
headers: { Authorization: `Bearer ${token}` },
});
// 200 with tracks array (even if empty) or 204
if (res.status() === 204) return; // no data yet -- valid
expect(res.ok()).toBe(true);
const data = await res.json() as { tracks: unknown[]; trackCount: number };
expect(Array.isArray(data.tracks)).toBe(true);
expect(typeof data.trackCount).toBe("number");
});
test("GET /api/vibe/similar returns tracks array for a valid id", async ({ page }) => {
const ids = await getVibeTrackIds(page);
if (!ids) { test.skip(); return; }
const token = await getAuthToken(page);
const res = await page.request.get(`/api/vibe/similar/${ids[0]}?limit=10`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBe(true);
const data = await res.json() as { tracks: Array<{ id: string; title: string }> };
expect(Array.isArray(data.tracks)).toBe(true);
expect(data.tracks.length).toBeGreaterThan(0);
// Returned tracks should all have ids and titles
for (const t of data.tracks.slice(0, 5)) {
expect(t.id).toBeTruthy();
expect(t.title).toBeTruthy();
}
});
test("POST /api/vibe/path returns a path with start and end tracks", async ({ page }) => {
const ids = await getVibeTrackIds(page);
if (!ids) { test.skip(); return; }
const token = await getAuthToken(page);
const res = await page.request.post("/api/vibe/path", {
data: { startTrackId: ids[0], endTrackId: ids[1], length: 8, mode: "smooth" },
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBe(true);
const data = await res.json() as {
startTrack: { id: string };
endTrack: { id: string };
path: Array<{ id: string }>;
};
expect(data.startTrack.id).toBe(ids[0]);
expect(data.endTrack.id).toBe(ids[1]);
expect(Array.isArray(data.path)).toBe(true);
});
// ---- Vibe search --------------------------------------------------------
test("vibe search highlights matching tracks", async ({ page }) => {
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
const canvas = page.locator("canvas").first();
const noData = page.locator("text=/No tracks with vibe/i").first();
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
if ((await noData.count()) > 0) { test.skip(); return; }
// Type a query that is likely to match something
const searchInput = page.locator('[aria-label="Search tracks or artists"]');
await searchInput.fill("the");
await page.waitForTimeout(400); // debounce
// Clear search
const clearBtn = page.locator('[aria-label="Clear search"]');
if (await clearBtn.isVisible()) await clearBtn.click();
await page.waitForTimeout(200);
// After clearing, no error -- map is still rendered
await expect(canvas).toBeVisible();
});
// ---- Drift via Song Path form -------------------------------------------
test("Drift button opens song path form", async ({ page }) => {
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
const canvas = page.locator("canvas").first();
const noData = page.locator("text=/No tracks with vibe/i").first();
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
if ((await noData.count()) > 0) { test.skip(); return; }
await page.locator('[title="Drift -- journey between two tracks"]').click();
await expect(page.locator('#path-start')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('#path-end')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('button:has-text("Generate Path")')).toBeVisible();
});
test("Drift song path form: search and select two tracks then generate queue", async ({ page }) => {
// Find queries that produce results in this library
const queries = await getTwoVibeSearchQueries(page);
if (!queries) { test.skip(); return; }
const [startQuery, endQuery] = queries;
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
const canvas = page.locator("canvas").first();
await canvas.waitFor({ timeout: 35_000 });
// Open drift form
await page.locator('[title="Drift -- journey between two tracks"]').click();
await expect(page.locator('#path-start')).toBeVisible({ timeout: 5_000 });
// Search and select start track
const startInput = page.locator('#path-start');
await startInput.click();
await startInput.fill(startQuery);
await page.waitForTimeout(600);
const firstResult = page.locator('.max-h-40 button').first();
await firstResult.waitFor({ timeout: 8_000 });
await firstResult.click();
// Should auto-focus end input
const endInput = page.locator('#path-end');
await endInput.click();
await endInput.fill(endQuery);
await page.waitForTimeout(600);
const endResult = page.locator('.max-h-40 button').first();
await endResult.waitFor({ timeout: 8_000 });
await endResult.click();
// Generate Path button should now be enabled
const generateBtn = page.locator('button:has-text("Generate Path")');
await expect(generateBtn).toBeEnabled({ timeout: 3_000 });
await generateBtn.click();
// The form closes and the path is visualized on the map (canvas still present)
await expect(page.locator('#path-start')).not.toBeVisible({ timeout: 8_000 });
await expect(canvas).toBeVisible();
});
// ---- Blend panel --------------------------------------------------------
test("Blend button opens blend panel", async ({ page }) => {
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
const canvas = page.locator("canvas").first();
const noData = page.locator("text=/No tracks with vibe/i").first();
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
if ((await noData.count()) > 0) { test.skip(); return; }
await page.locator('[title="Blend -- mix tracks to find new vibes"]').click();
await page.waitForTimeout(500);
// Blend (alchemy) panel should appear -- close button has aria-label="Close alchemy"
const closeEl = page.locator('[aria-label="Close alchemy"]').first();
await expect(closeEl).toBeVisible({ timeout: 5_000 });
// Dismiss
if (await closeEl.isVisible()) await closeEl.click();
});
// ---- Map / Galaxy view toggle -------------------------------------------
test("Map and Galaxy view buttons are present and switch view", async ({ page }) => {
await page.goto("/vibe", { waitUntil: "domcontentloaded" });
const canvas = page.locator("canvas").first();
const noData = page.locator("text=/No tracks with vibe/i").first();
await Promise.race([canvas.waitFor({ timeout: 35_000 }), noData.waitFor({ timeout: 35_000 })]);
if ((await noData.count()) > 0) { test.skip(); return; }
// Map button (already active)
const mapBtn = page.locator("button").filter({ hasText: /^Map$/ }).first();
const galaxyBtn = page.locator("button").filter({ hasText: /^Galaxy$/ }).first();
await expect(mapBtn).toBeVisible({ timeout: 5_000 });
await expect(galaxyBtn).toBeVisible({ timeout: 5_000 });
// Switch to Galaxy
await galaxyBtn.click();
await page.waitForTimeout(2_000);
// Canvas should still be present (WebGL scene renders)
await expect(canvas).toBeVisible();
// Switch back to Map
await mapBtn.click();
await page.waitForTimeout(800);
await expect(canvas).toBeVisible();
});
});
+17 -11
View File
@@ -27,17 +27,23 @@ HASH=$(docker exec -e "TEST_PASS=${TEST_PASS}" "${CONTAINER}" bash -c '
"
')
# Upsert the user with the generated hash
docker exec "${CONTAINER}" bash -c "
psql -U kima -d kima -c \"
INSERT INTO \\\"User\\\" (id, username, \\\"passwordHash\\\", role, \\\"onboardingComplete\\\")
VALUES ('e2e_test_user_kima', '${TEST_USER}', '${HASH}', 'user', true)
ON CONFLICT (username) DO UPDATE SET \\\"passwordHash\\\" = EXCLUDED.\\\"passwordHash\\\";
INSERT INTO \\\"UserSettings\\\" (\\\"userId\\\", \\\"playbackQuality\\\", \\\"wifiOnly\\\", \\\"offlineEnabled\\\", \\\"maxCacheSizeMb\\\")
VALUES ('e2e_test_user_kima', 'original', false, false, 10240)
ON CONFLICT (\\\"userId\\\") DO NOTHING;
\"
"
# Write SQL to a temp file inside the container to avoid dollar sign expansion.
# The bcrypt hash contains $2b$10$... which bash would mangle if embedded in
# a double-quoted string passed to docker exec.
docker exec -e "HASH=${HASH}" -e "TEST_USER=${TEST_USER}" "${CONTAINER}" bash -c '
# Heredoc is unquoted so ${TEST_USER} and ${HASH} expand (env vars set via -e above).
# SQL single quotes are plain literals here -- no shell escaping needed.
psql -U kima -d kima <<ENDSQL
INSERT INTO "User" (id, username, "passwordHash", role, "onboardingComplete")
VALUES ('\''e2e_test_user_kima'\'', '\''${TEST_USER}'\'', '\''${HASH}'\'', '\''admin'\'', true)
ON CONFLICT (username) DO UPDATE
SET "passwordHash" = EXCLUDED."passwordHash",
role = EXCLUDED.role;
INSERT INTO "UserSettings" ("userId", "playbackQuality", "wifiOnly", "offlineEnabled", "maxCacheSizeMb")
VALUES ('\''e2e_test_user_kima'\'', '\''original'\'', false, false, 10240)
ON CONFLICT ("userId") DO NOTHING;
ENDSQL
'
echo "[e2e setup] Test user '${TEST_USER}' ready."
echo ""
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Run the enrichment-cycle Playwright test with before/after host memory monitoring.
#
# Usage:
# bash scripts/run-enrichment-memory-test.sh [container-name]
#
# Environment:
# KIMA_TEST_USERNAME -- E2E test user (default: kima_e2e)
# KIMA_TEST_PASSWORD -- E2E test password (required)
# KIMA_UI_BASE_URL -- Base URL (default: http://127.0.0.1:3030)
# KIMA_CONTAINER -- Docker container name (default: kima-test)
# SUNRECLAIM_LIMIT_MB -- Fail if SUnreclaim grows by more than this (default: 1024)
set -euo pipefail
CONTAINER="${KIMA_CONTAINER:-kima-test}"
BASE_URL="${KIMA_UI_BASE_URL:-http://127.0.0.1:3030}"
SUNRECLAIM_LIMIT="${SUNRECLAIM_LIMIT_MB:-1024}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
sunreclaim_mb() {
awk '/^SUnreclaim:/ { printf "%d", $2 / 1024 }' /proc/meminfo
}
container_mem_mb() {
docker stats --no-stream --format "{{.MemUsage}}" "${CONTAINER}" 2>/dev/null \
| awk '{ match($0, /^([0-9.]+)([GMkB]+)/, arr); v=arr[1]; u=arr[2]; if(u~/GiB/) printf "%d", v*1024; else if(u~/MiB/) printf "%d", v; else printf "%d", v/1024 }'
}
# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
if ! docker ps --format "{{.Names}}" | grep -q "^${CONTAINER}$"; then
echo "[error] Container '${CONTAINER}' is not running."
echo " Start it first: see MEMORY.md for the build+run command."
exit 1
fi
if [[ -z "${KIMA_TEST_PASSWORD:-}" ]]; then
echo "[error] KIMA_TEST_PASSWORD is not set."
exit 1
fi
echo "[memory-test] Waiting for health check..."
timeout 30 bash -c "until curl -sf ${BASE_URL}/api/health > /dev/null; do sleep 2; done"
# ---------------------------------------------------------------------------
# Baseline memory snapshot
# ---------------------------------------------------------------------------
SUNRECLAIM_BEFORE=$(sunreclaim_mb)
CONTAINER_MEM_BEFORE=$(container_mem_mb)
echo ""
echo "=========================================================="
echo " Memory baseline"
echo " Host SUnreclaim: ${SUNRECLAIM_BEFORE} MB"
echo " Container RSS: ${CONTAINER_MEM_BEFORE} MB"
echo "=========================================================="
echo ""
# ---------------------------------------------------------------------------
# Run the Playwright enrichment-cycle spec
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="${SCRIPT_DIR}/../frontend"
echo "[memory-test] Running enrichment-cycle spec..."
npx --prefix "${FRONTEND_DIR}" playwright test \
tests/e2e/enrichment-cycle.spec.ts \
--reporter=list
TEST_EXIT=$?
# ---------------------------------------------------------------------------
# Post-test memory snapshot
# ---------------------------------------------------------------------------
SUNRECLAIM_AFTER=$(sunreclaim_mb)
CONTAINER_MEM_AFTER=$(container_mem_mb)
SUNRECLAIM_DELTA=$(( SUNRECLAIM_AFTER - SUNRECLAIM_BEFORE ))
CONTAINER_MEM_DELTA=$(( CONTAINER_MEM_AFTER - CONTAINER_MEM_BEFORE ))
echo ""
echo "=========================================================="
echo " Memory after enrichment cycle"
echo " Host SUnreclaim: ${SUNRECLAIM_AFTER} MB (delta: +${SUNRECLAIM_DELTA} MB)"
echo " Container RSS: ${CONTAINER_MEM_AFTER} MB (delta: +${CONTAINER_MEM_DELTA} MB)"
echo "=========================================================="
echo ""
# ---------------------------------------------------------------------------
# Fail if slab growth exceeds threshold
# ---------------------------------------------------------------------------
if (( SUNRECLAIM_DELTA > SUNRECLAIM_LIMIT )); then
echo "[FAIL] Host SUnreclaim grew by ${SUNRECLAIM_DELTA} MB -- exceeds limit of ${SUNRECLAIM_LIMIT} MB."
echo " This indicates kernel slab (anon_vma_chain) accumulation from the enrichment pipeline."
echo " Check: /proc/slabinfo | grep anon_vma -- if anon_vma_chain is large, suspect"
echo " excessive process forks or repeated VMA splits during audio processing."
exit 1
fi
echo "[memory-test] Slab growth within acceptable range (${SUNRECLAIM_DELTA} MB < ${SUNRECLAIM_LIMIT} MB limit)."
# Propagate Playwright exit code
exit "${TEST_EXIT}"