Ionosphere.tv
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: tests and CI/CD implementation plan

5 tasks: add vitest to frontend, extract pure functions, write unit
tests, create GitHub Actions workflow, verify CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+482
+482
docs/superpowers/plans/2026-03-31-tests-ci.md
··· 1 + # Tests & CI/CD Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Add frontend unit tests for logic-heavy transcript code and a GitHub Actions CI pipeline that runs typecheck + test on every push and PR. 6 + 7 + **Architecture:** Extract pure functions from TranscriptView.tsx into a testable module, test them with Vitest, add CI workflow that runs all workspace tests. 8 + 9 + **Tech Stack:** Vitest, GitHub Actions, pnpm 10 + 11 + **Spec:** `docs/superpowers/specs/2026-03-31-tests-ci-design.md` 12 + 13 + --- 14 + 15 + ## File Map 16 + 17 + ### New files 18 + - `apps/ionosphere/src/lib/transcript.ts` — pure functions extracted from TranscriptView.tsx 19 + - `apps/ionosphere/src/lib/transcript.test.ts` — unit tests 20 + - `apps/ionosphere/vitest.config.ts` — Vitest config for the frontend package 21 + - `.github/workflows/ci.yml` — GitHub Actions workflow 22 + 23 + ### Modified files 24 + - `apps/ionosphere/package.json` — add vitest devDependency and test script 25 + - `apps/ionosphere/src/app/components/TranscriptView.tsx` — import from extracted module instead of inline 26 + 27 + --- 28 + 29 + ## Chunk 1: Frontend Unit Tests 30 + 31 + ### Task 1: Add Vitest to the frontend package 32 + 33 + **Files:** 34 + - Modify: `apps/ionosphere/package.json` 35 + - Create: `apps/ionosphere/vitest.config.ts` 36 + 37 + - [ ] **Step 1: Add vitest devDependency** 38 + 39 + ```bash 40 + cd apps/ionosphere && pnpm add -D vitest 41 + ``` 42 + 43 + - [ ] **Step 2: Create vitest config** 44 + 45 + Create `apps/ionosphere/vitest.config.ts`: 46 + 47 + ```typescript 48 + import { defineConfig } from "vitest/config"; 49 + import path from "node:path"; 50 + 51 + export default defineConfig({ 52 + test: { 53 + include: ["src/**/*.test.ts"], 54 + }, 55 + resolve: { 56 + alias: { 57 + "@": path.resolve(__dirname, "src"), 58 + }, 59 + }, 60 + }); 61 + ``` 62 + 63 + - [ ] **Step 3: Add test script to package.json** 64 + 65 + Add to the `scripts` section: 66 + ```json 67 + "test": "vitest run" 68 + ``` 69 + 70 + - [ ] **Step 4: Verify vitest runs (no tests yet)** 71 + 72 + ```bash 73 + cd apps/ionosphere && pnpm test 74 + ``` 75 + 76 + Expected: "No test files found" or similar — no error. 77 + 78 + - [ ] **Step 5: Commit** 79 + 80 + ```bash 81 + git add apps/ionosphere/package.json apps/ionosphere/vitest.config.ts pnpm-lock.yaml 82 + git commit -m "chore: add vitest to frontend package" 83 + ``` 84 + 85 + ### Task 2: Extract pure functions from TranscriptView 86 + 87 + **Files:** 88 + - Create: `apps/ionosphere/src/lib/transcript.ts` 89 + - Modify: `apps/ionosphere/src/app/components/TranscriptView.tsx` 90 + 91 + - [ ] **Step 1: Create the extracted module** 92 + 93 + Create `apps/ionosphere/src/lib/transcript.ts` with the pure functions from TranscriptView.tsx. Extract these functions and types: 94 + 95 + - `TranscriptFacet` interface 96 + - `TranscriptDocument` interface 97 + - `WordSpan` interface 98 + - `ConceptSpan` interface 99 + - `extractData(doc: TranscriptDocument)` — parses facets into sorted word spans and concept spans with shared boundary times 100 + - `brightnessAtTime(currentTimeNs: number, timeNs: number): number` — brightness falloff calculation 101 + - `toColor(brightness: number, concept: ConceptSpan | null): string` — brightness + concept → CSS color 102 + - Constants: `BASE_BRIGHTNESS`, `PEAK_BRIGHTNESS`, `WINDOW_NS` 103 + 104 + These functions are currently defined inline in TranscriptView.tsx (lines 6-139). Copy them to the new module and export them. 105 + 106 + - [ ] **Step 2: Update TranscriptView.tsx to import from the extracted module** 107 + 108 + Replace the inline definitions with imports: 109 + 110 + ```typescript 111 + import { 112 + extractData, 113 + brightnessAtTime, 114 + toColor, 115 + type TranscriptDocument, 116 + type WordSpan, 117 + type ConceptSpan, 118 + } from "@/lib/transcript"; 119 + ``` 120 + 121 + Remove the inline `extractData`, `brightnessAtTime`, `toColor`, `WordSpan`, `ConceptSpan`, `TranscriptFacet`, `TranscriptDocument` definitions and the constants from TranscriptView.tsx. Keep the React components (`WordSpanComponent`, `TranscriptView`). 122 + 123 + - [ ] **Step 3: Verify the app still builds** 124 + 125 + ```bash 126 + cd apps/ionosphere && npx next build 2>&1 | tail -5 127 + ``` 128 + 129 + This may fail if the appview isn't serving data for SSG, but it should at least get past TypeScript compilation. If it fails on data fetching, that's fine — we're checking that the imports resolve. 130 + 131 + Alternatively, just run typecheck: 132 + ```bash 133 + cd apps/ionosphere && npx tsc --noEmit 134 + ``` 135 + 136 + - [ ] **Step 4: Commit** 137 + 138 + ```bash 139 + git add apps/ionosphere/src/lib/transcript.ts apps/ionosphere/src/app/components/TranscriptView.tsx 140 + git commit -m "refactor: extract pure transcript functions into testable module" 141 + ``` 142 + 143 + ### Task 3: Write transcript unit tests 144 + 145 + **Files:** 146 + - Create: `apps/ionosphere/src/lib/transcript.test.ts` 147 + 148 + - [ ] **Step 1: Write the test file** 149 + 150 + ```typescript 151 + import { describe, it, expect } from "vitest"; 152 + import { 153 + extractData, 154 + brightnessAtTime, 155 + toColor, 156 + BASE_BRIGHTNESS, 157 + PEAK_BRIGHTNESS, 158 + WINDOW_NS, 159 + type TranscriptDocument, 160 + } from "./transcript"; 161 + 162 + // --- Test fixtures --- 163 + 164 + function makeDoc(words: Array<{ text: string; startNs: number; endNs: number }>, concepts?: Array<{ byteStart: number; byteEnd: number; name: string }>): TranscriptDocument { 165 + const encoder = new TextEncoder(); 166 + const text = words.map((w) => w.text).join(" "); 167 + const facets: TranscriptDocument["facets"] = []; 168 + 169 + let searchFrom = 0; 170 + for (const w of words) { 171 + const idx = text.indexOf(w.text, searchFrom); 172 + const byteStart = encoder.encode(text.slice(0, idx)).length; 173 + const byteEnd = encoder.encode(text.slice(0, idx + w.text.length)).length; 174 + 175 + facets.push({ 176 + index: { byteStart, byteEnd }, 177 + features: [{ 178 + $type: "tv.ionosphere.facet#timestamp", 179 + startTime: w.startNs, 180 + endTime: w.endNs, 181 + }], 182 + }); 183 + searchFrom = idx + w.text.length; 184 + } 185 + 186 + if (concepts) { 187 + for (const c of concepts) { 188 + facets.push({ 189 + index: { byteStart: c.byteStart, byteEnd: c.byteEnd }, 190 + features: [{ 191 + $type: "tv.ionosphere.facet#concept-ref", 192 + conceptUri: `at://test/tv.ionosphere.concept/${c.name}`, 193 + conceptRkey: c.name, 194 + conceptName: c.name, 195 + }], 196 + }); 197 + } 198 + } 199 + 200 + return { text, facets }; 201 + } 202 + 203 + // --- extractData --- 204 + 205 + describe("extractData", () => { 206 + it("extracts words sorted by start time", () => { 207 + const doc = makeDoc([ 208 + { text: "Hello", startNs: 0, endNs: 500_000_000 }, 209 + { text: "world", startNs: 500_000_000, endNs: 1_000_000_000 }, 210 + ]); 211 + 212 + const { words } = extractData(doc); 213 + expect(words).toHaveLength(2); 214 + expect(words[0].text).toBe("Hello"); 215 + expect(words[1].text).toBe("world"); 216 + expect(words[0].startTime).toBe(0); 217 + expect(words[1].startTime).toBe(500_000_000); 218 + }); 219 + 220 + it("computes shared boundary times between adjacent words", () => { 221 + const doc = makeDoc([ 222 + { text: "Hello", startNs: 0, endNs: 400_000_000 }, 223 + { text: "world", startNs: 600_000_000, endNs: 1_000_000_000 }, 224 + ]); 225 + 226 + const { words } = extractData(doc); 227 + 228 + // First word: boundaryStart = own startTime, boundaryEnd = midpoint to next 229 + expect(words[0].boundaryStartTime).toBe(0); 230 + expect(words[0].boundaryEndTime).toBe(500_000_000); // (400M + 600M) / 2 231 + 232 + // Second word: boundaryStart = midpoint from prev, boundaryEnd = own endTime 233 + expect(words[1].boundaryStartTime).toBe(500_000_000); 234 + expect(words[1].boundaryEndTime).toBe(1_000_000_000); 235 + 236 + // KEY INVARIANT: word N boundaryEnd === word N+1 boundaryStart 237 + expect(words[0].boundaryEndTime).toBe(words[1].boundaryStartTime); 238 + }); 239 + 240 + it("matches concepts to words by byte range overlap", () => { 241 + const encoder = new TextEncoder(); 242 + // "AT Protocol" — concept covers the whole phrase 243 + const doc = makeDoc([ 244 + { text: "AT", startNs: 0, endNs: 200_000_000 }, 245 + { text: "Protocol", startNs: 200_000_000, endNs: 600_000_000 }, 246 + { text: "rocks", startNs: 700_000_000, endNs: 1_000_000_000 }, 247 + ]); 248 + 249 + const text = "AT Protocol rocks"; 250 + const conceptStart = 0; 251 + const conceptEnd = encoder.encode("AT Protocol").length; 252 + 253 + doc.facets.push({ 254 + index: { byteStart: conceptStart, byteEnd: conceptEnd }, 255 + features: [{ 256 + $type: "tv.ionosphere.facet#concept-ref", 257 + conceptUri: "at://test/tv.ionosphere.concept/at-protocol", 258 + conceptRkey: "at-protocol", 259 + conceptName: "AT Protocol", 260 + }], 261 + }); 262 + 263 + const { wordConcepts } = extractData(doc); 264 + 265 + // "AT" and "Protocol" overlap with the concept 266 + expect(wordConcepts[0]).toHaveLength(1); 267 + expect(wordConcepts[0][0].conceptName).toBe("AT Protocol"); 268 + expect(wordConcepts[1]).toHaveLength(1); 269 + // "rocks" does not 270 + expect(wordConcepts[2]).toHaveLength(0); 271 + }); 272 + 273 + it("handles empty document", () => { 274 + const doc: TranscriptDocument = { text: "", facets: [] }; 275 + const { words, concepts, wordConcepts } = extractData(doc); 276 + expect(words).toHaveLength(0); 277 + expect(concepts).toHaveLength(0); 278 + expect(wordConcepts).toHaveLength(0); 279 + }); 280 + }); 281 + 282 + // --- brightnessAtTime --- 283 + 284 + describe("brightnessAtTime", () => { 285 + it("returns peak brightness at current time", () => { 286 + expect(brightnessAtTime(1_000_000_000, 1_000_000_000)).toBe(PEAK_BRIGHTNESS); 287 + }); 288 + 289 + it("returns base brightness beyond the window", () => { 290 + const current = 5_000_000_000; 291 + const distant = current + WINDOW_NS + 1; 292 + expect(brightnessAtTime(current, distant)).toBe(BASE_BRIGHTNESS); 293 + }); 294 + 295 + it("returns intermediate brightness within the window", () => { 296 + const current = 5_000_000_000; 297 + const halfway = current + WINDOW_NS / 2; 298 + const b = brightnessAtTime(current, halfway); 299 + expect(b).toBeGreaterThan(BASE_BRIGHTNESS); 300 + expect(b).toBeLessThan(PEAK_BRIGHTNESS); 301 + }); 302 + 303 + it("is symmetric around current time", () => { 304 + const current = 5_000_000_000; 305 + const offset = 500_000_000; 306 + expect(brightnessAtTime(current, current + offset)) 307 + .toBe(brightnessAtTime(current, current - offset)); 308 + }); 309 + 310 + it("uses quadratic easing (not linear)", () => { 311 + const current = 5_000_000_000; 312 + const quarter = current + WINDOW_NS / 4; 313 + const half = current + WINDOW_NS / 2; 314 + const bQuarter = brightnessAtTime(current, quarter); 315 + const bHalf = brightnessAtTime(current, half); 316 + 317 + // With quadratic easing, the drop from peak to quarter should be 318 + // less than the drop from quarter to half (steeper falloff further out) 319 + const dropToQuarter = PEAK_BRIGHTNESS - bQuarter; 320 + const dropQuarterToHalf = bQuarter - bHalf; 321 + expect(dropQuarterToHalf).toBeGreaterThan(dropToQuarter); 322 + }); 323 + }); 324 + 325 + // --- toColor --- 326 + 327 + describe("toColor", () => { 328 + it("returns grayscale for non-concept words", () => { 329 + const color = toColor(0.5, null); 330 + expect(color).toMatch(/^rgb\(\d+ \d+ \d+\)$/); 331 + // Grayscale: all three channels equal 332 + const [r, g, b] = color.match(/\d+/g)!.map(Number); 333 + expect(r).toBe(g); 334 + expect(r).toBe(b); 335 + }); 336 + 337 + it("returns amber tint for concept words", () => { 338 + const concept = { 339 + byteStart: 0, 340 + byteEnd: 5, 341 + conceptUri: "at://test/concept/foo", 342 + conceptRkey: "foo", 343 + conceptName: "Foo", 344 + }; 345 + const color = toColor(0.8, concept); 346 + const [r, g, b] = color.match(/\d+/g)!.map(Number); 347 + // Amber: red > green > blue 348 + expect(r).toBeGreaterThan(g); 349 + expect(g).toBeGreaterThan(b); 350 + }); 351 + 352 + it("returns near-gray for dim concepts", () => { 353 + const concept = { 354 + byteStart: 0, 355 + byteEnd: 5, 356 + conceptUri: "at://test/concept/foo", 357 + conceptRkey: "foo", 358 + conceptName: "Foo", 359 + }; 360 + const color = toColor(BASE_BRIGHTNESS, concept); 361 + const [r, g, b] = color.match(/\d+/g)!.map(Number); 362 + // At base brightness, the amber tint should be very subtle 363 + // (channels close together) 364 + expect(Math.abs(r - b)).toBeLessThan(30); 365 + }); 366 + }); 367 + ``` 368 + 369 + - [ ] **Step 2: Run tests — expect failure** 370 + 371 + ```bash 372 + cd apps/ionosphere && pnpm test 373 + ``` 374 + 375 + Expected: FAIL — `./transcript` module doesn't export the expected functions yet (Task 2 may not be done, or the exports don't match). 376 + 377 + - [ ] **Step 3: Fix any import/export mismatches** 378 + 379 + Ensure `transcript.ts` exports match what the tests import. All functions and types listed in the test imports must be exported. 380 + 381 + - [ ] **Step 4: Run tests — expect pass** 382 + 383 + ```bash 384 + cd apps/ionosphere && pnpm test 385 + ``` 386 + 387 + Expected: All tests pass. 388 + 389 + - [ ] **Step 5: Commit** 390 + 391 + ```bash 392 + git add apps/ionosphere/src/lib/transcript.test.ts 393 + git commit -m "test: unit tests for transcript extraction, brightness, and concept overlay" 394 + ``` 395 + 396 + --- 397 + 398 + ## Chunk 2: CI/CD Pipeline 399 + 400 + ### Task 4: Create GitHub Actions workflow 401 + 402 + **Files:** 403 + - Create: `.github/workflows/ci.yml` 404 + 405 + - [ ] **Step 1: Create the workflow file** 406 + 407 + ```yaml 408 + name: CI 409 + 410 + on: 411 + push: 412 + branches: [main] 413 + pull_request: 414 + branches: [main] 415 + 416 + jobs: 417 + check: 418 + runs-on: ubuntu-latest 419 + 420 + steps: 421 + - uses: actions/checkout@v4 422 + 423 + - uses: pnpm/action-setup@v4 424 + with: 425 + version: 10 426 + 427 + - uses: actions/setup-node@v4 428 + with: 429 + node-version: 24 430 + cache: pnpm 431 + 432 + - run: pnpm install --frozen-lockfile 433 + 434 + - name: Typecheck 435 + run: pnpm -r typecheck 436 + 437 + - name: Test 438 + run: pnpm -r test 439 + ``` 440 + 441 + Note: `pnpm -r typecheck` runs `tsc --noEmit` in format and appview packages. The frontend doesn't have a typecheck script yet — add one if it fails, but Next.js projects typically typecheck during `next build`. 442 + 443 + - [ ] **Step 2: Add typecheck script to frontend package.json if missing** 444 + 445 + If `apps/ionosphere/package.json` doesn't have a `typecheck` script, add: 446 + ```json 447 + "typecheck": "tsc --noEmit" 448 + ``` 449 + 450 + The frontend needs a `tsconfig.json` that `tsc --noEmit` can use. Check if one exists — Next.js projects always have one. 451 + 452 + - [ ] **Step 3: Verify the full CI sequence locally** 453 + 454 + ```bash 455 + cd /Users/blainecook/Code/skeetv 456 + pnpm install --frozen-lockfile 457 + pnpm -r typecheck 458 + pnpm -r test 459 + ``` 460 + 461 + Expected: All typecheck and tests pass. The panproto tests will skip (no WASM in CI) but shouldn't fail. 462 + 463 + - [ ] **Step 4: Commit** 464 + 465 + ```bash 466 + git add .github/workflows/ci.yml apps/ionosphere/package.json 467 + git commit -m "ci: add GitHub Actions workflow for typecheck and test" 468 + ``` 469 + 470 + ### Task 5: Verify CI works 471 + 472 + - [ ] **Step 1: Push and check** 473 + 474 + Push the branch (or create a PR) and verify the GitHub Actions workflow runs successfully. 475 + 476 + ```bash 477 + git push origin main 478 + ``` 479 + 480 + Then check: `gh run list --limit 1` 481 + 482 + Expected: Workflow runs, typecheck passes, tests pass (panproto tests skip gracefully).