mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Blocking: - B1: delete screenshot scratch PNGs — observation frames reuse one fixed per-session path and unlink after every read (finally); browser_screenshot unlinks once the bytes are in the attachment store (kept only when the file itself is the model-facing artifact). - B2: replicate dsh's browser-trust fence on /bsk-observation/*: loopback Host only, Origin must match Host, sec-fetch-site: cross-site refused, POST requires application/json; README documents the loopback trust premise and the 0.0.0.0 warning. Major: - M1: KeyedExecutor tail chains previous+task (allSettled) so a task aborted while queued cannot release the next one into the running session. - M2: client tracks the live frame per session — replacing/removing/reset revokes the old blob URL at once; loads settling after replacement never resurrect it. Minor: drop replaced thumbRefs (+on remove); beginAction fires inside the queue (no label overwrite while queued, no idle flash); half-initialized session cleanup stops through the queue with one retry; SSE (re)open refetches /state (+ snapshot flag renamed subscribed); install probe uses --version (no daemon spawn); emulate mobile documents the width+height requirement (the daemon refuses it alone — verified). Nit: single BskRunOptions declaration; tails map entry dropped on drain; SSE cleanup on res close; popOut catches requestWindow rejections; resize handle gains keyboard control (arrows, 16px steps, aria value attrs). Tests: +14 (fence rules, scratch lifecycle, queue race, blob revocation, instrumentation timing, SSE resync). 113/113 green.
645 lines
24 KiB
TypeScript
645 lines
24 KiB
TypeScript
// ObservationService host tests: state machine, cadence/backoff, observation-
|
|
// traffic isolation, interrupt routing, the owned boundary, and the HTTP/SSE
|
|
// interface. All bsk runs are faked; the scheduler is a manual timer queue.
|
|
|
|
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
actionForLabel,
|
|
type ObservationEvent,
|
|
type ObservationScheduler,
|
|
ObservationService,
|
|
} from "../src/observation";
|
|
import { registerObservationRoutes } from "../src/observation-http";
|
|
import { KeyedExecutor } from "../src/queue";
|
|
import type { BskRunner, BskRunOptions, BskRunResult } from "../src/runner";
|
|
import { SessionRegistry } from "../src/sessions";
|
|
|
|
const OPTIONS = { enabled: true, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 };
|
|
|
|
/** Manual timer queue + controllable clock. */
|
|
function fakeScheduler() {
|
|
let now = 1_000_000;
|
|
const timers: { handle: object; fn: () => void; ms: number }[] = [];
|
|
const scheduler: ObservationScheduler & {
|
|
advance: (ms: number) => void;
|
|
runNext: () => void;
|
|
pending: () => number[];
|
|
} = {
|
|
now: () => now,
|
|
setTimeout: (fn, ms) => {
|
|
const handle = { fn, ms };
|
|
timers.push({ handle, fn, ms });
|
|
return handle;
|
|
},
|
|
clearTimeout: (h) => {
|
|
const index = timers.findIndex((t) => t.handle === h);
|
|
if (index >= 0) timers.splice(index, 1);
|
|
},
|
|
advance: (ms) => {
|
|
now += ms;
|
|
},
|
|
runNext: () => {
|
|
const next = timers.shift();
|
|
next?.fn();
|
|
},
|
|
pending: () => timers.map((t) => t.ms),
|
|
};
|
|
return scheduler;
|
|
}
|
|
|
|
interface FakeRunner extends BskRunner {
|
|
calls: { args: string[]; options: BskRunOptions }[];
|
|
killed: string[];
|
|
}
|
|
|
|
/** Runner that answers screenshot with a real temp PNG and records kills. */
|
|
function fakeRunner(
|
|
opts: { screenshotFails?: boolean; screenshotNotFound?: boolean; killCount?: number } = {},
|
|
): FakeRunner {
|
|
const calls: FakeRunner["calls"] = [];
|
|
const killed: string[] = [];
|
|
return {
|
|
calls,
|
|
killed,
|
|
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
|
calls.push({ args, options });
|
|
if (args[0] === "screenshot") {
|
|
if (opts.screenshotNotFound) {
|
|
return {
|
|
code: 4,
|
|
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
|
|
stderr: "",
|
|
timedOut: false,
|
|
aborted: false,
|
|
};
|
|
}
|
|
if (opts.screenshotFails) {
|
|
return { code: 1, stdout: "", stderr: "boom", timedOut: false, aborted: false };
|
|
}
|
|
const outIndex = args.indexOf("--out") + 1;
|
|
const out = args[outIndex];
|
|
writeFileSync(out, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
return {
|
|
code: 0,
|
|
stdout: JSON.stringify({
|
|
tab_id: 7,
|
|
width: 4,
|
|
height: 4,
|
|
format: "png",
|
|
path: out,
|
|
byte_size: 4,
|
|
}),
|
|
stderr: "",
|
|
timedOut: false,
|
|
aborted: false,
|
|
};
|
|
}
|
|
return { code: 0, stdout: "{}", stderr: "", timedOut: false, aborted: false };
|
|
},
|
|
killAll() {},
|
|
killFor(tag: string) {
|
|
killed.push(tag);
|
|
return opts.killCount ?? 1;
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeCtx(attachments?: unknown) {
|
|
return { get: (key: string) => (key === "attachments" ? attachments : undefined) } as never;
|
|
}
|
|
|
|
function setup(opts: {
|
|
runner?: FakeRunner;
|
|
attachments?: unknown;
|
|
registry?: SessionRegistry;
|
|
scheduler?: ReturnType<typeof fakeScheduler>;
|
|
}) {
|
|
const registry = opts.registry ?? new SessionRegistry(5);
|
|
const runner = opts.runner ?? fakeRunner();
|
|
const scheduler = opts.scheduler ?? fakeScheduler();
|
|
const attachments =
|
|
"attachments" in opts
|
|
? opts.attachments
|
|
: {
|
|
saveImage: async (input: { data: Uint8Array; name?: string }) => ({
|
|
attachmentId: "att-1",
|
|
mediaType: "image/png",
|
|
bytes: input.data.byteLength,
|
|
width: 4,
|
|
height: 4,
|
|
name: input.name,
|
|
}),
|
|
readImage: async (ref: { attachmentId: unknown }) => ({
|
|
data: new Uint8Array([1, 2, 3]),
|
|
attachment: ref,
|
|
}),
|
|
};
|
|
const service = new ObservationService({
|
|
ctx: fakeCtx(attachments),
|
|
runner,
|
|
registry,
|
|
queue: new KeyedExecutor(),
|
|
options: OPTIONS,
|
|
scheduler,
|
|
});
|
|
const events: ObservationEvent[] = [];
|
|
service.subscribe((event) => events.push(event));
|
|
return { service, registry, runner, scheduler, events };
|
|
}
|
|
|
|
/** Drive the registry through a real start so the session is owned. */
|
|
function own(registry: SessionRegistry, sessionId: string): void {
|
|
registry.reserveStart();
|
|
registry.completeStart({ sessionId, startedAtMs: 1 });
|
|
}
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
// setImmediate yields to the poll phase so real fs reads inside capture() settle.
|
|
for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
/** Poll until the condition holds (capture completion is real async I/O). */
|
|
async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
const start = Date.now();
|
|
while (!cond()) {
|
|
if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout");
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
describe("state machine", () => {
|
|
it("tracks add/action/end/url/remove and emits upsert/remove/reset events", () => {
|
|
const { service, events } = setup({});
|
|
service.addSession("s1");
|
|
expect(service.getState()).toEqual([{ sessionId: "s1", action: "idle", since: 1_000_000 }]);
|
|
service.beginAction("s1", "navigating");
|
|
expect(service.getState()[0].action).toBe("navigating");
|
|
service.endAction("s1");
|
|
expect(service.getState()[0].action).toBe("idle");
|
|
service.setUrl("s1", "https://example.com");
|
|
expect(service.getState()[0].url).toBe("https://example.com");
|
|
service.endAction("s1", "click failed: no ref");
|
|
expect(service.getState()[0].lastError).toBe("click failed: no ref");
|
|
service.removeSession("s1");
|
|
expect(service.getState()).toEqual([]);
|
|
expect(events.map((e) => e.type)).toEqual([
|
|
"upsert", // add
|
|
"upsert", // begin
|
|
"upsert", // end
|
|
"upsert", // setUrl
|
|
"upsert", // end with error
|
|
"remove",
|
|
]);
|
|
service.dispose();
|
|
expect(events[events.length - 1].type).toBe("reset");
|
|
});
|
|
|
|
it("ignores instrumentation for unknown sessions (owned-only by construction)", () => {
|
|
const { service } = setup({});
|
|
service.beginAction("foreign", "clicking");
|
|
service.endAction("foreign");
|
|
service.setUrl("foreign", "https://x");
|
|
expect(service.getState()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("thumbnail cadence", () => {
|
|
it("captures immediately on add and after action end, then fast-cadences while active", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ runner, scheduler });
|
|
const captures = () => runner.calls.filter((c) => c.args[0] === "screenshot").length;
|
|
service.addSession("s1");
|
|
// add schedules an immediate (0ms) capture.
|
|
expect(scheduler.pending()).toEqual([0]);
|
|
scheduler.runNext();
|
|
await waitFor(() => captures() === 1 && scheduler.pending().length === 1);
|
|
expect(service.getState()[0].thumbnailAttachmentId).toBe("att-1");
|
|
// Just captured with fresh activity: next frame on the fast cadence.
|
|
expect(scheduler.pending()).toEqual([1500]);
|
|
scheduler.runNext();
|
|
await waitFor(() => captures() === 2 && scheduler.pending().length === 1);
|
|
expect(scheduler.pending()).toEqual([1500]);
|
|
});
|
|
|
|
it("downclocks to the idle cadence when the session has been quiet", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner();
|
|
const { service, scheduler: sch } = setup({ scheduler, runner });
|
|
const captures = () => runner.calls.filter((c) => c.args[0] === "screenshot").length;
|
|
service.addSession("s1");
|
|
sch.runNext();
|
|
await waitFor(() => captures() === 1 && sch.pending().length === 1);
|
|
// Fresh activity: fast cadence.
|
|
expect(sch.pending()).toEqual([1500]);
|
|
// Go quiet beyond the idle window before the next frame fires.
|
|
sch.advance(9000);
|
|
sch.runNext();
|
|
await waitFor(() => captures() === 2 && sch.pending().length === 1);
|
|
expect(sch.pending()).toEqual([8000]);
|
|
});
|
|
|
|
it("backs off to the idle cadence after repeated capture failures, keeping the old frame", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner({ screenshotFails: true });
|
|
const { service } = setup({ runner, scheduler });
|
|
const captures = () => runner.calls.filter((c) => c.args[0] === "screenshot").length;
|
|
service.addSession("s1");
|
|
for (let i = 1; i <= 4; i++) {
|
|
scheduler.runNext();
|
|
await waitFor(() => captures() === i && scheduler.pending().length === 1);
|
|
}
|
|
// 4 attempts (1 immediate + 3 fast), then backoff to the idle cadence.
|
|
expect(captures()).toBe(4);
|
|
expect(scheduler.pending()).toEqual([8000]);
|
|
expect(service.getState()[0].thumbnailAttachmentId).toBeUndefined();
|
|
});
|
|
|
|
it("stays silent without an attachment store (headless composition)", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ runner, scheduler, attachments: undefined });
|
|
service.addSession("s1");
|
|
scheduler.runNext();
|
|
await flushMicrotasks();
|
|
expect(runner.calls.filter((c) => c.args[0] === "screenshot")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("observation traffic isolation", () => {
|
|
it("captures never emit action changes nor touch the registry", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const registry = new SessionRegistry(5);
|
|
own(registry, "s1");
|
|
const { service, events } = setup({ registry, scheduler });
|
|
service.addSession("s1");
|
|
events.length = 0;
|
|
scheduler.runNext();
|
|
await waitFor(() => events.filter((e) => e.type === "upsert").length === 1);
|
|
// The capture upsert carries the thumbnail but the action stays idle…
|
|
const captureEvents = events.filter((e) => e.type === "upsert");
|
|
expect(captureEvents).toHaveLength(1);
|
|
const captureEvent = captureEvents[0];
|
|
if (captureEvent.type !== "upsert") throw new Error("unreachable");
|
|
expect(captureEvent.session?.action).toBe("idle");
|
|
expect(captureEvent.session?.thumbnailAttachmentId).toBe("att-1");
|
|
// …and the registry's current pointer never moved through observation.
|
|
expect(registry.current()).toBe("s1");
|
|
expect(registry.list()[0].startedAtMs).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("interrupt routing", () => {
|
|
it("interrupts the current session by default", () => {
|
|
const registry = new SessionRegistry(5);
|
|
own(registry, "s1");
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ registry, runner });
|
|
expect(service.interrupt()).toBe(true);
|
|
expect(runner.killed).toEqual(["s1"]);
|
|
});
|
|
|
|
it("interrupts the specified owned session", () => {
|
|
const registry = new SessionRegistry(5);
|
|
own(registry, "s1");
|
|
own(registry, "s2");
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ registry, runner });
|
|
expect(service.interrupt("s1")).toBe(true);
|
|
expect(runner.killed).toEqual(["s1"]);
|
|
});
|
|
|
|
it("returns false with no current session and for foreign ids without killing", () => {
|
|
const registry = new SessionRegistry(5);
|
|
own(registry, "s1");
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ registry, runner });
|
|
expect(service.interrupt("foreign")).toBe(false);
|
|
registry.remove("s1");
|
|
expect(service.interrupt()).toBe(false);
|
|
expect(runner.killed).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("HTTP/SSE interface", () => {
|
|
interface RecordedRoute {
|
|
path: string;
|
|
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
}
|
|
|
|
function routeHarness() {
|
|
const routes = new Map<string, RecordedRoute>();
|
|
const webServer = {
|
|
register(route: RecordedRoute & { kind: string }) {
|
|
routes.set(route.path, route);
|
|
return () => routes.delete(route.path);
|
|
},
|
|
};
|
|
return { routes, webServer };
|
|
}
|
|
|
|
function fakeRes() {
|
|
const chunks: string[] = [];
|
|
const closeHandlers: (() => void)[] = [];
|
|
const res = {
|
|
status: 0,
|
|
writeHead(status: number) {
|
|
res.status = status;
|
|
return res;
|
|
},
|
|
write(chunk: string) {
|
|
chunks.push(chunk);
|
|
},
|
|
end(chunk?: string) {
|
|
if (chunk !== undefined) chunks.push(chunk);
|
|
},
|
|
on(event: string, fn: () => void) {
|
|
if (event === "close") closeHandlers.push(fn);
|
|
},
|
|
close: () => closeHandlers.forEach((fn) => fn()),
|
|
body: () => chunks.join(""),
|
|
};
|
|
return {
|
|
res: res as unknown as ServerResponse & {
|
|
status: number;
|
|
body: () => string;
|
|
close: () => void;
|
|
},
|
|
chunks,
|
|
};
|
|
}
|
|
|
|
/** Loopback GET/POST request stubs the fence accepts. */
|
|
function fakeReq(overrides: Record<string, unknown> = {}): IncomingMessage {
|
|
return {
|
|
method: "GET",
|
|
headers: { host: "127.0.0.1:3999" },
|
|
on: (event: string, fn: (chunk?: string) => void) => {
|
|
if (event === "data") fn(JSON.stringify({ sessionId: "s1" }));
|
|
if (event === "end") fn();
|
|
},
|
|
...overrides,
|
|
} as unknown as IncomingMessage;
|
|
}
|
|
|
|
it("serves state, streams events, and routes interrupt", async () => {
|
|
const registry = new SessionRegistry(5);
|
|
own(registry, "s1");
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ registry, runner });
|
|
service.addSession("s1");
|
|
|
|
const { routes, webServer } = routeHarness();
|
|
const ctx = { get: (key: string) => (key === "webServer" ? webServer : undefined) } as never;
|
|
const dispose = registerObservationRoutes(ctx, service);
|
|
|
|
// state
|
|
const stateRoute = routes.get("/bsk-observation/state");
|
|
expect(stateRoute).toBeDefined();
|
|
const stateRes = fakeRes();
|
|
await stateRoute?.handler(fakeReq(), stateRes.res as never);
|
|
expect(stateRes.res.status).toBe(200);
|
|
expect(JSON.parse(stateRes.res.body()).sessions[0].sessionId).toBe("s1");
|
|
|
|
// events (SSE): one write per service event
|
|
const eventsRoute = routes.get("/bsk-observation/events");
|
|
const eventsRes = fakeRes();
|
|
await eventsRoute?.handler(fakeReq(), eventsRes.res as never);
|
|
service.beginAction("s1", "clicking");
|
|
expect(eventsRes.res.body()).toContain('"action":"clicking"');
|
|
eventsRes.res.close();
|
|
|
|
// interrupt (POST {sessionId})
|
|
const interruptRoute = routes.get("/bsk-observation/interrupt");
|
|
const interruptRes = fakeRes();
|
|
const postReq = fakeReq({
|
|
method: "POST",
|
|
headers: { host: "127.0.0.1:3999", "content-type": "application/json" },
|
|
});
|
|
await interruptRoute?.handler(postReq, interruptRes.res as never);
|
|
expect(JSON.parse(interruptRes.res.body())).toEqual({ interrupted: true });
|
|
|
|
dispose();
|
|
expect(routes.size).toBe(0);
|
|
service.dispose();
|
|
});
|
|
|
|
it("fences off non-loopback, cross-site, and simple-request traffic", async () => {
|
|
const registry = new SessionRegistry(5);
|
|
own(registry, "s1");
|
|
const { service } = setup({ registry });
|
|
service.addSession("s1");
|
|
const { routes, webServer } = routeHarness();
|
|
const ctx = { get: (key: string) => (key === "webServer" ? webServer : undefined) } as never;
|
|
const dispose = registerObservationRoutes(ctx, service);
|
|
const stateRoute = routes.get("/bsk-observation/state");
|
|
const interruptRoute = routes.get("/bsk-observation/interrupt");
|
|
|
|
const getState = async (headers: Record<string, string>) => {
|
|
const res = fakeRes();
|
|
await stateRoute?.handler(fakeReq({ headers }), res.res as never);
|
|
return res.res.status;
|
|
};
|
|
|
|
// Host must be a loopback authority.
|
|
expect(await getState({ host: "192.168.1.20:3999" })).toBe(403);
|
|
expect(await getState({ host: "attacker.example" })).toBe(403);
|
|
expect(await getState({ host: "localhost:3999" })).toBe(200);
|
|
expect(await getState({ host: "[::1]:3999" })).toBe(200);
|
|
// A present Origin must match the Host (DNS-rebinding reads die here).
|
|
expect(await getState({ host: "127.0.0.1:3999", origin: "http://evil.example" })).toBe(403);
|
|
expect(await getState({ host: "127.0.0.1:3999", origin: "http://127.0.0.1:3999" })).toBe(200);
|
|
// Explicitly cross-site fetches are refused even without an Origin.
|
|
expect(await getState({ host: "127.0.0.1:3999", "sec-fetch-site": "cross-site" })).toBe(403);
|
|
expect(await getState({ host: "127.0.0.1:3999", "sec-fetch-site": "same-origin" })).toBe(200);
|
|
|
|
// POST must be application/json — a cross-site simple request can never comply.
|
|
const post = async (contentType: string | undefined) => {
|
|
const res = fakeRes();
|
|
const req = fakeReq({
|
|
method: "POST",
|
|
headers: {
|
|
host: "127.0.0.1:3999",
|
|
...(contentType !== undefined ? { "content-type": contentType } : {}),
|
|
},
|
|
});
|
|
await interruptRoute?.handler(req, res.res as never);
|
|
return res.res.status;
|
|
};
|
|
expect(await post("text/plain")).toBe(403);
|
|
expect(await post("application/x-www-form-urlencoded")).toBe(403);
|
|
expect(await post(undefined)).toBe(403);
|
|
expect(await post("application/json; charset=utf-8")).toBe(200);
|
|
|
|
dispose();
|
|
service.dispose();
|
|
});
|
|
|
|
it("registers nothing when no webServer is mounted", () => {
|
|
const { service } = setup({});
|
|
const ctx = { get: () => undefined } as never;
|
|
const dispose = registerObservationRoutes(ctx, service);
|
|
expect(typeof dispose).toBe("function");
|
|
dispose();
|
|
service.dispose();
|
|
});
|
|
});
|
|
|
|
describe("availability and dead sessions", () => {
|
|
it("flips availability off after repeated global failures and back on success", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner({ screenshotFails: true });
|
|
const { service, events } = setup({ runner, scheduler });
|
|
service.addSession("s1");
|
|
for (let i = 1; i <= 3; i++) {
|
|
scheduler.runNext();
|
|
// the frame's scratch-file unlink lands before the next timer is queued
|
|
await waitFor(
|
|
() =>
|
|
runner.calls.filter((c) => c.args[0] === "screenshot").length === i &&
|
|
scheduler.pending().length === 1,
|
|
);
|
|
}
|
|
expect(service.isAvailable()).toBe(false);
|
|
expect(events.some((e) => e.type === "availability" && e.available === false)).toBe(true);
|
|
service.dispose();
|
|
});
|
|
|
|
it("marks a session dead on session_not_found and stops asking for frames", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner({ screenshotNotFound: true });
|
|
const { service, events } = setup({ runner, scheduler });
|
|
service.addSession("s1");
|
|
scheduler.runNext();
|
|
await waitFor(() => events.some((e) => e.type === "upsert" && e.session?.dead === true));
|
|
expect(service.getState()[0].dead).toBe(true);
|
|
expect(service.getState()[0].action).toBe("idle");
|
|
// No further captures are scheduled for a dead session.
|
|
expect(scheduler.pending()).toEqual([]);
|
|
// Instrumentation is dropped for dead sessions.
|
|
service.beginAction("s1", "clicking");
|
|
expect(service.getState()[0].action).toBe("idle");
|
|
// A dead session leaves cleanly.
|
|
service.removeSession("s1");
|
|
expect(service.getState()).toEqual([]);
|
|
});
|
|
|
|
it("clears lastError on the next action and keeps it on failure", () => {
|
|
const { service } = setup({});
|
|
service.addSession("s1");
|
|
service.endAction("s1", "click failed");
|
|
expect(service.getState()[0].lastError).toBe("click failed");
|
|
service.beginAction("s1", "clicking");
|
|
expect(service.getState()[0].lastError).toBeUndefined();
|
|
service.endAction("s1");
|
|
expect(service.getState()[0].lastError).toBeUndefined();
|
|
service.endAction("s1", "fill failed");
|
|
expect(service.getState()[0].lastError).toBe("fill failed");
|
|
service.dispose();
|
|
});
|
|
});
|
|
|
|
describe("thumbnail read-back", () => {
|
|
it("serves captured frames through readThumbnail and 404s unknown ids", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ runner, scheduler });
|
|
service.addSession("s1");
|
|
scheduler.runNext();
|
|
await waitFor(() => service.getState()[0]?.thumbnailAttachmentId !== undefined);
|
|
const frame = await service.readThumbnail("att-1");
|
|
expect(frame?.mediaType).toBe("image/png");
|
|
expect([...(frame?.data ?? [])]).toEqual([1, 2, 3]);
|
|
expect(await service.readThumbnail("nope")).toBeUndefined();
|
|
service.dispose();
|
|
});
|
|
});
|
|
|
|
describe("actionForLabel", () => {
|
|
it("maps tool labels onto observation verbs", () => {
|
|
expect(actionForLabel("navigate")).toBe("navigating");
|
|
expect(actionForLabel("screenshot")).toBe("capturing");
|
|
expect(actionForLabel("session start")).toBe("starting");
|
|
});
|
|
});
|
|
|
|
describe("scratch files and thumbnail references", () => {
|
|
it("reuses one scratch path per session and deletes it after reading", async () => {
|
|
const scheduler = fakeScheduler();
|
|
const runner = fakeRunner();
|
|
const { service } = setup({ runner, scheduler });
|
|
service.addSession("s1");
|
|
// first frame (scheduled with delay 0)
|
|
scheduler.runNext();
|
|
await waitFor(
|
|
() =>
|
|
service.getState()[0]?.thumbnailAttachmentId !== undefined &&
|
|
scheduler.pending().length === 1,
|
|
);
|
|
const firstOut = runner.calls.filter((c) => c.args[0] === "screenshot").at(-1)?.args;
|
|
const pathOf = (args: string[]) => args[args.indexOf("--out") + 1];
|
|
expect(pathOf(firstOut ?? [])).toBe(join(tmpdir(), "bsk-obs-s1.png"));
|
|
expect(existsSync(pathOf(firstOut ?? []))).toBe(false);
|
|
// second frame: same path, deleted again
|
|
service.endAction("s1");
|
|
scheduler.runNext();
|
|
await waitFor(
|
|
() =>
|
|
runner.calls.filter((c) => c.args[0] === "screenshot").length >= 2 &&
|
|
scheduler.pending().length === 1,
|
|
);
|
|
const secondOut = runner.calls.filter((c) => c.args[0] === "screenshot").at(-1)?.args;
|
|
expect(pathOf(secondOut ?? [])).toBe(join(tmpdir(), "bsk-obs-s1.png"));
|
|
await waitFor(() => !existsSync(pathOf(secondOut ?? [])));
|
|
service.dispose();
|
|
});
|
|
|
|
it("drops the replaced frame reference and clears it on remove", async () => {
|
|
let counter = 0;
|
|
const attachments = {
|
|
saveImage: async (input: { data: Uint8Array; name?: string }) => {
|
|
counter += 1;
|
|
return {
|
|
attachmentId: `att-${counter}`,
|
|
mediaType: "image/png",
|
|
bytes: input.data.byteLength,
|
|
width: 4,
|
|
height: 4,
|
|
name: input.name,
|
|
};
|
|
},
|
|
readImage: async (ref: { attachmentId: unknown }) => ({
|
|
data: new Uint8Array([1, 2, 3]),
|
|
attachment: ref,
|
|
}),
|
|
};
|
|
const scheduler = fakeScheduler();
|
|
const { service } = setup({ scheduler, attachments });
|
|
service.addSession("s1");
|
|
scheduler.runNext();
|
|
await waitFor(
|
|
() =>
|
|
service.getState()[0]?.thumbnailAttachmentId === "att-1" &&
|
|
scheduler.pending().length === 1,
|
|
);
|
|
service.endAction("s1");
|
|
scheduler.runNext();
|
|
await waitFor(
|
|
() =>
|
|
service.getState()[0]?.thumbnailAttachmentId === "att-2" &&
|
|
scheduler.pending().length === 1,
|
|
);
|
|
// the replaced frame's read path is gone
|
|
expect(await service.readThumbnail("att-1")).toBeUndefined();
|
|
expect((await service.readThumbnail("att-2"))?.mediaType).toBe("image/png");
|
|
// removing the session clears its live reference too
|
|
service.removeSession("s1");
|
|
expect(await service.readThumbnail("att-2")).toBeUndefined();
|
|
service.dispose();
|
|
});
|
|
});
|