@omercnet/paseo-shared-browser 1.2.1 → 1.2.2-next.237.1
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/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1201 -0
- package/client/browser-input-queue.ts +505 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +370 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +991 -0
- package/client/use-browser-video.ts +474 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -0
- package/npm-shrinkwrap.json +1834 -0
- package/package.json +13 -8
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-dependencies.mjs +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +305 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +80 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Integration evidence for the current runtime's independent idle and original-page cleanup fences. */
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { AgentBrowserRuntime } from "./agent-browser-runtime";
|
|
4
|
+
import { SessionManager } from "./browser-policy";
|
|
5
|
+
|
|
6
|
+
afterEach(() => vi.useRealTimers());
|
|
7
|
+
|
|
8
|
+
/** Real policy and native timers share fake elapsed time; only Chromium's CDP transport is stubbed. */
|
|
9
|
+
async function fixture() {
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
vi.setSystemTime(1_000);
|
|
12
|
+
const runtime = new AgentBrowserRuntime({
|
|
13
|
+
binaryPath: "/tmp/unlaunched-browser",
|
|
14
|
+
executablePath: "/tmp/unlaunched-chromium",
|
|
15
|
+
profilePath: "/tmp/uncreated-profile",
|
|
16
|
+
ipcDirectory: "/tmp/uncreated-ipc",
|
|
17
|
+
session: "discrete-lifetime-fixture",
|
|
18
|
+
});
|
|
19
|
+
const publications: string[] = [];
|
|
20
|
+
const page = {
|
|
21
|
+
send: async (_method: string, params: Record<string, unknown> = {}) => {
|
|
22
|
+
if (typeof params.type === "string") publications.push(params.type);
|
|
23
|
+
return {};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const native = runtime as unknown as {
|
|
27
|
+
page: typeof page;
|
|
28
|
+
requirePage: () => Promise<typeof page>;
|
|
29
|
+
attachmentGeneration: number;
|
|
30
|
+
documentGeneration: number;
|
|
31
|
+
};
|
|
32
|
+
Object.assign(runtime, {
|
|
33
|
+
page,
|
|
34
|
+
emulationAppliedPage: page,
|
|
35
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false, touch: false },
|
|
36
|
+
});
|
|
37
|
+
native.requirePage = async () => native.page;
|
|
38
|
+
let stateDelay = 0;
|
|
39
|
+
let afterDownDelay = 0;
|
|
40
|
+
let ids = 0;
|
|
41
|
+
const manager = new SessionManager({
|
|
42
|
+
validateWorkspace: async () => true,
|
|
43
|
+
issueToken: () => String(++ids).padStart(32, "0"),
|
|
44
|
+
client: {
|
|
45
|
+
connect: async () => ({ epoch: 1 }),
|
|
46
|
+
ensureWorkspace: async (workspaceId) => ({
|
|
47
|
+
workspaceId,
|
|
48
|
+
runtimeId: "r".repeat(32),
|
|
49
|
+
createdAt: 1,
|
|
50
|
+
}),
|
|
51
|
+
archiveWorkspace: async () => {},
|
|
52
|
+
closeWorkspace: async () => {},
|
|
53
|
+
disconnect: () => {},
|
|
54
|
+
requestWorkspace: async (_workspace, operation, input) => {
|
|
55
|
+
const data = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
56
|
+
if (operation === "identity") return { userAgent: "Fixture" };
|
|
57
|
+
if (operation === "state") {
|
|
58
|
+
await vi.advanceTimersByTimeAsync(stateDelay);
|
|
59
|
+
return {
|
|
60
|
+
url: "https://fixture.invalid/",
|
|
61
|
+
title: "Fixture",
|
|
62
|
+
inputGeneration: `${native.attachmentGeneration}:${native.documentGeneration}`,
|
|
63
|
+
canGoBack: false,
|
|
64
|
+
canGoForward: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (operation === "frame") {
|
|
68
|
+
return {
|
|
69
|
+
dataBase64: "eA==",
|
|
70
|
+
byteLength: 1,
|
|
71
|
+
width: 1280,
|
|
72
|
+
height: 800,
|
|
73
|
+
capturedAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const gestureId = String(data.gestureId);
|
|
77
|
+
if (operation === "input.begin") {
|
|
78
|
+
await runtime.beginLiveInput(gestureId, String(data.expectedInputGeneration));
|
|
79
|
+
} else if (operation === "input.end") {
|
|
80
|
+
await runtime.endLiveInput(gestureId);
|
|
81
|
+
} else if (operation === "mouse.move") {
|
|
82
|
+
await runtime.mouseMove(Number(data.x), Number(data.y), gestureId);
|
|
83
|
+
} else if (operation === "mouse.down") {
|
|
84
|
+
await runtime.mouseDown(Number(data.x), Number(data.y), "left", 1, gestureId);
|
|
85
|
+
await vi.advanceTimersByTimeAsync(afterDownDelay);
|
|
86
|
+
} else if (operation === "mouse.up") {
|
|
87
|
+
await runtime.mouseUp(Number(data.x), Number(data.y), "left", 1, gestureId);
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
await manager.connect();
|
|
94
|
+
const { viewerToken } = await manager.attach("owned", "Fixture");
|
|
95
|
+
const { controlToken } = await manager.acquireControl(viewerToken);
|
|
96
|
+
const capture = await manager.capture(viewerToken);
|
|
97
|
+
return {
|
|
98
|
+
manager,
|
|
99
|
+
publications,
|
|
100
|
+
setDelays: (metadata: number, afterDown: number) => {
|
|
101
|
+
stateDelay = metadata;
|
|
102
|
+
afterDownDelay = afterDown;
|
|
103
|
+
},
|
|
104
|
+
send: () =>
|
|
105
|
+
manager.sendInput({
|
|
106
|
+
viewerToken,
|
|
107
|
+
controlToken,
|
|
108
|
+
expected: {
|
|
109
|
+
sessionId: capture.state.sessionId,
|
|
110
|
+
runtimeId: capture.state.runtimeId!,
|
|
111
|
+
bridgeEpoch: capture.state.bridgeEpoch!,
|
|
112
|
+
navigationGeneration: capture.state.navigationGeneration,
|
|
113
|
+
viewportGeneration: capture.state.viewportGeneration,
|
|
114
|
+
},
|
|
115
|
+
target: capture.frame!,
|
|
116
|
+
event: {
|
|
117
|
+
kind: "click",
|
|
118
|
+
button: "left",
|
|
119
|
+
clickCount: 1,
|
|
120
|
+
point: { x: 100, y: 100, width: 1280, height: 800 },
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it("finishes one click exceeding five seconds overall with each native idle gap bounded", async () => {
|
|
127
|
+
const f = await fixture();
|
|
128
|
+
try {
|
|
129
|
+
f.setDelays(2_500, 0);
|
|
130
|
+
const began = Date.now();
|
|
131
|
+
await expect(f.send()).resolves.toHaveProperty("state");
|
|
132
|
+
expect(Date.now() - began).toBeGreaterThan(5_000);
|
|
133
|
+
expect(f.publications).toEqual(["mouseMoved", "mousePressed", "mouseReleased"]);
|
|
134
|
+
} finally {
|
|
135
|
+
f.manager.disconnect();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("retains native idle expiry, releases the original press once and refuses replay", async () => {
|
|
140
|
+
const f = await fixture();
|
|
141
|
+
try {
|
|
142
|
+
f.setDelays(0, 6_000);
|
|
143
|
+
await expect(f.send()).rejects.toThrow("attachment changed");
|
|
144
|
+
expect(f.publications).toEqual(["mouseMoved", "mousePressed", "mouseReleased"]);
|
|
145
|
+
await expect(f.send()).rejects.toThrow("frame is stale");
|
|
146
|
+
expect(f.publications).toHaveLength(3);
|
|
147
|
+
} finally {
|
|
148
|
+
f.manager.disconnect();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { browserStateSchema } from "../shared/browser";
|
|
3
|
+
import { type JsonValue, RuntimeLostError } from "./runtime-protocol";
|
|
4
|
+
import { RuntimeSupervisor } from "./supervisor";
|
|
5
|
+
|
|
6
|
+
/** Only an explicit human attach replaces a runtime whose private display died, behind a new fence. */
|
|
7
|
+
function harness() {
|
|
8
|
+
const events: string[] = [];
|
|
9
|
+
const control = {
|
|
10
|
+
created: 0,
|
|
11
|
+
lostRuntime: 0,
|
|
12
|
+
genericFailure: false,
|
|
13
|
+
stopFails: false,
|
|
14
|
+
clock: 1_000,
|
|
15
|
+
};
|
|
16
|
+
const supervisor = new RuntimeSupervisor({
|
|
17
|
+
now: () => control.clock,
|
|
18
|
+
owner: {
|
|
19
|
+
create: async () => {
|
|
20
|
+
control.created += 1;
|
|
21
|
+
events.push(`create:${control.created}`);
|
|
22
|
+
return {
|
|
23
|
+
runtimeId: `${"r".repeat(30)}${String(control.created).padStart(2, "0")}`,
|
|
24
|
+
id: control.created,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
request: async (runtime, operation) => {
|
|
28
|
+
events.push(`${operation}:${runtime.id}`);
|
|
29
|
+
if (control.genericFailure) throw new Error("fixture generic failure");
|
|
30
|
+
if (runtime.id === control.lostRuntime)
|
|
31
|
+
throw new RuntimeLostError("Private browser display ended");
|
|
32
|
+
if (operation === "identity") return { userAgent: "Fixture Chromium" };
|
|
33
|
+
if (operation === "state")
|
|
34
|
+
return {
|
|
35
|
+
url: "https://fixture.invalid",
|
|
36
|
+
title: "Fixture",
|
|
37
|
+
canGoBack: false,
|
|
38
|
+
canGoForward: false,
|
|
39
|
+
inputGeneration: "0:0",
|
|
40
|
+
};
|
|
41
|
+
if (operation === "frame")
|
|
42
|
+
return {
|
|
43
|
+
dataBase64: "eA==",
|
|
44
|
+
byteLength: 1,
|
|
45
|
+
width: 1280,
|
|
46
|
+
height: 800,
|
|
47
|
+
capturedAt: new Date(control.clock).toISOString(),
|
|
48
|
+
};
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
51
|
+
stop: async (runtime) => {
|
|
52
|
+
events.push(`stop:${runtime.id}`);
|
|
53
|
+
if (control.stopFails) throw new Error("fixture stop unconfirmed");
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const lease = supervisor.claimBridge("bridge");
|
|
58
|
+
const request = (operation: string, input: unknown) =>
|
|
59
|
+
supervisor.dispatch({
|
|
60
|
+
version: 2,
|
|
61
|
+
id: "request",
|
|
62
|
+
token: "unused-by-direct-fixture",
|
|
63
|
+
bridgeId: "bridge",
|
|
64
|
+
epoch: lease.epoch,
|
|
65
|
+
method: "browser.request",
|
|
66
|
+
operation,
|
|
67
|
+
input: input as JsonValue,
|
|
68
|
+
});
|
|
69
|
+
const attach = async () =>
|
|
70
|
+
(await request("attach", { workspaceId: "workspace", viewerLabel: "Viewer" })) as {
|
|
71
|
+
viewerToken: string;
|
|
72
|
+
state: unknown;
|
|
73
|
+
};
|
|
74
|
+
const count = (prefix: string) => events.filter((event) => event.startsWith(prefix)).length;
|
|
75
|
+
return { supervisor, lease, events, control, request, attach, count };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("display loss recovery", () => {
|
|
79
|
+
it("recreates a lost runtime on the next attach and rejects every old attachment", async () => {
|
|
80
|
+
const h = harness();
|
|
81
|
+
try {
|
|
82
|
+
const first = await h.attach();
|
|
83
|
+
const control = (await h.request("acquire-control", { viewerToken: first.viewerToken })) as {
|
|
84
|
+
controlToken: string;
|
|
85
|
+
};
|
|
86
|
+
const before = browserStateSchema.parse(first.state);
|
|
87
|
+
|
|
88
|
+
h.control.lostRuntime = 1;
|
|
89
|
+
const failed = (await h.request("status", { viewerToken: first.viewerToken })) as {
|
|
90
|
+
state: { status: string };
|
|
91
|
+
};
|
|
92
|
+
expect(failed.state.status).toBe("error");
|
|
93
|
+
// Plain polling never restarts the browser.
|
|
94
|
+
expect(h.control.created).toBe(1);
|
|
95
|
+
expect(h.count("stop")).toBe(0);
|
|
96
|
+
|
|
97
|
+
const second = await h.attach();
|
|
98
|
+
const after = browserStateSchema.parse(second.state);
|
|
99
|
+
expect(after.status).toBe("ready");
|
|
100
|
+
expect(after.sessionId).not.toBe(before.sessionId);
|
|
101
|
+
expect(after.runtimeId).not.toBe(before.runtimeId);
|
|
102
|
+
expect(h.events.indexOf("stop:1")).toBeGreaterThan(-1);
|
|
103
|
+
expect(h.events.indexOf("stop:1")).toBeLessThan(h.events.indexOf("create:2"));
|
|
104
|
+
expect(h.events.some((event) => event.startsWith("mouse") || event === "navigate:1")).toBe(
|
|
105
|
+
false,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await expect(h.request("status", { viewerToken: first.viewerToken })).rejects.toThrow();
|
|
109
|
+
await expect(
|
|
110
|
+
h.request("input", {
|
|
111
|
+
viewerToken: first.viewerToken,
|
|
112
|
+
controlToken: control.controlToken,
|
|
113
|
+
expected: {
|
|
114
|
+
sessionId: before.sessionId,
|
|
115
|
+
runtimeId: before.runtimeId,
|
|
116
|
+
navigationGeneration: before.navigationGeneration,
|
|
117
|
+
viewportGeneration: before.viewportGeneration,
|
|
118
|
+
},
|
|
119
|
+
event: { kind: "move", point: { x: 1, y: 1 } },
|
|
120
|
+
}),
|
|
121
|
+
).rejects.toThrow();
|
|
122
|
+
await expect(h.request("status", { viewerToken: second.viewerToken })).resolves.toBeTruthy();
|
|
123
|
+
} finally {
|
|
124
|
+
await h.supervisor.stopAll();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("replaces once within the first attach after loss no earlier request observed", async () => {
|
|
129
|
+
const h = harness();
|
|
130
|
+
try {
|
|
131
|
+
const first = await h.attach();
|
|
132
|
+
const before = browserStateSchema.parse(first.state);
|
|
133
|
+
h.control.lostRuntime = 1; // nothing polled since the display died
|
|
134
|
+
|
|
135
|
+
const second = await h.attach();
|
|
136
|
+
const after = browserStateSchema.parse(second.state);
|
|
137
|
+
expect(after.status).toBe("ready");
|
|
138
|
+
expect(after.runtimeId).not.toBe(before.runtimeId);
|
|
139
|
+
expect(after.sessionId).not.toBe(before.sessionId);
|
|
140
|
+
expect(h.control.created).toBe(2);
|
|
141
|
+
expect(h.events.filter((event) => event.startsWith("stop"))).toEqual(["stop:1"]);
|
|
142
|
+
await expect(h.request("status", { viewerToken: first.viewerToken })).rejects.toThrow();
|
|
143
|
+
} finally {
|
|
144
|
+
await h.supervisor.stopAll();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not retry or replace on a generic error and never replays input", async () => {
|
|
149
|
+
const h = harness();
|
|
150
|
+
try {
|
|
151
|
+
await h.attach();
|
|
152
|
+
h.control.genericFailure = true;
|
|
153
|
+
const again = browserStateSchema.parse((await h.attach()).state);
|
|
154
|
+
expect(again.status).toBe("error");
|
|
155
|
+
expect(h.control.created).toBe(1);
|
|
156
|
+
expect(h.count("stop")).toBe(0);
|
|
157
|
+
} finally {
|
|
158
|
+
await h.supervisor.stopAll();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps implicit agent observation from replacing an existing lost runtime", async () => {
|
|
163
|
+
const h = harness();
|
|
164
|
+
const ticket = `agent_${"x".repeat(40)}`;
|
|
165
|
+
const agentRequest = (operation: "status" | "capture") =>
|
|
166
|
+
h.supervisor.dispatch({
|
|
167
|
+
id: "agent",
|
|
168
|
+
version: 2,
|
|
169
|
+
method: "agent.request",
|
|
170
|
+
ticket,
|
|
171
|
+
operation,
|
|
172
|
+
input: {},
|
|
173
|
+
}) as Promise<{ state: { status: string } }>;
|
|
174
|
+
try {
|
|
175
|
+
const human = await h.attach();
|
|
176
|
+
const base = {
|
|
177
|
+
version: 2 as const,
|
|
178
|
+
token: "unused",
|
|
179
|
+
bridgeId: "bridge",
|
|
180
|
+
epoch: h.lease.epoch,
|
|
181
|
+
};
|
|
182
|
+
await h.supervisor.dispatch({ ...base, id: "t1", method: "ticket.issue", ticket });
|
|
183
|
+
await h.supervisor.dispatch({
|
|
184
|
+
...base,
|
|
185
|
+
id: "t2",
|
|
186
|
+
method: "ticket.bind",
|
|
187
|
+
ticket,
|
|
188
|
+
agentId: "agent-one",
|
|
189
|
+
workspaceId: "workspace",
|
|
190
|
+
});
|
|
191
|
+
expect((await agentRequest("status")).state.status).toBe("ready");
|
|
192
|
+
|
|
193
|
+
// Initial binding path: another agent binds only after the runtime was lost.
|
|
194
|
+
h.control.lostRuntime = 1;
|
|
195
|
+
const lateTicket = `late_${"y".repeat(40)}`;
|
|
196
|
+
await h.supervisor.dispatch({
|
|
197
|
+
...base,
|
|
198
|
+
id: "t3",
|
|
199
|
+
method: "ticket.issue",
|
|
200
|
+
ticket: lateTicket,
|
|
201
|
+
});
|
|
202
|
+
await h.supervisor.dispatch({
|
|
203
|
+
...base,
|
|
204
|
+
id: "t4",
|
|
205
|
+
method: "ticket.bind",
|
|
206
|
+
ticket: lateTicket,
|
|
207
|
+
agentId: "agent-two",
|
|
208
|
+
workspaceId: "workspace",
|
|
209
|
+
});
|
|
210
|
+
const late = (await h.supervisor.dispatch({
|
|
211
|
+
id: "agent-late",
|
|
212
|
+
version: 2,
|
|
213
|
+
method: "agent.request",
|
|
214
|
+
ticket: lateTicket,
|
|
215
|
+
operation: "status",
|
|
216
|
+
input: {},
|
|
217
|
+
})) as unknown as { state: { status: string } };
|
|
218
|
+
expect(late.state.status).toBe("error");
|
|
219
|
+
|
|
220
|
+
// Expired-viewer path: the first agent's viewer lapses, then it observes again.
|
|
221
|
+
for (let step = 0; step < 3; step += 1) {
|
|
222
|
+
h.control.clock += 20_000; // beyond the 45 s viewer TTL, within each bridge lease
|
|
223
|
+
h.supervisor.heartbeat("bridge", h.lease.epoch);
|
|
224
|
+
}
|
|
225
|
+
expect((await agentRequest("status")).state.status).toBe("error");
|
|
226
|
+
await expect(agentRequest("capture")).rejects.toMatchObject({ code: "RUNTIME_LOST" });
|
|
227
|
+
expect(h.control.created).toBe(1);
|
|
228
|
+
expect(h.count("stop")).toBe(0);
|
|
229
|
+
|
|
230
|
+
// Only the explicit human reconnect recovers.
|
|
231
|
+
const recovered = browserStateSchema.parse((await h.attach()).state);
|
|
232
|
+
expect(recovered.status).toBe("ready");
|
|
233
|
+
expect(h.control.created).toBe(2);
|
|
234
|
+
expect(h.events.filter((event) => event.startsWith("stop"))).toEqual(["stop:1"]);
|
|
235
|
+
expect(human.viewerToken).toBeTruthy();
|
|
236
|
+
} finally {
|
|
237
|
+
await h.supervisor.stopAll();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("keeps a lost runtime whose stop failed away from implicit agent paths until explicit recovery", async () => {
|
|
242
|
+
const h = harness();
|
|
243
|
+
const ticket = `agent_${"x".repeat(40)}`;
|
|
244
|
+
const base = { version: 2 as const, token: "unused", bridgeId: "bridge", epoch: h.lease.epoch };
|
|
245
|
+
const agentRequest = (operation: "status" | "capture") =>
|
|
246
|
+
h.supervisor.dispatch({
|
|
247
|
+
id: "agent",
|
|
248
|
+
version: 2,
|
|
249
|
+
method: "agent.request",
|
|
250
|
+
ticket,
|
|
251
|
+
operation,
|
|
252
|
+
input: {},
|
|
253
|
+
});
|
|
254
|
+
try {
|
|
255
|
+
await h.attach();
|
|
256
|
+
h.control.lostRuntime = 1;
|
|
257
|
+
h.control.stopFails = true;
|
|
258
|
+
// The explicit reconnect discards its session, then cannot confirm the old runtime stopped.
|
|
259
|
+
await expect(h.attach()).rejects.toThrow("stop unconfirmed");
|
|
260
|
+
const stopsAfterFailure = h.count("stop");
|
|
261
|
+
expect(h.control.created).toBe(1);
|
|
262
|
+
|
|
263
|
+
await h.supervisor.dispatch({ ...base, id: "t1", method: "ticket.issue", ticket });
|
|
264
|
+
await h.supervisor.dispatch({
|
|
265
|
+
...base,
|
|
266
|
+
id: "t2",
|
|
267
|
+
method: "ticket.bind",
|
|
268
|
+
ticket,
|
|
269
|
+
agentId: "agent-one",
|
|
270
|
+
workspaceId: "workspace",
|
|
271
|
+
});
|
|
272
|
+
await expect(agentRequest("status")).rejects.toMatchObject({ code: "RUNTIME_LOST" });
|
|
273
|
+
await expect(agentRequest("capture")).rejects.toMatchObject({ code: "RUNTIME_LOST" });
|
|
274
|
+
// Protocol-level ensure carries no replacement authority either.
|
|
275
|
+
await expect(
|
|
276
|
+
h.supervisor.dispatch({
|
|
277
|
+
...base,
|
|
278
|
+
id: "ensure",
|
|
279
|
+
method: "workspace.ensure",
|
|
280
|
+
workspaceId: "workspace",
|
|
281
|
+
}),
|
|
282
|
+
).rejects.toMatchObject({ code: "RUNTIME_LOST" });
|
|
283
|
+
expect(h.count("stop")).toBe(stopsAfterFailure);
|
|
284
|
+
expect(h.control.created).toBe(1);
|
|
285
|
+
|
|
286
|
+
h.control.stopFails = false;
|
|
287
|
+
const recovered = browserStateSchema.parse((await h.attach()).state);
|
|
288
|
+
expect(recovered.status).toBe("ready");
|
|
289
|
+
expect(h.control.created).toBe(2);
|
|
290
|
+
} finally {
|
|
291
|
+
await h.supervisor.stopAll();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|