alvin-bot 4.8.8 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,136 +1,161 @@
1
1
  ---
2
2
  name: Browser Automation
3
- description: Interactive browser control — navigate, click, fill forms, screenshot, test web apps
4
- triggers: browse, browser, test webapp, test app, test website, screenshot page, interact with, click on, fill form, visual test, qa test, check page, open page, test my app, browse to, open url, puppeteer, playwright, browser automation, test die seite, teste die app, schau dir an, öffne die seite, teste mal, visual check, check the ui, check the page
3
+ description: 3-tier browser control — stealth scraping, CDP with persistent cookies, visual oversight. Navigate, screenshot, extract text, interact with logged-in pages.
4
+ triggers: browse, browser, test webapp, test app, test website, screenshot page, interact with, click on, fill form, visual test, qa test, check page, open page, test my app, browse to, open url, puppeteer, playwright, browser automation, linkedin, stepstone, indeed, scrape, fetch page, crawl, teste die seite, teste die app, schau dir an, öffne die seite, teste mal, visual check, check the ui, check the page, webseite öffnen, seite abrufen
5
5
  priority: 8
6
6
  category: automation
7
7
  ---
8
8
 
9
- # Browser Automation — Playwright Interactive
9
+ # Browser Automation — 3-Tier Router
10
10
 
11
- ## Browser Strategies
11
+ Du hast drei Browser-Strategien plus WebFetch. **Wähle die billigste passende Stufe** und eskaliere nur wenn nötig.
12
12
 
13
- Alvin Bot auto-selects the best browser approach:
13
+ ## Entscheidungsregel (in dieser Reihenfolge)
14
14
 
15
- | Strategy | When | How |
16
- |----------|------|-----|
17
- | **CLI** (default) | Simple screenshots, text extraction, PDF | Headless Playwright, one-shot |
18
- | **HTTP Gateway** | Interactive browsing, form-filling, QA testing | Persistent browser server on port 3800 |
19
- | **CDP** | Attach to user's Chrome (with login state) | Chrome DevTools Protocol via CDP_URL |
15
+ | Task | Tool | Warum |
16
+ |------|------|-------|
17
+ | Einzelne öffentliche Seite, nur Text | WebFetch oder `curl` | Am schnellsten, keine Browser-Engine |
18
+ | Öffentliche Seite mit JS / Cloudflare | **Tier 1 Stealth** | Headless + Fingerprint-Masking |
19
+ | Login-pflichtige Seite (LinkedIn, Gmail, …) | **Tier 2 CDP** | Echtes Chrome, persistente Cookies |
20
+ | Komplexer Multi-Step-Flow, User soll zusehen | **Tier 3 Extension** | Visuelle Kontrolle |
20
21
 
21
- The gateway starts automatically when needed and shuts down after 5 min idle.
22
- For CDP: Launch Chrome with `--remote-debugging-port=9222` and set `CDP_URL=http://localhost:9222`.
22
+ **NIEMALS** `scripts/browse-server.cjs` nutzen existiert nicht mehr. **NIEMALS** nacktes `node -e "const {chromium}…"` für externe Seiten — wird sofort geblockt.
23
23
 
24
24
  ---
25
25
 
26
- You have a persistent Playwright browser server that gives you **eyes** and **hands** to interact with web pages. You can navigate, see screenshots, read the accessibility tree, click buttons, fill forms, and test running web apps.
26
+ ## Tier 0 WebFetch / curl (schnellster Pfad)
27
27
 
28
- ## Quick Start
28
+ Für statische Seiten oder APIs, die keine JS-Rendering brauchen:
29
29
 
30
30
  ```bash
31
- # 1. Ensure server is running (auto-shuts down after 5 min idle)
32
- curl -s http://127.0.0.1:3800/health 2>/dev/null | grep -q '"ok":true' || \
33
- (BOT_DIR=$(node -e "console.log(require('path').resolve(require.resolve('alvin-bot/package.json'), '..'))" 2>/dev/null || echo ".") && cd "$BOT_DIR" && node scripts/browse-server.cjs &) && sleep 3
31
+ # Direkter curl
32
+ curl -sL -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \
33
+ "https://www.michaelpage.de/jobs/it-director" | htmlq -t "h1, .job-title"
34
34
 
35
- # 2. Navigate to a page
36
- curl -s "http://127.0.0.1:3800/navigate?url=https://example.com" | jq
35
+ # Oder das WebFetch-Tool, wenn verfügbar (interpretiert Inhalt direkt)
36
+ ```
37
37
 
