@@ -9,9 +9,21 @@ import test from "node:test";
99const electron = `data:text/javascript,${ encodeURIComponent ( `
1010 import { EventEmitter } from "node:events";
1111 export const shell = {};
12- export class WebContentsView {
12+ export class View {
13+ children = [];
14+ parent = null;
15+ visible = true;
16+ bounds = { x: 0, y: 0, width: 0, height: 0 };
17+ addChildView(view) { this.children.push(view); view.parent = this; }
18+ removeChildView(view) { this.children.splice(this.children.indexOf(view), 1); view.parent = null; }
19+ setBounds(bounds) { this.bounds = { ...bounds }; }
20+ getBounds() { return { ...this.bounds }; }
21+ setVisible(visible) { this.visible = visible; }
22+ }
23+ export class WebContentsView extends View {
1324 static instances = [];
1425 constructor() {
26+ super();
1527 const wc = Object.assign(new EventEmitter(), {
1628 id: WebContentsView.instances.length + 1,
1729 url: "", destroyed: false, viewport: { width: 0, height: 0 },
@@ -24,7 +36,11 @@ const electron = `data:text/javascript,${encodeURIComponent(`
2436 getURL() { return this.url; },
2537 getTitle: () => "fixture", isLoading: () => false,
2638 isDestroyed() { return this.destroyed; },
27- close() { this.destroyed = true; },
39+ close() {
40+ if (this.destroyed) throw new Error("guest already destroyed");
41+ this.destroyed = true;
42+ this.emit("destroyed");
43+ },
2844 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2945 setWindowOpenHandler: () => {},
3046 session: { setPermissionRequestHandler: () => {} },
@@ -51,13 +67,13 @@ const electron = `data:text/javascript,${encodeURIComponent(`
5167 },
5268 });
5369 this.webContents = wc;
70+ wc.once("destroyed", () => { this.webContents = undefined; });
5471 WebContentsView.instances.push(this);
5572 }
5673 setBounds(bounds) {
5774 this.bounds = { ...bounds };
5875 this.webContents.viewport = { width: bounds.width, height: bounds.height };
5976 }
60- setVisible() {}
6177 }
6278` ) } `;
6379registerHooks ( { resolve ( specifier , context , next ) {
@@ -86,7 +102,41 @@ async function harness(t) {
86102 host . setChromeSession ( "session" , "first" ) ;
87103 await host . navigate ( { url : "https://fixture.invalid/first" } , "session" ) ;
88104 t . after ( ( ) => host . dispose ( ) ) ;
89- return { host, resize, wc : WebContentsView . instances . at ( - 1 ) . webContents } ;
105+ return { host, resize, children, view : WebContentsView . instances . at ( - 1 ) , wc : WebContentsView . instances . at ( - 1 ) . webContents } ;
106+ }
107+
108+ function paintedBounds ( view ) {
109+ const own = view . getBounds ( ) ;
110+ if ( ! view . parent ) return own ;
111+ const parent = view . parent . getBounds ( ) ;
112+ return {
113+ x : parent . x + own . x , y : parent . y + own . y ,
114+ width : Math . min ( own . width , parent . width - own . x ) ,
115+ height : Math . min ( own . height , parent . height - own . y ) ,
116+ } ;
117+ }
118+
119+ for ( const raw of [ false , true ] ) {
120+ test ( `${ raw ? "raw CDP" : "browser screenshot" } stays inside the relocated panel before capture finishes` , async ( t ) => {
121+ const { host, wc, view } = await harness ( t ) ;
122+ const started = once ( wc , "capture-start" ) ;
123+ const capture = raw
124+ ? host . cdpCommand ( "Page.captureScreenshot" , { format : "png" } )
125+ : host . screenshot ( { fullPage : true } ) ;
126+ await started ;
127+ // A maximize/restore can move the panel while the tool's screenshot is
128+ // still pending. The content must never remain above the conversation.
129+ const relocated = { x : 1100 , y : 42 , width : 620 , height : 430 } ;
130+ host . setChromeSurface ( { pluginId : "pi.browser" , viewId : "browser" , visible : true , bounds : relocated } ) ;
131+ host . setGuestHole ( "pi.browser" , { x : 0 , y : 0 , width : 620 , height : 430 } ) ;
132+ const during = paintedBounds ( view ) ;
133+ assert . deepEqual ( wc . viewport , { width : 800 , height : 500 } , "capture viewport remains stable" ) ;
134+ wc . captures [ 0 ] . resolve ( ) ;
135+ await capture ;
136+ assert . deepEqual ( during , relocated , "move and clip the native guest before awaiting capture" ) ;
137+ assert . deepEqual ( paintedBounds ( view ) , relocated ) ;
138+ assert . deepEqual ( wc . viewport , { width : 620 , height : 430 } ) ;
139+ } ) ;
90140}
91141
92142for ( const raw of [ false , true ] ) {
@@ -165,3 +215,59 @@ test("closing a tab rejects its queued capture without capturing a replacement",
165215 assert . equal ( wc . captures . length , 1 ) ;
166216 assert . equal ( WebContentsView . instances . at ( - 1 ) . webContents . captures . length , 0 ) ;
167217} ) ;
218+
219+ test ( "hiding and restoring a captured page keeps its presentation isolated from sibling tabs" , async ( t ) => {
220+ const { host, children, view, wc } = await harness ( t ) ;
221+ const surface = view . parent ;
222+ const started = once ( wc , "capture-start" ) ;
223+ const capture = host . screenshot ( ) ;
224+ await started ;
225+ host . setChromeSession ( "session" , "second" ) ;
226+ await host . navigate ( { url : "https://fixture.invalid/second" } , "session" ) ;
227+ assert ( ! children . includes ( surface ) ) ;
228+ assert . equal ( surface . visible , false ) ;
229+ const relocated = { x : 960 , y : 70 , width : 530 , height : 400 } ;
230+ host . setChromeSurface ( { pluginId : "pi.browser" , viewId : "browser" , visible : true , bounds : relocated } ) ;
231+ host . setGuestHole ( "pi.browser" , { x : 0 , y : 0 , width : 530 , height : 400 } ) ;
232+ host . setChromeSession ( "session" , "first" ) ;
233+ assert . deepEqual ( paintedBounds ( view ) , relocated ) ;
234+ assert . equal ( surface . visible , true ) ;
235+ assert . equal ( children . at ( - 1 ) , surface ) ;
236+ wc . captures [ 0 ] . resolve ( ) ;
237+ await capture ;
238+ assert . deepEqual ( wc . viewport , { width : 530 , height : 400 } ) ;
239+ host . closeTab ( "session" , "first" ) ;
240+ assert ( ! children . includes ( surface ) ) ;
241+ assert . equal ( surface . children . length , 0 ) ;
242+ assert . equal ( wc . destroyed , true ) ;
243+ } ) ;
244+
245+ test ( "a destroyed native guest can be resized, replaced and disposed safely" , async ( t ) => {
246+ const { host, resize, wc, view, children } = await harness ( t ) ;
247+ const previousSurface = view . parent ;
248+ wc . close ( ) ;
249+ assert . equal ( view . webContents , undefined , "Electron clears a destroyed guest handle" ) ;
250+ resize ( 930 , 620 ) ;
251+ await host . navigate ( { url : "https://fixture.invalid/recreated" } , "session" ) ;
252+ const replacement = WebContentsView . instances . at ( - 1 ) ;
253+ assert . notEqual ( replacement , view ) ;
254+ assert ( ! children . includes ( previousSurface ) ) ;
255+ assert . equal ( children . length , 1 ) ;
256+ assert . deepEqual ( replacement . webContents . viewport , { width : 930 , height : 620 } ) ;
257+ replacement . webContents . close ( ) ;
258+ host . dispose ( ) ;
259+ host . dispose ( ) ;
260+ assert . equal ( children . length , 0 ) ;
261+ } ) ;
262+
263+ test ( "capture completion after native guest destruction preserves the original capture result" , async ( t ) => {
264+ const { host, resize, wc } = await harness ( t ) ;
265+ const started = once ( wc , "capture-start" ) ;
266+ const capture = host . screenshot ( ) ;
267+ await started ;
268+ wc . close ( ) ;
269+ resize ( 900 , 620 ) ;
270+ wc . captures [ 0 ] . resolve ( ) ;
271+ assert . equal ( ( await capture ) . data , "fixture-image" ) ;
272+ host . dispose ( ) ;
273+ } ) ;
0 commit comments