File size: 5,589 Bytes
34810d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// Claw banner tests: static/animated gating and the final-frame invariant.
import { describe, expect, it, vi } from "vitest";
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import type { RuntimeEnv } from "../runtime.js";
import { printClawBanner } from "./claw-banner.js";

const runtimeStub = () => {
  const log = vi.fn();
  return { runtime: { log } as unknown as RuntimeEnv, log };
};

async function runAnimated(rng: () => number) {
  const chunks: string[] = [];
  const { runtime } = runtimeStub();
  await printClawBanner(runtime, {
    columns: 120,
    isTty: true,
    rich: true,
    env: {},
    rng,
    sleep: async () => {},
    write: (chunk) => chunks.push(chunk),
  });
  return chunks;
}

async function runStatic() {
  const { runtime, log } = runtimeStub();
  await printClawBanner(runtime, { columns: 120, isTty: false, env: {} });
  return stripAnsi(String(log.mock.calls[0]?.[0]))
    .split("\n")
    .filter((row) => row.length > 0);
}

const EXPECTED_MASCOT = [
  " ‒●●:.        .:●●‒",
  ":●●●●:        :●●●●:",
  ".●●●●:.:‒●●‒:.:●●●●.",
  " .●●●: ‒●●●●‒ :●●●.",
  " ..:‒‒●●●●●●●●‒‒:..",
  ".::‒‒‒‒●●●●●●‒‒‒‒::.",
  " . .:  ‒●●●●‒  :. .",
  "    .  :●●●●:  .",
  "      .●●●●●●.",
  "       :β€’β€’β€’β€’:",
] as const;

describe("printClawBanner", () => {
  it("prints the static banner when not animatable", async () => {
    const { runtime, log } = runtimeStub();
    await printClawBanner(runtime, { columns: 120, isTty: false, env: {} });
    const output = stripAnsi(String(log.mock.calls[0]?.[0]));
    const rows = output.split("\n").filter((row) => row.length > 0);
    expect(rows.map((row) => row.slice(0, 20).trimEnd())).toEqual(EXPECTED_MASCOT);
    expect(output).toContain("β–ˆβ–€β–€β–€β–ˆ β–ˆβ–€β–€β–€β–ˆ β–ˆβ–€β–€β–€β–€ β–ˆβ–„  β–ˆ");
  });

  it("stays static under CI even on a rich TTY", async () => {
    const { runtime, log } = runtimeStub();
    await printClawBanner(runtime, { columns: 120, isTty: true, rich: true, env: { CI: "1" } });
    expect(log).toHaveBeenCalledTimes(1);
  });

  it("falls back to the plain title on narrow terminals", async () => {
    const { runtime, log } = runtimeStub();
    await printClawBanner(runtime, { columns: 50, isTty: true, rich: true, env: {} });
    const output = String(log.mock.calls[0]?.[0]);
    expect(output).toContain("OPENCLAW");
    expect(output).not.toContain("β–ˆ");
  });

  it("animates on a rich TTY and settles on the exact static banner", async () => {
    const staticRows = await runStatic();
    const chunks = await runAnimated(() => 0);
    expect(chunks[0]).toBe("\x1b[?25l");
    expect(chunks).toContain("\x1b[?25h");
    const frames = chunks.filter((chunk) => chunk.includes("\x1b[K"));
    expect(frames.length).toBeGreaterThan(10);
    expect(
      frames.some((frame) => {
        const [first = "", second = ""] = stripAnsi(frame).split("\n");
        return (
          first.slice(0, 20).trimEnd() === "‒●‒.:.        .:.‒●‒" &&
          second.slice(0, 20).trimEnd() === ":●●●‒:        :‒●●●:"
        );
      }),
    ).toBe(true);
    const finalRows = stripAnsi(frames[frames.length - 1] ?? "")
      .split("\n")
      .filter((row) => row.length > 0);
    expect(finalRows).toEqual(staticRows);
  });

  it("installs scoped signal handlers only while animating", async () => {
    const before = process.listenerCount("SIGINT");
    let during = -1;
    const { runtime } = runtimeStub();
    await printClawBanner(runtime, {
      columns: 120,
      isTty: true,
      rich: true,
      env: {},
      rng: () => 0.99,
      sleep: async () => {
        during = Math.max(during, process.listenerCount("SIGINT"));
      },
      write: () => {},
    });
    expect(during).toBe(before + 1);
    expect(process.listenerCount("SIGINT")).toBe(before);
  });

  it("settles on the static frame when parallel work finishes first", async () => {
    const staticRows = await runStatic();
    const chunks: string[] = [];
    const beforeSigint = process.listenerCount("SIGINT");
    let settle!: () => void;
    const settleWhen = new Promise<void>((resolve) => {
      settle = resolve;
    });
    const { runtime } = runtimeStub();
    const banner = printClawBanner(runtime, {
      columns: 120,
      isTty: true,
      rich: true,
      env: {},
      rng: () => 0.99,
      settleWhen,
      sleep: () => new Promise<void>(() => {}),
      write: (chunk) => chunks.push(chunk),
    });

    expect(chunks[0]).toBe("\x1b[?25l");
    expect(process.listenerCount("SIGINT")).toBe(beforeSigint + 1);
    settle();
    await expect(banner).resolves.toBe("settled");

    const frames = chunks.filter((chunk) => chunk.includes("\x1b[K"));
    const finalRows = stripAnsi(frames.at(-1) ?? "")
      .split("\n")
      .filter((row) => row.length > 0);
    expect(finalRows).toEqual(staticRows);
    expect(chunks.at(-2)).toBe("\x1b[?25h");
    expect(chunks.at(-1)).toBe("\n");
    expect(process.listenerCount("SIGINT")).toBe(beforeSigint);
  });

  it("varies snips and shimmer passes with the rng", async () => {
    // rng below the thresholds adds a second shimmer pass and a second snip.
    const maximal = (await runAnimated(() => 0)).filter((c) => c.includes("\x1b[K"));
    const minimal = (await runAnimated(() => 0.99)).filter((c) => c.includes("\x1b[K"));
    expect(maximal.length).toBeGreaterThan(minimal.length);
  });
});