38
- # 3. Take a screenshot (view it with Read tool)
39
- SHOT=$(curl -s "http://127.0.0.1:3800/screenshot" | jq -r '.path')
40
- # Then use Read tool on $SHOT to see the image
38
+ Wenn das einen 403/Captcha gibt eskaliere auf Tier 1.
41
39
 
42
- # 4. Get interactive elements
43
- curl -s "http://127.0.0.1:3800/tree" | jq '.tree[]' -r
40
+ ---
44
41
 
45
- # 5. Click something
46
- curl -s "http://127.0.0.1:3800/click?ref=e5" | jq
47
- ```
42
+ ## Tier 1 — Playwright Stealth (headless, schnell, maskiert)
43
+
44
+ **Router-Script:** `~/.claude/hub/SCRIPTS/browser.sh`
45
+
46
+ ```bash
47
+ # Seite laden, JSON-Metadata zurück (title, url, html_length)
48
+ ~/.claude/hub/SCRIPTS/browser.sh stealth "https://www.stepstone.de/jobs/it-delivery"
48
49
 
49
- ## All Routes
50
-
51
- | Route | Params | What it does |
52
- |-------|--------|-------------|
53
- | `/navigate` | `url` | Open a URL, returns title + accessibility tree |
54
- | `/screenshot` | `full=true` (optional) | Take screenshot, returns file path |
55
- | `/tree` | `limit=N` (optional) | Get all interactive elements with @eN refs |
56
- | `/click` | `ref=eN` | Click element by ref |
57
- | `/fill` | `ref=eN`, `value=text` | Fill input field |
58
- | `/type` | `ref=eN`, `text=chars` | Type character by character (for special inputs) |
59
- | `/press` | `key=Enter`, `ref=eN` (opt) | Press keyboard key |
60
- | `/select` | `ref=eN`, `value=opt` | Select dropdown option |
61
- | `/hover` | `ref=eN` | Hover over element |
62
- | `/scroll` | `direction=down/up/top/bottom`, `amount=600` | Scroll page |
63
- | `/eval` | `js=expression` | Run JavaScript on page |
64
- | `/wait` | `ms=2000` or `selector=.class` | Wait for time or element |
65
- | `/viewport` | `device=mobile/tablet` or `width=W&height=H` | Change viewport |
66
- | `/cookies` | `set=[{...}]` (optional) | Get or set cookies |
67
- | `/back` | — | Browser back |
68
- | `/forward` | — | Browser forward |
69
- | `/reload` | — | Reload page |
70
- | `/network` | `limit=20` | Recent network requests |
71
- | `/info` | — | Current page info |
72
- | `/close` | — | Close browser + shutdown server |
73
- | `/health` | — | Server status check |
74
-
75
- ## Element Refs (@eN)
76
-
77
- The accessibility tree assigns **refs** like `@e1`, `@e2`, `@e3` to every interactive element (links, buttons, inputs, etc.). Use these refs for all interactions — they're more robust than CSS selectors.
78
-
79
- Example tree:
50
+ # Mit Screenshot (PNG in ~/.claude/hub/BROWSER/screenshots/)
51
+ ~/.claude/hub/SCRIPTS/browser.sh stealth "https://example.com" --screenshot=page.png
80
52
  ```
81
- @e1 <a href="/"> "Home"
82
- @e2 <a href="/dashboard"> "Dashboard"
83
- @e3 <input type="email" name="email" placeholder="Enter email">
84
- @e4 <input type="password" name="password" placeholder="Password">
85
- @e5 <button> "Sign In"
86
- @e6 <a href="/forgot"> "Forgot password?"
53
+
54
+ **Was du bekommst:** JSON mit `{title, url, html_length, screenshot}`. Der volle HTML liegt nicht in stdout — zum Parsen den `stealth.js` direkt als Modul importieren oder `/tmp/`-File lesen.
55
+
56
+ **Wann blockt das:** reCAPTCHA v3, aggressive Cloudflare, Login-Walls.
57
+
58
+ **Konkrete funktionierende Targets (Stand 2026):**
59
+ - StepStone (alle Job-Suchen) ✅
60
+ - Michael Page ✅
61
+ - Hays ✅
62
+ - Öffentliche Blog-Posts, News-Sites ✅
63
+ - LinkedIn (ohne Login) ❌ → Tier 2
64
+ - Indeed / Glassdoor ❌ (403 Scraping-Block) → nur über E-Mail-Alerts
65
+
66
+ ---
67
+
68
+ ## Tier 2 — Chrome CDP (persistent Profile, echte Cookies)
69
+
70
+ Echtes Chrome mit Profil unter `~/.claude/hub/BROWSER/profile/`. Login-Cookies für LinkedIn/Gmail/etc. bleiben über Sessions erhalten.
71
+
72
+ ```bash
73
+ # Einmal starten (checkt ob schon läuft)
74
+ ~/.claude/hub/SCRIPTS/browser.sh cdp start headless # headless — für Cron/Daemon
75
+ ~/.claude/hub/SCRIPTS/browser.sh cdp start headful # sichtbar — wenn User zusehen soll
76
+
77
+ # Navigieren
78
+ ~/.claude/hub/SCRIPTS/browser.sh cdp goto "https://www.linkedin.com/jobs/search/?keywords=IT+Director"
79
+
80
+ # Screenshot
81
+ ~/.claude/hub/SCRIPTS/browser.sh cdp shot "https://www.linkedin.com/feed/" linkedin_feed.png
82
+
83
+ # Tabs auflisten
84
+ ~/.claude/hub/SCRIPTS/browser.sh cdp tabs
85
+
86
+ # Stoppen (meistens nicht nötig, Chrome läuft persistent)
87
+ ~/.claude/hub/SCRIPTS/browser.sh cdp stop
87
88
  ```
88
89
 
89
- To login:
90
+ **Login-Setup (einmalig):** Falls LinkedIn ausgeloggt ist, Ali per Telegram fragen:
91
+ > "Bitte einmal in Chrome (Hub-Profil) bei LinkedIn einloggen. Cookies bleiben dann dauerhaft erhalten."
92
+
93
+ Starten mit `cdp start headful` und Chrome öffnet sichtbar → Ali loggt ein → ab dann bleiben Cookies im Profil.
94
+
95
+ **Wie teste ich ob eingeloggt:** nach `cdp goto` die URL prüfen — wenn `/authwall` oder `/login` im Pfad steht, bist du ausgeloggt.
96
+
97
+ ---
98
+
99
+ ## Tier 3 — Claude-in-Chrome Extension (visuelle Kontrolle)
100
+
101
+ Nur in interaktiven CLI-Sessions, nicht im Cron/Daemon.
102
+
90
103
  ```bash
