mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
* test: read the download item inside will-download in api-session spec `will-download event > identifies the frame and origin that started the download` called preventDefault() in its will-download listener and only read item.getInitiatorOrigin() after awaiting the event. preventDefault() cancels the download, which destroys the item, and that can happen before the awaiting code runs, so the read threw "DownloadItem used after being destroyed". Read the origin inside the listener, while the item is alive. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS * test: wait for the page to see clearData() in api-session spec `ses.clearData() > clears all data when no options supplied` (and its "called twice in parallel" twin) read localStorage.length right after clearData() resolved. The page answers from its own cached copy of localStorage, which the storage backend updates over a separate pipe, so the page could still report the old item. Wait until the page sees the clear, as the clearStorageData() spec already does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS * test: enable Network capture before navigating in debugger valid unicode spec "handles valid unicode characters in message" started loadURL() on a fresh window and only then attached the debugger and sent Network.enable without waiting for it. Network.enable reaches the renderer only once it exists, so when the tiny response won that race Network.responseReceived was never emitted and the test hung until its 30s timeout. Load about:blank first, attach, await Network.enable, then start listening and navigate, matching the neighbouring invalid unicode spec. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS * test: drop the 5s timeout on contentTracing recording specs startRecording() resolves only once every child process acks the start, or after the tracing service's 10s ack timeout, and stopRecording() then waits up to 5s for the stop acks. A child that is slow to get scheduled on a busy CI host therefore stretches a single record() past the 5s per-suite timeout whatever the trace categories are, and the test only passed on the in-suite retry. Use the default spec timeout instead. Stopping the network service for 25s while a recording runs makes the slowest record() take about 15s, which matches that bound and stays well inside the default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS * test: listen for the PDF viewer download's done event before it can fire The asar PDF viewer download test attached its 'done' listener only after the click loop noticed that the download had started. When the click's executeJavaScript() round trip came back late, the small download could start and finish inside that window, and the test then waited for a 'done' event that had already fired until the spec timed out. Start listening for 'done' in the synchronous will-download handler that accepts the first download, so the event cannot be missed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS * test: isolate the custom protocol extension fixtures' profiles runFixture() started the custom-protocol fixture apps on the default profile (they have no package.json, so userData is the stock Electron directory other fixture apps use too) and never stopped them. When another browser process holds that profile, the fixture's MV3 service worker never answers the content script, the page title never changes and the app hangs until the test times out. Because the hung app was not killed it kept the profile open, so every in-job retry hung the same way. Give each run its own --user-data-dir, and kill the app and remove the directory when the test ends. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS * test: follow links without synthesized clicks in navigation ordering specs The "navigation events > ordering" specs for a second navigation, a redirect and an in-page navigation clicked a link in a window that was never shown, by sending CDP mouse events after a fixed 2s wait. On Wayland a hidden window presents no frames, and the click can be lost before it reaches the page: the browser forwards mouseDown and mouseUp, but the page sees no pointer, mouse or click event, so the navigation never starts and the test times out. These specs check the order of navigation events, not input delivery. Follow the link with a renderer-side click() once the page has loaded instead; it is the same renderer-initiated navigation, and it no longer depends on frames being presented or on the 2s wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS --------- Co-authored-by: Claude <noreply@anthropic.com>
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
import { BrowserWindow } from 'electron/main';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { once } from 'node:events';
|
|
import * as http from 'node:http';
|
|
import * as path from 'node:path';
|
|
import { setTimeout } from 'node:timers/promises';
|
|
|
|
import { emittedUntil } from './lib/events-helpers.ts';
|
|
import { listen, startRemoteControlApp, waitUntil } from './lib/spec-helpers.ts';
|
|
import { closeAllWindows } from './lib/window-helpers.ts';
|
|
|
|
describe('debugger module', () => {
|
|
const fixtures = path.resolve(import.meta.dirname, 'fixtures');
|
|
let w: BrowserWindow;
|
|
|
|
beforeEach(() => {
|
|
w = new BrowserWindow({
|
|
show: false,
|
|
width: 400,
|
|
height: 400
|
|
});
|
|
});
|
|
|
|
afterEach(closeAllWindows);
|
|
|
|
describe('debugger.attach', () => {
|
|
it('succeeds when devtools is already open', async () => {
|
|
await w.webContents.loadURL('about:blank');
|
|
w.webContents.openDevTools();
|
|
w.webContents.debugger.attach();
|
|
expect(w.webContents.debugger.isAttached()).toBe(true);
|
|
});
|
|
|
|
it('fails when protocol version is not supported', () => {
|
|
expect(() => w.webContents.debugger.attach('2.0')).toThrow();
|
|
expect(w.webContents.debugger.isAttached()).toBe(false);
|
|
});
|
|
|
|
it('attaches when no protocol version is specified', () => {
|
|
w.webContents.debugger.attach();
|
|
expect(w.webContents.debugger.isAttached()).toBe(true);
|
|
});
|
|
|
|
it('throws when the webContents has been destroyed', async () => {
|
|
const { debugger: dbg } = w.webContents;
|
|
w.webContents.destroy();
|
|
await once(w.webContents, 'destroyed');
|
|
await setTimeout(50);
|
|
expect(() => dbg.attach()).toThrow('No target available');
|
|
expect(dbg.isAttached()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('debugger.detach', () => {
|
|
it('fires detach event', async () => {
|
|
const detach = once(w.webContents.debugger, 'detach');
|
|
w.webContents.debugger.attach();
|
|
w.webContents.debugger.detach();
|
|
const [, reason] = await detach;
|
|
expect(reason).toBe('target closed');
|
|
expect(w.webContents.debugger.isAttached()).toBe(false);
|
|
});
|
|
|
|
it("doesn't disconnect an active devtools session", async () => {
|
|
w.webContents.loadURL('about:blank');
|
|
const detach = once(w.webContents.debugger, 'detach');
|
|
w.webContents.debugger.attach();
|
|
w.webContents.openDevTools();
|
|
w.webContents.once('devtools-opened', () => {
|
|
w.webContents.debugger.detach();
|
|
});
|
|
await detach;
|
|
expect(w.webContents.debugger.isAttached()).toBe(false);
|
|
expect(w.devToolsWebContents.isDestroyed()).toBe(false);
|
|
});
|
|
|
|
it('clears device metrics overrides left behind by the session', async () => {
|
|
await w.webContents.loadURL('about:blank');
|
|
const innerSize = () => w.webContents.executeJavaScript('window.innerWidth + "x" + window.innerHeight');
|
|
|
|
w.webContents.debugger.attach();
|
|
await w.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
|
|
width: 200,
|
|
height: 150,
|
|
deviceScaleFactor: 0,
|
|
mobile: false
|
|
});
|
|
expect(await innerSize()).toBe('200x150');
|
|
|
|
// Detaching without Emulation.clearDeviceMetricsOverride used to leave
|
|
// the view pinned at the emulated size.
|
|
w.webContents.debugger.detach();
|
|
await waitUntil(async () => (await innerSize()) !== '200x150');
|
|
});
|
|
|
|
it('fires detach event for a webContents that is still attached at quit', async () => {
|
|
const rc = await startRemoteControlApp();
|
|
let stdout = '';
|
|
rc.process.stdout!.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
await rc.remotely(async () => {
|
|
const { app, webContents } = require('electron');
|
|
const contents = (webContents as any).create();
|
|
await contents.loadURL('about:blank');
|
|
contents.debugger.on('detach', (_event: unknown, reason: string) => {
|
|
process.stdout.write(`debugger-detach: ${reason}\n`);
|
|
});
|
|
contents.debugger.attach();
|
|
(globalThis as any).contents = contents;
|
|
setImmediate(() => app.quit());
|
|
});
|
|
const [code] = await once(rc.process, 'close');
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain('debugger-detach: target closed');
|
|
});
|
|
});
|
|
|
|
describe('debugger.sendCommand', () => {
|
|
let server: http.Server;
|
|
|
|
afterEach(() => {
|
|
if (server != null) {
|
|
server.close();
|
|
server = null as any;
|
|
}
|
|
});
|
|
|
|
it('returns response', async () => {
|
|
w.webContents.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
|
|
const params = { expression: '4+2' };
|
|
const res = await w.webContents.debugger.sendCommand('Runtime.evaluate', params);
|
|
|
|
expect(res.wasThrown).toBeUndefined();
|
|
expect(res.result.value).toBe(6);
|
|
|
|
w.webContents.debugger.detach();
|
|
});
|
|
|
|
it('returns response when devtools is opened', async () => {
|
|
w.webContents.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
|
|
const opened = once(w.webContents, 'devtools-opened');
|
|
w.webContents.openDevTools();
|
|
await opened;
|
|
|
|
const params = { expression: '4+2' };
|
|
const res = await w.webContents.debugger.sendCommand('Runtime.evaluate', params);
|
|
|
|
expect(res.wasThrown).toBeUndefined();
|
|
expect(res.result.value).toBe(6);
|
|
|
|
w.webContents.debugger.detach();
|
|
});
|
|
|
|
it('fires message event', async () => {
|
|
const url =
|
|
process.platform !== 'win32'
|
|
? `file://${path.join(fixtures, 'pages', 'a.html')}`
|
|
: `file:///${path.join(fixtures, 'pages', 'a.html').replaceAll('\\', '/')}`;
|
|
w.webContents.loadURL(url);
|
|
w.webContents.debugger.attach();
|
|
const message = emittedUntil(
|
|
w.webContents.debugger,
|
|
'message',
|
|
(event: Electron.Event, method: string) => method === 'Console.messageAdded'
|
|
);
|
|
w.webContents.debugger.sendCommand('Console.enable');
|
|
const [, , params] = await message;
|
|
w.webContents.debugger.detach();
|
|
expect(params.message.level).toBe('log');
|
|
expect(params.message.url).toBe(url);
|
|
expect(params.message.text).toBe('a');
|
|
});
|
|
|
|
it('returns error message when command fails', async () => {
|
|
w.webContents.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
|
|
const promise = w.webContents.debugger.sendCommand('Test');
|
|
await expect(promise).rejects.toThrow("'Test' wasn't found");
|
|
|
|
w.webContents.debugger.detach();
|
|
});
|
|
|
|
it('rejects a non-string value for a string parameter', async () => {
|
|
// Sends a number for Input.dispatchKeyEvent's optional `text` string param,
|
|
// which must reject rather than abort the browser process. See
|
|
// spec/fixtures/crash-cases/debugger-send-command-wrong-type.
|
|
w.webContents.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
|
|
const promise = w.webContents.debugger.sendCommand('Input.dispatchKeyEvent', {
|
|
type: 'keyDown',
|
|
text: 1 as any
|
|
});
|
|
await expect(promise).rejects.toThrow();
|
|
|
|
w.webContents.debugger.detach();
|
|
});
|
|
|
|
it('handles valid unicode characters in message', async () => {
|
|
server = http.createServer((req, res) => {
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.end('\u0024');
|
|
});
|
|
|
|
const { url } = await listen(server);
|
|
// Network.enable only reaches the renderer once it exists. Sent during
|
|
// the first navigation of a fresh window, it can arrive after the
|
|
// response, so Network.responseReceived never fires. Load a page first
|
|
// and wait for Network.enable before the real navigation.
|
|
await w.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
await w.webContents.debugger.sendCommand('Network.enable');
|
|
|
|
let requestId: string | undefined;
|
|
const loadingFinished = emittedUntil(
|
|
w.webContents.debugger,
|
|
'message',
|
|
(_event: any, method: string, params: any) => {
|
|
if (method === 'Network.responseReceived' && params.response.url.startsWith('http://127.0.0.1')) {
|
|
requestId = params.requestId;
|
|
}
|
|
return method === 'Network.loadingFinished' && requestId !== undefined && params.requestId === requestId;
|
|
}
|
|
);
|
|
await w.loadURL(url);
|
|
await loadingFinished;
|
|
const { body } = await w.webContents.debugger.sendCommand('Network.getResponseBody', { requestId });
|
|
expect(body).toBe('\u0024');
|
|
});
|
|
|
|
it('does not crash for invalid unicode characters in message', async () => {
|
|
server = http.createServer((req, res) => {
|
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
res.end('\uFFFF');
|
|
});
|
|
|
|
const { url } = await listen(server);
|
|
// Network.enable only reaches the renderer once it exists. On a fresh
|
|
// window that is during the first navigation, which can then finish
|
|
// before the renderer reports Network.loadingFinished. Load a page
|
|
// first and wait for Network.enable before the real navigation.
|
|
await w.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
await w.webContents.debugger.sendCommand('Network.enable');
|
|
|
|
// loadingFinished indicates that page has been loaded and it did not
|
|
// crash because of invalid UTF-8 data
|
|
const loadingFinished = emittedUntil(
|
|
w.webContents.debugger,
|
|
'message',
|
|
(_event: any, method: string) => method === 'Network.loadingFinished'
|
|
);
|
|
await w.loadURL(url);
|
|
await loadingFinished;
|
|
});
|
|
|
|
it('can continue a Fetch-paused document navigation without webRequest listeners', async () => {
|
|
server = http.createServer((_req, res) => {
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end('<!DOCTYPE html><html><body><h1>Hello World</h1></body></html>');
|
|
});
|
|
|
|
const { port } = await listen(server);
|
|
const url = `http://localhost:${port}`;
|
|
const continueRequests: Array<Promise<any>> = [];
|
|
|
|
const onMessage = (_event: Electron.Event, method: string, params: any) => {
|
|
if (method === 'Fetch.requestPaused') {
|
|
continueRequests.push(
|
|
w.webContents.debugger.sendCommand('Fetch.continueRequest', {
|
|
requestId: params.requestId
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
w.webContents.debugger.attach();
|
|
w.webContents.debugger.on('message', onMessage);
|
|
|
|
try {
|
|
await w.webContents.debugger.sendCommand('Fetch.enable', {
|
|
patterns: [{ resourceType: 'Document' }]
|
|
});
|
|
|
|
await w.loadURL(url);
|
|
await Promise.all(continueRequests);
|
|
} finally {
|
|
w.webContents.debugger.off('message', onMessage);
|
|
if (w.webContents.debugger.isAttached()) {
|
|
w.webContents.debugger.detach();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('reports network requests for a new document after a main-frame navigation', async () => {
|
|
server = http.createServer((req, res) => {
|
|
if (req.url === '/page') {
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end(
|
|
'<!DOCTYPE html><html><head>' +
|
|
'<link rel="stylesheet" href="/style.css">' +
|
|
'<script src="/script.js"></script>' +
|
|
'</head><body>page</body></html>'
|
|
);
|
|
} else if (req.url === '/style.css') {
|
|
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
res.end('body { color: red; }');
|
|
} else if (req.url === '/script.js') {
|
|
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
|
|
res.end('void 0;');
|
|
} else {
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end('<!DOCTYPE html><html><body>start</body></html>');
|
|
}
|
|
});
|
|
|
|
const { url } = await listen(server);
|
|
await w.loadURL(`${url}/start`);
|
|
w.webContents.debugger.attach();
|
|
try {
|
|
await w.webContents.debugger.sendCommand('Network.enable');
|
|
await w.webContents.debugger.sendCommand('Page.enable');
|
|
await w.webContents.debugger.sendCommand('Target.setAutoAttach', {
|
|
autoAttach: true,
|
|
flatten: true,
|
|
waitForDebuggerOnStart: false
|
|
});
|
|
|
|
const requestedUrls = new Set<string>();
|
|
const loadEvent = new Promise<void>((resolve) => {
|
|
w.webContents.debugger.on('message', (_event, method, params) => {
|
|
if (method === 'Network.requestWillBeSent') {
|
|
requestedUrls.add(params.request.url);
|
|
} else if (method === 'Page.loadEventFired') {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
// This navigation swaps the main-frame RenderFrameHost under
|
|
// RenderDocument. Before the fix, the debugger tore down and rebound its
|
|
// session here, dropping these subresource requests.
|
|
await w.loadURL(`${url}/page`);
|
|
|
|
await loadEvent;
|
|
expect(requestedUrls).toContain(`${url}/style.css`);
|
|
expect(requestedUrls).toContain(`${url}/script.js`);
|
|
} finally {
|
|
if (w.webContents.debugger.isAttached()) {
|
|
w.webContents.debugger.detach();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('can get and set cookies using the Storage API', async () => {
|
|
await w.webContents.loadURL('about:blank');
|
|
w.webContents.debugger.attach('1.1');
|
|
|
|
await w.webContents.debugger.sendCommand('Storage.clearCookies', {});
|
|
await w.webContents.debugger.sendCommand('Storage.setCookies', {
|
|
cookies: [
|
|
{
|
|
name: 'cookieOne',
|
|
value: 'cookieValueOne',
|
|
url: 'https://cookieone.com'
|
|
},
|
|
{
|
|
name: 'cookieTwo',
|
|
value: 'cookieValueTwo',
|
|
url: 'https://cookietwo.com'
|
|
}
|
|
]
|
|
});
|
|
|
|
const { cookies } = await w.webContents.debugger.sendCommand('Storage.getCookies', {});
|
|
expect(cookies).toHaveLength(2);
|
|
|
|
const cookieOne = cookies.find((cookie: any) => cookie.name === 'cookieOne');
|
|
expect(cookieOne.domain).toBe('cookieone.com');
|
|
expect(cookieOne.value).toBe('cookieValueOne');
|
|
|
|
const cookieTwo = cookies.find((cookie: any) => cookie.name === 'cookieTwo');
|
|
expect(cookieTwo.domain).toBe('cookietwo.com');
|
|
expect(cookieTwo.value).toBe('cookieValueTwo');
|
|
});
|
|
|
|
it('uses empty sessionId by default', async () => {
|
|
w.webContents.loadURL('about:blank');
|
|
w.webContents.debugger.attach();
|
|
const onMessage = once(w.webContents.debugger, 'message');
|
|
await w.webContents.debugger.sendCommand('Target.setDiscoverTargets', { discover: true });
|
|
const [, method, params, sessionId] = await onMessage;
|
|
expect(method).toBe('Target.targetCreated');
|
|
expect(params.targetInfo.targetId).not.toHaveLength(0);
|
|
expect(sessionId).toHaveLength(0);
|
|
w.webContents.debugger.detach();
|
|
});
|
|
|
|
it('creates unique session id for each target', () =>
|
|
new Promise<void>((resolve) => {
|
|
w.webContents.loadFile(path.join(import.meta.dirname, 'fixtures', 'sub-frames', 'debug-frames.html'));
|
|
w.webContents.debugger.attach();
|
|
let debuggerSessionId: string;
|
|
|
|
w.webContents.debugger.on('message', (_event, ...args) => {
|
|
const [method, params, sessionId] = args;
|
|
if (method === 'Target.targetCreated') {
|
|
w.webContents.debugger
|
|
.sendCommand('Target.attachToTarget', { targetId: params.targetInfo.targetId, flatten: true })
|
|
.then((result) => {
|
|
debuggerSessionId = result.sessionId;
|
|
w.webContents.debugger.sendCommand('Debugger.enable', {}, result.sessionId);
|
|
|
|
// Ensure debugger finds a script to pause to possibly reduce flaky
|
|
// tests.
|
|
w.webContents.mainFrame.executeJavaScript('void 0;');
|
|
});
|
|
}
|
|
if (method === 'Debugger.scriptParsed') {
|
|
if (sessionId === debuggerSessionId) {
|
|
w.webContents.debugger.detach();
|
|
resolve();
|
|
}
|
|
}
|
|
});
|
|
w.webContents.debugger.sendCommand('Target.setDiscoverTargets', { discover: true });
|
|
}));
|
|
});
|
|
});
|