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.
- package/CHANGELOG.md +72 -0
- package/dist/handlers/message.js +5 -2
- package/dist/index.js +14 -10
- package/dist/paths.js +2 -0
- package/dist/platforms/whatsapp-auth-helpers.js +53 -0
- package/dist/platforms/whatsapp.js +6 -2
- package/dist/services/browser-manager.js +470 -95
- package/dist/services/browser-webfetch.js +93 -0
- package/dist/services/cron-scheduling.js +142 -0
- package/dist/services/cron.js +32 -6
- package/dist/services/skills.js +15 -11
- package/dist/services/subagent-delivery.js +8 -2
- package/dist/services/subagents.js +49 -8
- package/dist/services/telegram.js +12 -3
- package/dist/services/watchdog-brake.js +113 -0
- package/dist/services/watchdog.js +56 -42
- package/dist/util/console-formatter.js +109 -0
- package/dist/util/debounce.js +24 -0
- package/dist/util/telegram-error-filter.js +62 -0
- package/dist/web/server.js +56 -0
- package/package.json +1 -1
- package/skills/browse/SKILL.md +123 -98
- package/test/browser-webfetch.test.ts +121 -0
- package/test/console-timestamps.test.ts +98 -0
- package/test/cron-restart-resilience.test.ts +191 -0
- package/test/debounce.test.ts +60 -0
- package/test/subagent-final-text.test.ts +132 -0
- package/test/telegram-error-filter.test.ts +85 -0
- package/test/watchdog-brake.test.ts +157 -0
- package/test/web-server-shutdown.test.ts +111 -0
- package/test/whatsapp-auth-resilience.test.ts +96 -0
package/skills/browse/SKILL.md
CHANGED
|
@@ -1,136 +1,161 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: Browser Automation
|
|
3
|
-
description:
|
|
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,
|
|
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 —
|
|
9
|
+
# Browser Automation — 3-Tier Router
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Du hast drei Browser-Strategien plus WebFetch. **Wähle die billigste passende Stufe** und eskaliere nur wenn nötig.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## Entscheidungsregel (in dieser Reihenfolge)
|
|
14
14
|
|
|
15
|
-
|
|
|
16
|
-
|
|
17
|
-
|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
## Tier 0 — WebFetch / curl (schnellster Pfad)
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Für statische Seiten oder APIs, die keine JS-Rendering brauchen:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
#
|
|
32
|
-
curl -
|
|
33
|
-
|
|
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
|
-
#
|
|
36
|
-
|
|
35
|
+
# Oder das WebFetch-Tool, wenn verfügbar (interpretiert Inhalt direkt)
|
|
36
|
+
```
|
|
37
37
|
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
curl -s "http://127.0.0.1:3800/tree" | jq '.tree[]' -r
|
|
40
|
+
---
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
113
|
+
**Wann nutzen:** Drag&Drop, komplexe UI, User soll live zusehen und eingreifen können.
|
|
97
114
|
|
|
98
|
-
|
|
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
|
-
##
|
|
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
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
#
|
|
115
|
-
curl -s
|
|
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
|
-
##
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
154
|
+
Für komplexere Flows (sequentielles Klicken nach DOM-Updates) → Tier 3 (Extension) nutzen.
|
|
155
|
+
|
|
156
|
+
## Wichtige Notes
|
|
129
157
|
|
|
130
|
-
- **
|
|
131
|
-
- **
|
|
132
|
-
- **
|
|
133
|
-
- **
|
|
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 & B</title>")).toBe("A & B");
|
|
41
|
+
expect(parseTitle("<title>"quoted"</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
|
+
});
|