|
1 | 1 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; |
2 | | -import { existsSync, mkdtempSync, rmSync } from "node:fs"; |
| 2 | +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
3 | 3 | import { tmpdir } from "node:os"; |
4 | | -import { join } from "node:path"; |
| 4 | +import { join, resolve } from "node:path"; |
5 | 5 | import { Hono } from "hono"; |
6 | 6 | import { createPhoneImportApp, createProjectorApp } from "./app"; |
| 7 | +import { TRANSCRIPT_ARCHIVE_DEFAULT_DIR, listDays, localDayKey, readDay } from "./transcript-archive"; |
7 | 8 | import { registerForestSurface, type ForestState, type ForestSurfaceLoader } from "./github-org"; |
8 | 9 | import { RemoteHandsHub } from "./remote-hands"; |
9 | 10 | import { createProjectorRuntime, type ProjectorRuntime, type ProjectorRuntimeOptions } from "./composition"; |
@@ -2632,3 +2633,120 @@ describe("GitHub import → deploy resolver → deployUrl surfaces", () => { |
2632 | 2633 | expect(runtime.snapshot().processes.find((entry) => entry.upid === upid)!.deployUrl).toBeNull(); |
2633 | 2634 | }); |
2634 | 2635 | }); |
| 2636 | + |
| 2637 | +// THE READ-BACK SURFACE. "Get me today's transcript" used to be a bespoke |
| 2638 | +// python pass over a rolling file that had already evicted most of the evening; |
| 2639 | +// the archive answers it with a read. (`bun run transcript` is the same read |
| 2640 | +// without a server, for when the room is down.) |
| 2641 | +describe("GET /api/transcript/*", () => { |
| 2642 | + function seedArchive(lines: { text: string; atMs: number }[]): string { |
| 2643 | + const dir = mkdtempSync(join(tmpdir(), "vibersyn-transcript-")); |
| 2644 | + tempDirs.push(dir); |
| 2645 | + const byDay = new Map<string, string[]>(); |
| 2646 | + for (const entry of lines) { |
| 2647 | + const day = localDayKey(entry.atMs); |
| 2648 | + const body = JSON.stringify({ |
| 2649 | + time: new Date(entry.atMs).toISOString().slice(11, 19), |
| 2650 | + speaker: "speaker_0", |
| 2651 | + text: entry.text, |
| 2652 | + kind: "room", |
| 2653 | + atMs: entry.atMs, |
| 2654 | + }); |
| 2655 | + byDay.set(day, [...(byDay.get(day) ?? []), body]); |
| 2656 | + } |
| 2657 | + mkdirSync(dir, { recursive: true }); |
| 2658 | + for (const [day, bodies] of byDay) { |
| 2659 | + writeFileSync(join(dir, `${day}.jsonl`), `${bodies.join("\n")}\n`); |
| 2660 | + } |
| 2661 | + return dir; |
| 2662 | + } |
| 2663 | + |
| 2664 | + const noonToday = new Date(new Date().setHours(12, 0, 0, 0)).getTime(); |
| 2665 | + const noonYesterday = noonToday - 24 * 60 * 60_000; |
| 2666 | + |
| 2667 | + test("today's lines come back, in spoken order, with their original atMs", async () => { |
| 2668 | + const archiveDir = seedArchive([ |
| 2669 | + { text: "we should build a birdhouse app", atMs: noonToday }, |
| 2670 | + { text: "with a webcam feed", atMs: noonToday + 2_000 }, |
| 2671 | + ]); |
| 2672 | + const { app } = await makeApp({ runtimeOptions: { transcriptArchiveDir: archiveDir } }); |
| 2673 | + const response = await app.request("/api/transcript/today"); |
| 2674 | + expect(response.status).toBe(200); |
| 2675 | + const body = (await response.json()) as { day: string; archiveDir: string; lines: { text: string; atMs: number }[] }; |
| 2676 | + expect(body.archiveDir).toBe(archiveDir); |
| 2677 | + expect(body.lines.map((line) => line.text)).toEqual(["we should build a birdhouse app", "with a webcam feed"]); |
| 2678 | + expect(body.lines[0]!.atMs).toBe(noonToday); |
| 2679 | + }); |
| 2680 | + |
| 2681 | + test("yesterday, and an explicit YYYY-MM-DD, address their own segments", async () => { |
| 2682 | + const archiveDir = seedArchive([ |
| 2683 | + { text: "last night", atMs: noonYesterday }, |
| 2684 | + { text: "this afternoon", atMs: noonToday }, |
| 2685 | + ]); |
| 2686 | + const { app } = await makeApp({ runtimeOptions: { transcriptArchiveDir: archiveDir } }); |
| 2687 | + const yesterday = (await (await app.request("/api/transcript/yesterday")).json()) as { lines: { text: string }[] }; |
| 2688 | + expect(yesterday.lines.map((line) => line.text)).toEqual(["last night"]); |
| 2689 | + const explicit = (await (await app.request(`/api/transcript/${localDayKey(noonToday)}`)).json()) as { lines: { text: string }[] }; |
| 2690 | + expect(explicit.lines.map((line) => line.text)).toEqual(["this afternoon"]); |
| 2691 | + }); |
| 2692 | + |
| 2693 | + test("?format=text renders LOCAL stamps a human can read", async () => { |
| 2694 | + const at = new Date(new Date().setHours(17, 52, 1, 0)).getTime(); |
| 2695 | + const archiveDir = seedArchive([{ text: "not i just", atMs: at }]); |
| 2696 | + const { app } = await makeApp({ runtimeOptions: { transcriptArchiveDir: archiveDir } }); |
| 2697 | + const response = await app.request("/api/transcript/today?format=text"); |
| 2698 | + expect(response.status).toBe(200); |
| 2699 | + expect(await response.text()).toBe("17:52:01 speaker_0: not i just"); |
| 2700 | + }); |
| 2701 | + |
| 2702 | + test("/days lists what the archive holds, oldest first", async () => { |
| 2703 | + const archiveDir = seedArchive([ |
| 2704 | + { text: "last night", atMs: noonYesterday }, |
| 2705 | + { text: "this afternoon", atMs: noonToday }, |
| 2706 | + ]); |
| 2707 | + const { app } = await makeApp({ runtimeOptions: { transcriptArchiveDir: archiveDir } }); |
| 2708 | + const body = (await (await app.request("/api/transcript/days")).json()) as { archiveDir: string; days: string[] }; |
| 2709 | + expect(body.days).toEqual([localDayKey(noonYesterday), localDayKey(noonToday)]); |
| 2710 | + }); |
| 2711 | + |
| 2712 | + // Every failure says something specific. An empty array would read as "we |
| 2713 | + // said nothing", which is the one answer that must never be guessed at. |
| 2714 | + test("a malformed day is 400, a day with no segment is 404, no archive is 503", async () => { |
| 2715 | + const archiveDir = seedArchive([{ text: "this afternoon", atMs: noonToday }]); |
| 2716 | + const { app } = await makeApp({ runtimeOptions: { transcriptArchiveDir: archiveDir } }); |
| 2717 | + const bad = await app.request("/api/transcript/tomorrow"); |
| 2718 | + expect(bad.status).toBe(400); |
| 2719 | + expect((await bad.json()) as { error: string }).toHaveProperty("error"); |
| 2720 | + const missing = await app.request("/api/transcript/2001-01-01"); |
| 2721 | + expect(missing.status).toBe(404); |
| 2722 | + expect(((await missing.json()) as { days: string[] }).days).toEqual([localDayKey(noonToday)]); |
| 2723 | + |
| 2724 | + const { app: noArchive } = await makeApp(); |
| 2725 | + expect((await noArchive.request("/api/transcript/today")).status).toBe(503); |
| 2726 | + expect((await noArchive.request("/api/transcript/days")).status).toBe(503); |
| 2727 | + }); |
| 2728 | +}); |
| 2729 | + |
| 2730 | +// THE DEFAULT-ON GUARD, asserted the way the operator asked: a runtime built |
| 2731 | +// the way TESTS build one must write NOTHING to the default archive path. |
| 2732 | +// Commit 6a1d228 gated the old store behind an env marker because self-mode |
| 2733 | +// test runtimes were polluting the operator's live store; making the archive |
| 2734 | +// default-on at the BOOT ENTRY (src/server/index.ts) keeps that hole shut, and |
| 2735 | +// this test is what proves it stays shut. |
| 2736 | +describe("a test-built runtime keeps no archive", () => { |
| 2737 | + const defaultArchivePath = resolve(process.cwd(), TRANSCRIPT_ARCHIVE_DEFAULT_DIR); |
| 2738 | + |
| 2739 | + test("no archive directory resolves, and the default path is never created", async () => { |
| 2740 | + const before = existsSync(defaultArchivePath) ? listDays(defaultArchivePath).map((day) => `${day}:${readDay(defaultArchivePath, day).lines.length}`) : null; |
| 2741 | + const { runtime } = await makeApp(); |
| 2742 | + expect(runtime.transcriptArchiveDir).toBeNull(); |
| 2743 | + const after = existsSync(defaultArchivePath) ? listDays(defaultArchivePath).map((day) => `${day}:${readDay(defaultArchivePath, day).lines.length}`) : null; |
| 2744 | + expect(after).toEqual(before); |
| 2745 | + }); |
| 2746 | + |
| 2747 | + test("even a SELF-MODE runtime with no directory keeps no archive", async () => { |
| 2748 | + const { runtime } = await makeApp({ runtimeEnv: { VIBERSYN_SELF_MODE: "1" } }); |
| 2749 | + expect(runtime.transcriptArchiveDir).toBeNull(); |
| 2750 | + expect(existsSync(defaultArchivePath) ? listDays(defaultArchivePath) : []).not.toContain("__never__"); |
| 2751 | + }); |
| 2752 | +}); |
0 commit comments