91
- curl -s "http://127.0.0.1:3800/fill?ref=e3&value=user@example.com"
92
- curl -s "http://127.0.0.1:3800/fill?ref=e4&value=mypassword"
93
- curl -s "http://127.0.0.1:3800/click?ref=e5"
104
+ # Check ob Extension verbunden
105
+ ~/.claude/hub/SCRIPTS/browser.sh ext check
106
+
107
+ # Dann MCP-Tools über ToolSearch laden:
108
+ # mcp__claude-in-chrome__tabs_context_mcp
109
+ # mcp__claude-in-chrome__navigate
110
+ # mcp__claude-in-chrome__computer
94
111
  ```
95
112
 
96
- ## Standard Workflow: Test a Web App
113
+ **Wann nutzen:** Drag&Drop, komplexe UI, User soll live zusehen und eingreifen können.
97
114
 
98
- 1. **Start** the browse server if not running
99
- 2. **Navigate** to the app URL
100
- 3. **Screenshot** → view with Read tool to see current state
101
- 4. **Tree** → see all interactive elements
102
- 5. **Interact** (click, fill, press) using @eN refs
103
- 6. **Screenshot** again to verify the result
104
- 7. **Repeat** for each test step
105
- 8. **Report** findings to the user
106
- 9. **Close** when done
115
+ ---
107
116
 
108
- ## Mobile Testing
117
+ ## Eskalations-Regel (PFLICHT)
118
+
119
+ ```
120
+ Öffentliche Text-Seite → Tier 0 (WebFetch/curl)
121
+ ↓ 403/Cloudflare/leerer HTML?
122
+ Tier 1 (stealth) → browser.sh stealth <url>
123
+ ↓ Captcha/Login-Wall?
124
+ Tier 2 (CDP) → cdp start headless/headful + cdp goto <url>
125
+ ↓ Cookies fehlen?
126
+ Ali fragen: "Bitte einmal in Chrome bei X einloggen, dann kann ich weitermachen."
127
+ ```
128
+
129
+ **NIEMALS aufgeben mit "Browser funktioniert nicht"** — es gibt immer einen nächsten Schritt. Lieber ehrlich melden "Tier 1 blockt mit Captcha, versuche Tier 2" als "Failed to load".
130
+
131
+ ## Status-Checks
109
132
 
110
133
  ```bash
