mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Harden the extension's connection so the daemon link survives normal
idle periods and OS sleep instead of silently dropping.
- Send a `system.heartbeat` event every 20s while the post-handshake
link is live. Since Chrome 116 WebSocket activity resets the MV3
service-worker idle timer, so this keeps the worker (and the socket)
alive during use rather than relying on the 30s keepalive alarm that
sits right on the eviction boundary. Declare `minimum_chrome_version:
"116"` accordingly.
- Reconnect on `runtime.onStartup` and `idle.onStateChanged` ("active"),
which revive the worker after a browser restart or machine wake and
let it reconnect immediately instead of waiting for the next alarm
tick. Add the `idle` permission and document it.
Co-authored-by: Cursor <cursoragent@cursor.com>
131 lines
4.0 KiB
TypeScript
131 lines
4.0 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { ProtocolFrame } from "@/transport/types";
|
|
import { HEARTBEAT_EVENT, HEARTBEAT_INTERVAL_MS, startHeartbeat } from "../heartbeat";
|
|
|
|
function makeTimers() {
|
|
let nextId = 1;
|
|
const intervals = new Map<number, { cb: () => void; ms: number }>();
|
|
return {
|
|
intervals,
|
|
api: {
|
|
setInterval: (cb: () => void, ms: number) => {
|
|
const id = nextId++;
|
|
intervals.set(id, { cb, ms });
|
|
return id;
|
|
},
|
|
clearInterval: (id: number) => {
|
|
intervals.delete(id);
|
|
},
|
|
},
|
|
tick: (id: number) => intervals.get(id)?.cb(),
|
|
};
|
|
}
|
|
|
|
/** Drives `onActiveChange` subscribers so a test can flip the link state. */
|
|
function makeActiveSource() {
|
|
const listeners = new Set<(active: boolean) => void>();
|
|
return {
|
|
subscribe: (cb: (active: boolean) => void) => {
|
|
// Mirror the controller contract: fire immediately with the current
|
|
// value (starts inactive), then on every change.
|
|
cb(false);
|
|
listeners.add(cb);
|
|
return () => listeners.delete(cb);
|
|
},
|
|
set: (active: boolean) => {
|
|
for (const cb of listeners) cb(active);
|
|
},
|
|
listenerCount: () => listeners.size,
|
|
};
|
|
}
|
|
|
|
describe("startHeartbeat", () => {
|
|
it("does not beat while the link is inactive", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
const send = vi.fn();
|
|
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
|
|
|
expect(timers.intervals.size).toBe(0);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("starts a 20s interval on activation and sends heartbeat events", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
const send = vi.fn();
|
|
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
|
|
|
active.set(true);
|
|
expect(timers.intervals.size).toBe(1);
|
|
const [id, info] = [...timers.intervals.entries()][0];
|
|
expect(info.ms).toBe(HEARTBEAT_INTERVAL_MS);
|
|
|
|
timers.tick(id);
|
|
expect(send).toHaveBeenCalledWith({ event: HEARTBEAT_EVENT, payload: {} });
|
|
});
|
|
|
|
it("stops beating when the link goes inactive", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
const send = vi.fn();
|
|
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
|
|
|
active.set(true);
|
|
expect(timers.intervals.size).toBe(1);
|
|
active.set(false);
|
|
expect(timers.intervals.size).toBe(0);
|
|
});
|
|
|
|
it("does not stack intervals on repeated activation", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
startHeartbeat({ send: vi.fn(), onActiveChange: active.subscribe, timers: timers.api });
|
|
|
|
active.set(true);
|
|
active.set(true);
|
|
expect(timers.intervals.size).toBe(1);
|
|
});
|
|
|
|
it("swallows send errors so the interval keeps running", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
const send = vi.fn(() => {
|
|
throw new Error("socket closed");
|
|
});
|
|
const handle = startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
|
expect(() => handle.beatForTest()).not.toThrow();
|
|
});
|
|
|
|
it("dispose clears the interval and unsubscribes", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
const handle = startHeartbeat({
|
|
send: vi.fn(),
|
|
onActiveChange: active.subscribe,
|
|
timers: timers.api,
|
|
});
|
|
active.set(true);
|
|
expect(timers.intervals.size).toBe(1);
|
|
|
|
handle.dispose();
|
|
expect(timers.intervals.size).toBe(0);
|
|
expect(active.listenerCount()).toBe(0);
|
|
});
|
|
|
|
it("sends a well-formed protocol frame", () => {
|
|
const timers = makeTimers();
|
|
const active = makeActiveSource();
|
|
let captured: ProtocolFrame | null = null;
|
|
const handle = startHeartbeat({
|
|
send: (f) => {
|
|
captured = f;
|
|
},
|
|
onActiveChange: active.subscribe,
|
|
timers: timers.api,
|
|
});
|
|
handle.beatForTest();
|
|
expect(captured).toEqual({ event: HEARTBEAT_EVENT, payload: {} });
|
|
});
|
|
});
|