@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,245 @@
|
|
|
1
|
+
/** Execute the shipped extension program so rate admission is not a duplicate policy test. */
|
|
2
|
+
import { createContext, runInContext } from "node:vm";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { NATIVE_VIDEO_EXTENSION_SOURCE } from "./native-video-extension-source";
|
|
5
|
+
|
|
6
|
+
it("owns bitrate/FPS per encoder, actually rate-limits frames, and closes only the removed cohort", async () => {
|
|
7
|
+
const encoders: Encoder[] = [];
|
|
8
|
+
const constraints: unknown[] = [];
|
|
9
|
+
let deliver: (value: unknown) => void = () => {};
|
|
10
|
+
let context: ReturnType<typeof createContext>;
|
|
11
|
+
class Encoder {
|
|
12
|
+
static async isConfigSupported() {
|
|
13
|
+
return { supported: true };
|
|
14
|
+
}
|
|
15
|
+
configuration = { bitrate: 0, framerate: 0 };
|
|
16
|
+
calls = 0;
|
|
17
|
+
encoded: { timestamp: number; keyFrame: boolean }[] = [];
|
|
18
|
+
closed = false;
|
|
19
|
+
encodeQueueSize = 0;
|
|
20
|
+
constructor(readonly callbacks: { output(chunk: unknown, metadata: unknown): void }) {
|
|
21
|
+
encoders.push(this);
|
|
22
|
+
}
|
|
23
|
+
configure(value: typeof this.configuration) {
|
|
24
|
+
this.configuration = value;
|
|
25
|
+
}
|
|
26
|
+
close() {
|
|
27
|
+
this.closed = true;
|
|
28
|
+
}
|
|
29
|
+
reset() {}
|
|
30
|
+
encode(frame: { timestamp: number }, options: { keyFrame: boolean }) {
|
|
31
|
+
this.calls++;
|
|
32
|
+
this.encoded.push({ timestamp: frame.timestamp, keyFrame: options.keyFrame });
|
|
33
|
+
this.callbacks.output(
|
|
34
|
+
{
|
|
35
|
+
timestamp: frame.timestamp,
|
|
36
|
+
type: "key",
|
|
37
|
+
byteLength: 1,
|
|
38
|
+
copyTo: (bytes: Uint8Array) => bytes.fill(1),
|
|
39
|
+
},
|
|
40
|
+
{},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const reader = {
|
|
45
|
+
read: () =>
|
|
46
|
+
new Promise((resolve) => {
|
|
47
|
+
deliver = resolve;
|
|
48
|
+
}),
|
|
49
|
+
cancel: async () => {
|
|
50
|
+
deliver({ done: true });
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
context = createContext({
|
|
54
|
+
chrome: {
|
|
55
|
+
debugger: { getTargets: async () => [{ id: "exact", type: "page", tabId: 7 }] },
|
|
56
|
+
tabCapture: { getMediaStreamId: async () => "source" },
|
|
57
|
+
},
|
|
58
|
+
navigator: {
|
|
59
|
+
mediaDevices: {
|
|
60
|
+
getUserMedia: async (value: unknown) => {
|
|
61
|
+
constraints.push(value);
|
|
62
|
+
return {
|
|
63
|
+
getVideoTracks: () => [{ getSettings: () => ({ width: 1280, height: 800 }) }],
|
|
64
|
+
getTracks: () => [{ stop() {} }],
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
MediaStreamTrackProcessor: class {
|
|
70
|
+
readable = { getReader: () => reader };
|
|
71
|
+
},
|
|
72
|
+
VideoEncoder: Encoder,
|
|
73
|
+
nativeVideoPacket(json: string) {
|
|
74
|
+
const packet = JSON.parse(json);
|
|
75
|
+
runInContext(`acknowledge(${JSON.stringify(packet.streamId)},${packet.sequence})`, context);
|
|
76
|
+
},
|
|
77
|
+
performance: { now: () => 0 },
|
|
78
|
+
Uint8Array,
|
|
79
|
+
Map,
|
|
80
|
+
Set,
|
|
81
|
+
Object,
|
|
82
|
+
JSON,
|
|
83
|
+
Number,
|
|
84
|
+
Math,
|
|
85
|
+
btoa: (value: string) => Buffer.from(value, "binary").toString("base64"),
|
|
86
|
+
});
|
|
87
|
+
runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
|
|
88
|
+
await runInContext('startCapture("exact",1280,800,1)', context);
|
|
89
|
+
await runInContext('addEncoder("24000000:15","slow",24000000,15)', context);
|
|
90
|
+
await runInContext('addEncoder("5000000:30","medium",5000000,30)', context);
|
|
91
|
+
await runInContext('addEncoder("12000000:60","fast",12000000,60)', context);
|
|
92
|
+
expect(
|
|
93
|
+
encoders.map((value) => [value.configuration.bitrate, value.configuration.framerate]),
|
|
94
|
+
).toEqual([
|
|
95
|
+
[24_000_000, 15],
|
|
96
|
+
[5_000_000, 30],
|
|
97
|
+
[12_000_000, 60],
|
|
98
|
+
]);
|
|
99
|
+
expect(constraints[0]).toMatchObject({ video: { mandatory: { maxFrameRate: 60 } } });
|
|
100
|
+
await expect(
|
|
101
|
+
runInContext('addEncoder("2000000:30","fourth",2000000,30)', context),
|
|
102
|
+
).rejects.toThrow("limit");
|
|
103
|
+
for (let i = 0; i < 60; i++) {
|
|
104
|
+
deliver({
|
|
105
|
+
value: {
|
|
106
|
+
timestamp: 1_000_000 + Math.ceil((i * 1_000_000) / 60),
|
|
107
|
+
displayWidth: 1280,
|
|
108
|
+
displayHeight: 800,
|
|
109
|
+
close() {},
|
|
110
|
+
},
|
|
111
|
+
done: false,
|
|
112
|
+
});
|
|
113
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
114
|
+
}
|
|
115
|
+
expect(encoders.map((value) => value.calls)).toEqual([15, 30, 60]);
|
|
116
|
+
// A forced recovery key bypasses FPS thinning, while backlog remains a hard gate.
|
|
117
|
+
runInContext('requestKeyFrame("24000000:15")', context);
|
|
118
|
+
encoders[0]!.encodeQueueSize = 2;
|
|
119
|
+
const push = async (timestamp: number) => {
|
|
120
|
+
deliver({
|
|
121
|
+
value: { timestamp, displayWidth: 1280, displayHeight: 800, close() {} },
|
|
122
|
+
done: false,
|
|
123
|
+
});
|
|
124
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
125
|
+
};
|
|
126
|
+
await push(1_985_000);
|
|
127
|
+
expect(encoders.map((value) => value.calls)).toEqual([15, 30, 60]);
|
|
128
|
+
encoders[0]!.encodeQueueSize = 0;
|
|
129
|
+
await push(1_990_000);
|
|
130
|
+
expect(encoders.map((value) => value.calls)).toEqual([16, 30, 60]);
|
|
131
|
+
expect(encoders[0]!.encoded.at(-1)).toEqual({ timestamp: 1_990_000, keyFrame: true });
|
|
132
|
+
await push(1_991_000);
|
|
133
|
+
expect(encoders.map((value) => value.calls)).toEqual([16, 30, 60]);
|
|
134
|
+
|
|
135
|
+
runInContext('removeEncoder("5000000:30")', context);
|
|
136
|
+
expect(encoders.map((value) => value.closed)).toEqual([false, true, false]);
|
|
137
|
+
await runInContext('addEncoder("2000000:30","new",2000000,30)', context);
|
|
138
|
+
expect(encoders).toHaveLength(4);
|
|
139
|
+
await runInContext("stopCapture()", context);
|
|
140
|
+
expect(encoders.every((value) => value.closed)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Codec probes deliberately remain pending, as a timed-out CDP evaluation does. */
|
|
144
|
+
function allocationFixture() {
|
|
145
|
+
const support: ((value: { supported: boolean }) => void)[] = [];
|
|
146
|
+
const created: { closed: boolean; configure(value: unknown): void }[] = [];
|
|
147
|
+
let deliver: (result: unknown) => void = () => {};
|
|
148
|
+
const context = createContext({
|
|
149
|
+
chrome: {
|
|
150
|
+
debugger: { getTargets: async () => [{ id: "exact", type: "page", tabId: 7 }] },
|
|
151
|
+
tabCapture: { getMediaStreamId: async () => "source" },
|
|
152
|
+
},
|
|
153
|
+
navigator: {
|
|
154
|
+
mediaDevices: {
|
|
155
|
+
getUserMedia: async () => ({
|
|
156
|
+
getVideoTracks: () => [{ getSettings: () => ({}) }],
|
|
157
|
+
getTracks: () => [{ stop() {} }],
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
MediaStreamTrackProcessor: class {
|
|
162
|
+
readable = {
|
|
163
|
+
getReader: () => ({
|
|
164
|
+
read: () =>
|
|
165
|
+
new Promise((resolve) => {
|
|
166
|
+
deliver = resolve;
|
|
167
|
+
}),
|
|
168
|
+
cancel: async () => {
|
|
169
|
+
deliver({ done: true });
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
VideoEncoder: class {
|
|
175
|
+
static isConfigSupported() {
|
|
176
|
+
return new Promise((resolve) => support.push(resolve));
|
|
177
|
+
}
|
|
178
|
+
closed = false;
|
|
179
|
+
constructor() {
|
|
180
|
+
created.push(this);
|
|
181
|
+
}
|
|
182
|
+
configure(_value: unknown) {}
|
|
183
|
+
close() {
|
|
184
|
+
this.closed = true;
|
|
185
|
+
}
|
|
186
|
+
reset() {}
|
|
187
|
+
},
|
|
188
|
+
nativeVideoPacket() {},
|
|
189
|
+
performance: { now: () => 0 },
|
|
190
|
+
btoa() {},
|
|
191
|
+
});
|
|
192
|
+
runInContext(NATIVE_VIDEO_EXTENSION_SOURCE, context);
|
|
193
|
+
const evaluate = (expression: string) => runInContext(expression, context);
|
|
194
|
+
return { support, created, evaluate };
|
|
195
|
+
}
|
|
196
|
+
it("helper reserves its three pending slots before asynchronous codec probes", async () => {
|
|
197
|
+
const f = allocationFixture();
|
|
198
|
+
await f.evaluate('startCapture("exact",1280,800,1)');
|
|
199
|
+
const pending = [2_000_000, 5_000_000, 12_000_000].map((value) =>
|
|
200
|
+
f.evaluate(`addEncoder("${value}:30","${value}",${value},30)`),
|
|
201
|
+
);
|
|
202
|
+
await expect(f.evaluate('addEncoder("24000000:30","fourth",24000000,30)')).rejects.toThrow(
|
|
203
|
+
"limit",
|
|
204
|
+
);
|
|
205
|
+
expect(f.support).toHaveLength(3);
|
|
206
|
+
for (const resolve of f.support) resolve({ supported: true });
|
|
207
|
+
await Promise.all(pending);
|
|
208
|
+
expect(f.created).toHaveLength(3);
|
|
209
|
+
await f.evaluate("stopCapture()");
|
|
210
|
+
});
|
|
211
|
+
it("same-key allocation cannot replace or orphan an encoder with a different stream identity", async () => {
|
|
212
|
+
const f = allocationFixture();
|
|
213
|
+
await f.evaluate('startCapture("exact",1280,800,1)');
|
|
214
|
+
const first = f.evaluate('addEncoder("12000000:30","original",12000000,30)');
|
|
215
|
+
const same = f.evaluate('addEncoder("12000000:30","original",12000000,30)');
|
|
216
|
+
await expect(f.evaluate('addEncoder("12000000:30","replacement",12000000,30)')).rejects.toThrow(
|
|
217
|
+
"identity",
|
|
218
|
+
);
|
|
219
|
+
expect(f.support).toHaveLength(1);
|
|
220
|
+
f.support[0]!({ supported: true });
|
|
221
|
+
await Promise.all([first, same]);
|
|
222
|
+
expect(f.created).toHaveLength(1);
|
|
223
|
+
await expect(f.evaluate('addEncoder("12000000:30","replacement",12000000,30)')).rejects.toThrow(
|
|
224
|
+
"identity",
|
|
225
|
+
);
|
|
226
|
+
await f.evaluate("stopCapture()");
|
|
227
|
+
});
|
|
228
|
+
it("cancelled pending allocation cannot publish after a replacement or capture restart", async () => {
|
|
229
|
+
const f = allocationFixture();
|
|
230
|
+
await f.evaluate('startCapture("exact",1280,800,1)');
|
|
231
|
+
const old = f.evaluate('addEncoder("12000000:30","old",12000000,30)');
|
|
232
|
+
const oldRejected = expect(old).rejects.toThrow("changed");
|
|
233
|
+
f.evaluate('removeEncoder("12000000:30")');
|
|
234
|
+
const newOne = f.evaluate('addEncoder("12000000:30","new",12000000,30)');
|
|
235
|
+
f.support[0]!({ supported: true });
|
|
236
|
+
await oldRejected;
|
|
237
|
+
expect(f.created).toHaveLength(0);
|
|
238
|
+
await f.evaluate("stopCapture()");
|
|
239
|
+
await f.evaluate('startCapture("exact",1280,800,2)');
|
|
240
|
+
const newRejected = expect(newOne).rejects.toThrow("changed");
|
|
241
|
+
f.support[1]!({ supported: true });
|
|
242
|
+
await newRejected;
|
|
243
|
+
expect(f.created).toHaveLength(0);
|
|
244
|
+
await f.evaluate("stopCapture()");
|
|
245
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Ready, attached navigation budget inside the host's 30s RPC deadline.
|
|
2
|
+
* Native ACK and matched main-frame commit are distinct from document loading.
|
|
3
|
+
* Metadata gets one bounded post-commit phase; history needs a preflight.
|
|
4
|
+
* Reattachment and held-input cleanup remain separate possible failure costs.
|
|
5
|
+
*/
|
|
6
|
+
export const NAVIGATION_ACK_TIMEOUT_MS = 10_000;
|
|
7
|
+
export const NAVIGATION_COMMIT_TIMEOUT_MS = 3_000;
|
|
8
|
+
export const NAVIGATION_METADATA_TIMEOUT_MS = 3_000;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Observes one native navigation's main-frame commit, never DOM readiness.
|
|
2
|
+
* Events can precede the command ACK, so bounded receipts are buffered before
|
|
3
|
+
* dispatch. A missing or detached post-ACK commit is uncertain, never replayed.
|
|
4
|
+
*/
|
|
5
|
+
import { type CdpSession, CdpUnknownOutcomeError } from "./cdp";
|
|
6
|
+
|
|
7
|
+
export interface NavigationCommitReceipt {
|
|
8
|
+
frameId: string;
|
|
9
|
+
loaderId?: string;
|
|
10
|
+
previousLoaderId?: string;
|
|
11
|
+
/** Required for same-document commits and history entry selection. */
|
|
12
|
+
url?: string;
|
|
13
|
+
download?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Commit = { frameId: string; loaderId?: string; url: string; sameDocument: boolean };
|
|
17
|
+
|
|
18
|
+
function normalizedUrl(url: string): string {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(url).href;
|
|
21
|
+
} catch {
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Dispatch once, then await the matching root commit within timeoutMs.
|
|
27
|
+
* assertCurrent binds the original native attachment before and after waiting.
|
|
28
|
+
* Download ACKs intentionally leave the current document unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export async function waitForNavigationCommit(
|
|
31
|
+
page: CdpSession,
|
|
32
|
+
dispatch: () => Promise<NavigationCommitReceipt>,
|
|
33
|
+
assertCurrent: () => void,
|
|
34
|
+
timeoutMs: number,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const commits: Commit[] = [];
|
|
37
|
+
let attachmentLost = false;
|
|
38
|
+
let notify: (() => void) | undefined;
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
const append = (commit: Commit): void => {
|
|
41
|
+
if (commits.length === 64) commits.shift();
|
|
42
|
+
commits.push(commit);
|
|
43
|
+
notify?.();
|
|
44
|
+
};
|
|
45
|
+
const frameNavigated = (event: {
|
|
46
|
+
frame: { id: string; parentId?: string; loaderId?: string; url: string; urlFragment?: string };
|
|
47
|
+
}): void => {
|
|
48
|
+
if (event.frame.parentId) return;
|
|
49
|
+
append({
|
|
50
|
+
frameId: event.frame.id,
|
|
51
|
+
...(event.frame.loaderId ? { loaderId: event.frame.loaderId } : {}),
|
|
52
|
+
// CDP Frame.url omits the fragment; history entries keep the full URL.
|
|
53
|
+
url: event.frame.url + (event.frame.urlFragment ?? ""),
|
|
54
|
+
sameDocument: false,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const withinDocument = (event: { frameId: string; url: string }): void => {
|
|
58
|
+
append({ frameId: event.frameId, url: event.url, sameDocument: true });
|
|
59
|
+
};
|
|
60
|
+
const detached = (): void => {
|
|
61
|
+
attachmentLost = true;
|
|
62
|
+
notify?.();
|
|
63
|
+
};
|
|
64
|
+
page.on("Page.frameNavigated", frameNavigated);
|
|
65
|
+
page.on("Page.navigatedWithinDocument", withinDocument);
|
|
66
|
+
page.on("Inspector.detached", detached);
|
|
67
|
+
page.connection.on("disconnect", detached);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
assertCurrent();
|
|
71
|
+
const receipt = await dispatch();
|
|
72
|
+
const unknown = (): CdpUnknownOutcomeError =>
|
|
73
|
+
new CdpUnknownOutcomeError(
|
|
74
|
+
"Navigation was acknowledged but its matching document commit was not observed; outcome is unknown",
|
|
75
|
+
);
|
|
76
|
+
const checkCurrent = (): void => {
|
|
77
|
+
if (attachmentLost) throw unknown();
|
|
78
|
+
try {
|
|
79
|
+
assertCurrent();
|
|
80
|
+
} catch {
|
|
81
|
+
throw unknown();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
checkCurrent();
|
|
85
|
+
if (receipt.download) return;
|
|
86
|
+
if (!receipt.frameId) throw unknown();
|
|
87
|
+
const matches = (commit: Commit): boolean => {
|
|
88
|
+
if (commit.frameId !== receipt.frameId) return false;
|
|
89
|
+
if (receipt.loaderId) return !commit.sameDocument && commit.loaderId === receipt.loaderId;
|
|
90
|
+
if (commit.sameDocument) {
|
|
91
|
+
return (
|
|
92
|
+
receipt.url !== undefined && normalizedUrl(commit.url) === normalizedUrl(receipt.url)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// History/reload ACK has no loader. Require a fresh main loader, and the
|
|
96
|
+
// selected history URL when known, rather than accepting an old event.
|
|
97
|
+
return (
|
|
98
|
+
receipt.previousLoaderId !== undefined &&
|
|
99
|
+
commit.loaderId !== undefined &&
|
|
100
|
+
commit.loaderId !== receipt.previousLoaderId &&
|
|
101
|
+
(receipt.url === undefined || normalizedUrl(commit.url) === normalizedUrl(receipt.url))
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
let expired = false;
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
expired = true;
|
|
107
|
+
notify?.();
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
for (;;) {
|
|
110
|
+
checkCurrent();
|
|
111
|
+
if (commits.some(matches)) return;
|
|
112
|
+
if (expired) throw unknown();
|
|
113
|
+
const waiter = Promise.withResolvers<void>();
|
|
114
|
+
notify = waiter.resolve;
|
|
115
|
+
await waiter.promise;
|
|
116
|
+
notify = undefined;
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
if (timer) clearTimeout(timer);
|
|
120
|
+
page.off("Page.frameNavigated", frameNavigated);
|
|
121
|
+
page.off("Page.navigatedWithinDocument", withinDocument);
|
|
122
|
+
page.off("Inspector.detached", detached);
|
|
123
|
+
page.connection.off("disconnect", detached);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Navigation metadata budget and single-publication behavior through the real session policy. */
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { type BrowserRuntimeClient, SessionManager } from "./browser-policy";
|
|
4
|
+
import { CdpUnknownOutcomeError } from "./cdp";
|
|
5
|
+
import { NAVIGATION_METADATA_TIMEOUT_MS } from "./navigation-budget";
|
|
6
|
+
|
|
7
|
+
async function fixture() {
|
|
8
|
+
let counter = 0;
|
|
9
|
+
let url = "https://before.invalid/";
|
|
10
|
+
let inputGeneration = "0:0";
|
|
11
|
+
let metadataFails = false;
|
|
12
|
+
let dispatchFails = false;
|
|
13
|
+
const calls: { operation: string; input: unknown }[] = [];
|
|
14
|
+
const client: BrowserRuntimeClient = {
|
|
15
|
+
connect: async () => ({ epoch: 1 }),
|
|
16
|
+
ensureWorkspace: async (workspaceId) => ({
|
|
17
|
+
workspaceId,
|
|
18
|
+
runtimeId: "r".repeat(32),
|
|
19
|
+
createdAt: 1,
|
|
20
|
+
}),
|
|
21
|
+
requestWorkspace: async (_workspaceId, operation, input) => {
|
|
22
|
+
calls.push({ operation, input });
|
|
23
|
+
if (operation === "identity") return { userAgent: "Fixture" };
|
|
24
|
+
if (operation === "state") {
|
|
25
|
+
if (metadataFails) throw new Error("Browser metadata unavailable");
|
|
26
|
+
return {
|
|
27
|
+
url,
|
|
28
|
+
title: "Still loading",
|
|
29
|
+
canGoBack: true,
|
|
30
|
+
canGoForward: true,
|
|
31
|
+
inputGeneration,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (["navigate", "back", "forward", "reload"].includes(operation)) {
|
|
35
|
+
if (dispatchFails) throw new CdpUnknownOutcomeError("Navigation acknowledgement unknown");
|
|
36
|
+
// A redirect is observed from the native state, never invented from the request URL.
|
|
37
|
+
url = "https://redirected.invalid/";
|
|
38
|
+
inputGeneration = "0:1";
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
},
|
|
42
|
+
archiveWorkspace: async () => {},
|
|
43
|
+
closeWorkspace: async () => {},
|
|
44
|
+
disconnect() {},
|
|
45
|
+
};
|
|
46
|
+
const manager = new SessionManager({
|
|
47
|
+
client,
|
|
48
|
+
validateWorkspace: async () => true,
|
|
49
|
+
issueToken: () => String(++counter).padStart(32, "0"),
|
|
50
|
+
});
|
|
51
|
+
await manager.connect();
|
|
52
|
+
const attached = await manager.attach("owned", "Viewer");
|
|
53
|
+
const control = await manager.acquireControl(attached.viewerToken);
|
|
54
|
+
const context = {
|
|
55
|
+
viewerToken: attached.viewerToken,
|
|
56
|
+
controlToken: control.controlToken,
|
|
57
|
+
expected: {
|
|
58
|
+
sessionId: control.state.sessionId,
|
|
59
|
+
navigationGeneration: control.state.navigationGeneration,
|
|
60
|
+
viewportGeneration: control.state.viewportGeneration,
|
|
61
|
+
runtimeId: control.state.runtimeId!,
|
|
62
|
+
bridgeEpoch: control.state.bridgeEpoch!,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
calls.length = 0;
|
|
66
|
+
return {
|
|
67
|
+
manager,
|
|
68
|
+
context,
|
|
69
|
+
calls,
|
|
70
|
+
failMetadata: () => {
|
|
71
|
+
metadataFails = true;
|
|
72
|
+
},
|
|
73
|
+
failDispatch: () => {
|
|
74
|
+
dispatchFails = true;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
afterEach(() => vi.restoreAllMocks());
|
|
80
|
+
|
|
81
|
+
it.each(["goto", "back", "forward", "reload"] as const)(
|
|
82
|
+
"%s performs one bounded post-observation, with one preflight only for history actions",
|
|
83
|
+
async (kind) => {
|
|
84
|
+
const f = await fixture();
|
|
85
|
+
try {
|
|
86
|
+
const action = kind === "goto" ? { kind, url: "https://requested.invalid/" } : { kind };
|
|
87
|
+
const result = await f.manager.navigate({ ...f.context, action });
|
|
88
|
+
expect(result.state.url).toBe("https://redirected.invalid/");
|
|
89
|
+
expect(result.state.title).toBe("Still loading");
|
|
90
|
+
const metadata = f.calls.filter((call) => call.operation === "state");
|
|
91
|
+
expect(metadata).toHaveLength(kind === "back" || kind === "forward" ? 2 : 1);
|
|
92
|
+
expect(
|
|
93
|
+
metadata.every(
|
|
94
|
+
(call) =>
|
|
95
|
+
JSON.stringify(call.input) ===
|
|
96
|
+
JSON.stringify({ timeoutMs: NAVIGATION_METADATA_TIMEOUT_MS }),
|
|
97
|
+
),
|
|
98
|
+
).toBe(true);
|
|
99
|
+
expect(
|
|
100
|
+
f.calls.filter((call) =>
|
|
101
|
+
["navigate", "back", "forward", "reload"].includes(call.operation),
|
|
102
|
+
),
|
|
103
|
+
).toHaveLength(1);
|
|
104
|
+
} finally {
|
|
105
|
+
await f.manager.disconnect();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
it("acknowledged navigation with unavailable metadata returns explicit error, never cached-ready", async () => {
|
|
111
|
+
const f = await fixture();
|
|
112
|
+
try {
|
|
113
|
+
f.failMetadata();
|
|
114
|
+
const result = await f.manager.navigate({
|
|
115
|
+
...f.context,
|
|
116
|
+
action: { kind: "goto", url: "https://requested.invalid/" },
|
|
117
|
+
});
|
|
118
|
+
expect(result.state.status).toBe("error");
|
|
119
|
+
expect(result.state.error).toBe("Browser metadata unavailable");
|
|
120
|
+
expect(f.calls.map((call) => call.operation)).toEqual(["navigate", "state"]);
|
|
121
|
+
} finally {
|
|
122
|
+
await f.manager.disconnect();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("failed history preflight blocks publication", async () => {
|
|
127
|
+
const f = await fixture();
|
|
128
|
+
try {
|
|
129
|
+
f.failMetadata();
|
|
130
|
+
await expect(f.manager.navigate({ ...f.context, action: { kind: "back" } })).rejects.toThrow(
|
|
131
|
+
"metadata unavailable",
|
|
132
|
+
);
|
|
133
|
+
expect(f.calls.map((call) => call.operation)).toEqual(["state"]);
|
|
134
|
+
} finally {
|
|
135
|
+
await f.manager.disconnect();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("uncertain native publication is not retried or relabelled as a loaded document", async () => {
|
|
140
|
+
const f = await fixture();
|
|
141
|
+
try {
|
|
142
|
+
f.failDispatch();
|
|
143
|
+
await expect(
|
|
144
|
+
f.manager.navigate({
|
|
145
|
+
...f.context,
|
|
146
|
+
action: { kind: "goto", url: "https://requested.invalid/" },
|
|
147
|
+
}),
|
|
148
|
+
).rejects.toBeInstanceOf(CdpUnknownOutcomeError);
|
|
149
|
+
expect(f.calls.map((call) => call.operation)).toEqual(["navigate"]);
|
|
150
|
+
} finally {
|
|
151
|
+
await f.manager.disconnect();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
/** Proof that a PID is a process launched by this runtime and has not been replaced since. */
|
|
4
|
+
export interface ProcessIdentity {
|
|
5
|
+
pid: number;
|
|
6
|
+
/** Kernel start time in clock ticks; a recycled PID has a different value. */
|
|
7
|
+
startTicks: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Environment variable carrying the per-runtime ownership nonce through the launch chain. */
|
|
11
|
+
export const RUNTIME_OWNER_VARIABLE = "PASEO_SHARED_BROWSER_RUNTIME_OWNER";
|
|
12
|
+
|
|
13
|
+
/** Positive outcomes are distinct from "could not tell": only `absent` proves disappearance. */
|
|
14
|
+
export type ProcessProbe =
|
|
15
|
+
| { status: "owned"; identity: ProcessIdentity }
|
|
16
|
+
/** The process exists and was read, but is not this runtime's (no nonce): proven replacement. */
|
|
17
|
+
| { status: "foreign" }
|
|
18
|
+
/** The kernel reports no such process (ENOENT/ESRCH): positive exit. */
|
|
19
|
+
| { status: "absent" }
|
|
20
|
+
/** Unreadable, unsupported platform or malformed data: nothing is proven either way. */
|
|
21
|
+
| { status: "unknown" };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Probes `pid` for this runtime's private nonce, i.e. a descendant of its own launch (JS
|
|
25
|
+
* launcher, native CLI, daemon) rather than merely a process running some executable.
|
|
26
|
+
*/
|
|
27
|
+
export async function probeProcess(pid: number, nonce: string): Promise<ProcessProbe> {
|
|
28
|
+
if (process.platform !== "linux" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
29
|
+
return { status: "unknown" };
|
|
30
|
+
try {
|
|
31
|
+
const [stat, environment] = await Promise.all([
|
|
32
|
+
readFile(`/proc/${pid}/stat`, "utf8"),
|
|
33
|
+
readFile(`/proc/${pid}/environ`, "utf8"),
|
|
34
|
+
]);
|
|
35
|
+
if (!environment.split("\0").includes(`${RUNTIME_OWNER_VARIABLE}=${nonce}`))
|
|
36
|
+
return { status: "foreign" };
|
|
37
|
+
// Fields after the parenthesised command name; starttime is field 22 overall.
|
|
38
|
+
const startTicks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
39
|
+
return startTicks && /^\d+$/.test(startTicks)
|
|
40
|
+
? { status: "owned", identity: { pid, startTicks } }
|
|
41
|
+
: { status: "unknown" };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ||
|
|
44
|
+
(error as NodeJS.ErrnoException).code === "ESRCH"
|
|
45
|
+
? { status: "absent" }
|
|
46
|
+
: { status: "unknown" };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Signal-0 probe: only ESRCH proves the process is gone; EPERM means it is still alive. */
|
|
51
|
+
export function processExists(pid: number): boolean {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
57
|
+
}
|
|
58
|
+
}
|