111
- # Switch to mobile viewport
112
- curl -s "http://127.0.0.1:3800/viewport?device=mobile"
113
- curl -s "http://127.0.0.1:3800/screenshot" | jq -r '.path'
114
- # Switch back to desktop
115
- curl -s "http://127.0.0.1:3800/viewport?width=1280&height=720"
134
+ # Übersicht aller Tiers + Health
135
+ ~/.claude/hub/SCRIPTS/browser.sh status
136
+
137
+ # Ist CDP Chrome gerade auf Port 9222?
138
+ curl -s http://127.0.0.1:9222/json/version | head -c 200
116
139
  ```
117
140
 
118
- ## Auth / Cookie Injection
141
+ ## Screenshot-Ausgabe ansehen
142
+
143
+ Screenshots werden gespeichert unter `~/.claude/hub/BROWSER/screenshots/` (relativ) oder dem absoluten Pfad, den du angibst. Read-Tool auf den Pfad zeigt dir das Bild direkt an.
144
+
145
+ ## Interaktive Ops (Klicken, Formular füllen)
146
+
147
+ Für einfache Fälle: `cdp eval` mit JavaScript, das in der Seite ausgeführt wird:
119
148
 
120
- For pages that need authentication:
121
149
  ```bash
122
- # Set cookies manually
123
- curl -s 'http://127.0.0.1:3800/cookies?set=[{"name":"session","value":"abc123","domain":"example.com","path":"/"}]'
124
- # Then navigate to the authenticated page
125
- curl -s "http://127.0.0.1:3800/navigate?url=https://example.com/dashboard"
150
+ ~/.claude/hub/SCRIPTS/browser.sh cdp eval "https://example.com/login" \
151
+ "document.querySelector('#username').value='test'; document.querySelector('#password').value='pw'; document.querySelector('form').submit();"
126
152
  ```
127
153
 
128
- ## Important Notes
154
+ Für komplexere Flows (sequentielles Klicken nach DOM-Updates) → Tier 3 (Extension) nutzen.
155
+
156
+ ## Wichtige Notes
129
157
 
130
- - **Server auto-shuts down** after 5 min idle restart if needed
131
- - **One page at a time**navigation replaces the current page
132
- - **Screenshots** are saved to `/tmp/alvin-bot/browse/`view with Read tool
133
- - **127.0.0.1 only** not accessible from outside
134
- - **URL-encode** values with special chars: `value=hello%20world`
135
- - **Refs reset** on every navigation/click — always get fresh /tree after page changes
136
- - For **local dev servers**: use `http://localhost:PORT` as the URL
158
+ - **CDP-Profil-Konflikt:** Chrome kann `~/.claude/hub/BROWSER/profile/` nicht doppelt öffnen. Wenn Ali es lokal auf hatte, Port 9222 checken und `cdp stop` + `cdp start` machen.
159
+ - **Headless vs Headful:** Im Cron/Daemon (launchd) IMMER `headless` sonst scheitert Chrome an fehlendem Display.
160
+ - **Nach Seiten-Navigation** (`cdp goto`) neue Tabs legt Playwright standardmäßig an reuseTab ist nicht exponiert. Das ist OK für einzelne Scrapes, kann aber zu Tab-Explosion führen. `cdp stop` & Neustart räumt auf.
161
+ - **Persistenz:** Cookies, LocalStorage, IndexedDB, alles in `~/.claude/hub/BROWSER/profile/`. Komplett persistiert zwischen Bot-Restarts.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Fix #11 (minimal) — webfetch Tier 0 for browser-manager.
3
+ *
4
+ * Background: the current browser fallback chain is
5
+ * gateway → cdp → hub-stealth → cli
6
+ * Every tier spawns playwright (or talks to a CDP-controlled Chrome),
7
+ * which is slow and occasionally impossible under load. Many scraping
8
+ * tasks only need plain HTTP — an RSS feed, a JSON API, an OG meta-
9
+ * tag sniff. For those, Node's native `fetch` is 100× faster and
10
+ * doesn't need a browser at all.
11
+ *
12
+ * Contract: `webfetchNavigate(url)` returns `{ title, url }` for a
13
+ * successful GET, or throws a distinct `WebfetchFailed` error that the
14
+ * cascade can catch and fall through to the next tier. Title is the
15
+ * first `<title>` tag content; if none, the URL is returned.
16
+ *
17
+ * Keep it small — this is a Tier 0 helper, not a full scraper.
18
+ */
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
20
+ import {
21
+ webfetchNavigate,
22
+ WebfetchFailed,
23
+ parseTitle,
24
+ } from "../src/services/browser-webfetch.js";
25
+
26
+ describe("parseTitle (Fix #11)", () => {
27
+ it("extracts a simple <title>", () => {
28
+ expect(parseTitle("<html><head><title>Hello World</title></head></html>")).toBe("Hello World");
29
+ });
30
+
31
+ it("handles whitespace and newlines", () => {
32
+ expect(parseTitle("<title>\n Multi line \n</title>")).toBe("Multi line");
33
+ });
34
+
35
+ it("returns empty string when there's no title", () => {
36
+ expect(parseTitle("<html><body>no title</body></html>")).toBe("");
37
+ });
38
+
39
+ it("decodes basic HTML entities", () => {
40
+ expect(parseTitle("<title>A &amp; B</title>")).toBe("A & B");
41
+ expect(parseTitle("<title>&quot;quoted&quot;</title>")).toBe('"quoted"');
42
+ });
43
+
44
+ it("is case-insensitive for the tag name", () => {
45
+ expect(parseTitle("<HEAD><TITLE>Foo</TITLE></HEAD>")).toBe("Foo");
46
+ });
47
+ });
48
+
49
+ describe("webfetchNavigate (Fix #11)", () => {
50
+ let originalFetch: typeof fetch;
51
+
52
+ beforeEach(() => {
53
+ originalFetch = globalThis.fetch;
54
+ });
55
+ afterEach(() => {
56
+ globalThis.fetch = originalFetch;
57
+ });
58
+
59
+ it("returns title + url on a 200 response", async () => {
60
+ globalThis.fetch = vi.fn(async () =>
61
+ new Response(
62
+ "<html><head><title>GitHub · alvbln/alvin-bot</title></head></html>",
63
+ { status: 200, headers: { "content-type": "text/html" } },
64
+ ),
65
+ ) as unknown as typeof fetch;
66
+
67
+ const result = await webfetchNavigate("https://github.com/alvbln/alvin-bot");
68
+ expect(result.title).toBe("GitHub · alvbln/alvin-bot");
69
+ expect(result.url).toBe("https://github.com/alvbln/alvin-bot");
70
+ });
71
+
72
+ it("throws WebfetchFailed with the HTTP status on 4xx/5xx", async () => {
73
+ globalThis.fetch = vi.fn(async () =>
74
+ new Response("blocked", { status: 403 }),
75
+ ) as unknown as typeof fetch;
76
+
77
+ await expect(webfetchNavigate("https://example.com")).rejects.toThrow(WebfetchFailed);
78
+ try {
79
+ await webfetchNavigate("https://example.com");
80
+ } catch (err) {
81
+ expect((err as WebfetchFailed).status).toBe(403);
82
+ }
83
+ });
84
+
85
+ it("throws WebfetchFailed when the response is not HTML and forceHtml=true", async () => {
86
+ globalThis.fetch = vi.fn(async () =>
87
+ new Response('{"json":true}', {
88
+ status: 200,
89
+ headers: { "content-type": "application/json" },
90
+ }),
91
+ ) as unknown as typeof fetch;
92
+
93
+ await expect(
94
+ webfetchNavigate("https://api.example.com/data", { forceHtml: true }),
95
+ ).rejects.toThrow(WebfetchFailed);
96
+ });
97
+
98
+ it("accepts non-HTML responses when forceHtml is false (default)", async () => {
99
+ globalThis.fetch = vi.fn(async () =>
100
+ new Response("plain text", {
101
+ status: 200,
102
+ headers: { "content-type": "text/plain" },
103
+ }),
104
+ ) as unknown as typeof fetch;
105
+
106
+ const result = await webfetchNavigate("https://example.com/raw");
107
+ // No <title> in plain text → falls back to URL as display title
108
+ expect(result.url).toBe("https://example.com/raw");
109
+ expect(result.title).toBe("https://example.com/raw");
110
+ });
111
+
112
+ it("wraps network errors in WebfetchFailed so the cascade can catch a single type", async () => {
113
+ globalThis.fetch = vi.fn(async () => {
114
+ throw new Error("getaddrinfo ENOTFOUND nonexistent.invalid");
115
+ }) as unknown as typeof fetch;
116
+
117
+ await expect(
118
+ webfetchNavigate("https://nonexistent.invalid/"),
119
+ ).rejects.toThrow(WebfetchFailed);
120
+ });
121
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Fix #10 — console output must carry ISO timestamps so out.log / err.log
3
+ * are actually debuggable. Also: silence libsignal's "Closing session"
4
+ * SessionEntry dumps which were pushing tens of KB per day into the logs
5
+ * and making forensic work painful.
6
+ *
7
+ * Contract: `installConsoleFormatter(console)` wraps console.log /
8
+ * console.warn / console.error so every line is prefixed with the
9
+ * current ISO timestamp (zero-padded, UTC), and certain noise patterns
10
+ * (libsignal session dumps) are dropped entirely.
11
+ *
12
+ * The wrapper is idempotent — calling it twice is a no-op.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
15
+ import {
16
+ installConsoleFormatter,
17
+ uninstallConsoleFormatter,
18
+ isNoisyLine,
19
+ } from "../src/util/console-formatter.js";
20
+
21
+ describe("installConsoleFormatter (Fix #10)", () => {
22
+ let stdoutWrites: string[];
23
+ let stderrWrites: string[];
24
+ let origStdout: typeof process.stdout.write;
25
+ let origStderr: typeof process.stderr.write;
26
+
27
+ beforeEach(() => {
28
+ stdoutWrites = [];
29
+ stderrWrites = [];
30
+ origStdout = process.stdout.write.bind(process.stdout);
31
+ origStderr = process.stderr.write.bind(process.stderr);
32
+ process.stdout.write = ((chunk: unknown) => {
33
+ stdoutWrites.push(String(chunk));
34
+ return true;
35
+ }) as typeof process.stdout.write;
36
+ process.stderr.write = ((chunk: unknown) => {
37
+ stderrWrites.push(String(chunk));
38
+ return true;
39
+ }) as typeof process.stderr.write;
40
+ });
41
+
42
+ afterEach(() => {
43
+ uninstallConsoleFormatter();
44
+ process.stdout.write = origStdout;
45
+ process.stderr.write = origStderr;
46
+ });
47
+
48
+ it("prefixes console.log output with an ISO timestamp", () => {
49
+ installConsoleFormatter();
50
+ console.log("hello world");
51
+ const line = stdoutWrites.join("");
52
+ // ISO format like 2026-04-11T14:00:00.000Z
53
+ expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+hello world/);
54
+ });
55
+
56
+ it("prefixes console.error output with an ISO timestamp", () => {
57
+ installConsoleFormatter();
58
+ console.error("boom");
59
+ const line = stderrWrites.join("");
60
+ expect(line).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+boom/);
61
+ });
62
+
63
+ it("is idempotent — second install call does not double-prefix", () => {
64
+ installConsoleFormatter();
65
+ installConsoleFormatter();
66
+ console.log("once");
67
+ const line = stdoutWrites.join("");
68
+ // Exactly one ISO timestamp, not two
69
+ const matches = line.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g);
70
+ expect(matches).not.toBeNull();
71
+ expect(matches!.length).toBe(1);
72
+ });
73
+ });
74
+
75
+ describe("isNoisyLine (Fix #10)", () => {
76
+ it("treats libsignal session dumps as noise", () => {
77
+ const dump = `Closing session: SessionEntry {
78
+ _chains: {
79
+ 'BUQxzJlwgVTCxCL5C4rTbZP/7a0ciMPnyo47Pwr4flJt': { chainKey: [Object], chainType: 1, messageKeys: {} }
80
+ },
81
+ registrationId: 1446528770`;
82
+ expect(isNoisyLine(dump)).toBe(true);
83
+ });
84
+
85
+ it("treats the one-line 'Closing open session' as noise", () => {
86
+ expect(isNoisyLine("Closing open session in favor of incoming prekey bundle")).toBe(true);
87
+ });
88
+
89
+ it("treats the repetitive claude native binary banner as noise", () => {
90
+ expect(isNoisyLine("[claude] Native binary: /Users/foo/.local/share/claude/versions/2.1.101")).toBe(true);
91
+ });
92
+
93
+ it("does NOT silence normal log output", () => {
94
+ expect(isNoisyLine("⏰ Cron scheduler started (30s interval)")).toBe(false);
95
+ expect(isNoisyLine("[watchdog] started — beacon every 30s")).toBe(false);
96
+ expect(isNoisyLine("Cron: Running job \"Daily Job Alert\"")).toBe(false);
97
+ });
98
+ });