Skip to content

Commit 9da4569

Browse files
committed
feat(expo): console collector with sentinel-guarded patching
1 parent 50e2018 commit 9da4569

2 files changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect, beforeEach, afterEach } from "bun:test"
2+
import { createConsoleCollector } from "./console"
3+
4+
let originalConsole: typeof console
5+
6+
beforeEach(() => {
7+
originalConsole = { ...console }
8+
})
9+
10+
afterEach(() => {
11+
Object.assign(console, originalConsole)
12+
})
13+
14+
test("patches console.log and records the call", () => {
15+
const c = createConsoleCollector({ max: 10 })
16+
c.start()
17+
console.log("hi", 1)
18+
const entries = c.snapshot()
19+
expect(entries).toHaveLength(1)
20+
expect(entries[0]?.level).toBe("log")
21+
expect(entries[0]?.args).toEqual(["hi", "1"])
22+
c.stop()
23+
})
24+
25+
test("captures stack on warn + error only", () => {
26+
const c = createConsoleCollector({ max: 10 })
27+
c.start()
28+
console.log("no stack")
29+
console.warn("with stack")
30+
console.error("with stack")
31+
const entries = c.snapshot()
32+
expect(entries.find((e) => e.level === "log")?.stack).toBeUndefined()
33+
expect(entries.find((e) => e.level === "warn")?.stack).toBeDefined()
34+
expect(entries.find((e) => e.level === "error")?.stack).toBeDefined()
35+
c.stop()
36+
})
37+
38+
test("stop restores the original console functions", () => {
39+
const original = console.log
40+
const c = createConsoleCollector({ max: 10 })
41+
c.start()
42+
expect(console.log).not.toBe(original)
43+
c.stop()
44+
expect(console.log).toBe(original)
45+
})
46+
47+
test("fails open if host code throws inside patched log", () => {
48+
const c = createConsoleCollector({ max: 10 })
49+
c.start()
50+
// Stub the ring push to throw — collector must still call through to the original.
51+
const originalPush = (c as unknown as { __buf: { push: (v: unknown) => void } }).__buf.push
52+
;(c as unknown as { __buf: { push: (v: unknown) => void } }).__buf.push = () => {
53+
throw new Error("boom")
54+
}
55+
expect(() => console.log("x")).not.toThrow()
56+
;(c as unknown as { __buf: { push: (v: unknown) => void } }).__buf.push = originalPush
57+
c.stop()
58+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { RingBuffer } from "@reprojs/sdk-utils"
2+
3+
export interface ConsoleEntry {
4+
level: "log" | "info" | "warn" | "error" | "debug"
5+
ts: number
6+
args: string[]
7+
stack?: string
8+
}
9+
10+
const LEVELS = ["log", "info", "warn", "error", "debug"] as const
11+
const SENTINEL = "__reprojs_patched"
12+
13+
function stringifyArg(v: unknown): string {
14+
if (typeof v === "string") return v
15+
try {
16+
return JSON.stringify(v)
17+
} catch {
18+
return String(v)
19+
}
20+
}
21+
22+
export interface ConsoleCollector {
23+
start: () => void
24+
stop: () => void
25+
snapshot: () => ConsoleEntry[]
26+
clear: () => void
27+
}
28+
29+
export function createConsoleCollector(opts: { max: number }): ConsoleCollector {
30+
const buf = new RingBuffer<ConsoleEntry>(opts.max)
31+
const originals: Partial<Record<(typeof LEVELS)[number], (...args: unknown[]) => void>> = {}
32+
let started = false
33+
34+
function start() {
35+
if (started) return
36+
started = true
37+
for (const level of LEVELS) {
38+
const existing = console[level] as ((...args: unknown[]) => void) & {
39+
[SENTINEL]?: boolean
40+
}
41+
if (existing?.[SENTINEL]) continue
42+
originals[level] = existing
43+
const wrapped = ((...args: unknown[]) => {
44+
try {
45+
buf.push({
46+
level,
47+
ts: Date.now(),
48+
args: args.map(stringifyArg),
49+
stack:
50+
level === "warn" || level === "error"
51+
? new Error().stack?.split("\n").slice(2).join("\n")
52+
: undefined,
53+
})
54+
} catch {
55+
// fail-open
56+
}
57+
existing.apply(console, args)
58+
}) as ((...args: unknown[]) => void) & { [SENTINEL]?: boolean }
59+
wrapped[SENTINEL] = true
60+
console[level] = wrapped as (typeof console)[typeof level]
61+
}
62+
}
63+
64+
function stop() {
65+
if (!started) return
66+
started = false
67+
for (const level of LEVELS) {
68+
const original = originals[level]
69+
if (original) {
70+
console[level] = original as (typeof console)[typeof level]
71+
}
72+
}
73+
}
74+
75+
return {
76+
start,
77+
stop,
78+
snapshot: () => buf.peek(),
79+
clear: () => buf.clear(),
80+
// Test-only access to the underlying buffer for fail-open test:
81+
__buf: buf,
82+
} as ConsoleCollector & { __buf: RingBuffer<ConsoleEntry> }
83+
}

0 commit comments

Comments
 (0)