mirror of
https://github.com/electron/electron.git
synced 2026-10-28 11:44:46 +08:00
The macOS spellchecker specs relied on retries because the system spelling server starts on first use, which took 30 to 60 seconds on fresh CI machines. Until then AppKit times every request out and Blink never asks again about the text it already checked, so the first context menu spec never saw its misspelling. Wait once, in a beforeAll, until the spell checker answers with suggestions for a misspelled word, and have the context menu waits require the suggestions as well as the misspelled word. The renderer OOM crash-key specs filled the heap with arrays of pointers, so each of the ~20 full GCs V8 runs near the heap limit traced the whole heap. On the Intel macOS runners that took long enough for the crash fixture's 30 second exit timer to end the app before the renderer crashed. Fill the heap with arrays of doubles instead, which the GC does not trace. Claude-Session: https://claude.ai/code/session_01LPMvJBQiyP6b7QjatACmnS Co-authored-by: Claude <noreply@anthropic.com>
322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import { BrowserWindow, type Session, session } from 'electron/main';
|
|
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { once } from 'node:events';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as http from 'node:http';
|
|
import * as path from 'node:path';
|
|
import { setTimeout } from 'node:timers/promises';
|
|
|
|
import { ifit, ifdescribe, listen, waitUntil } from './lib/spec-helpers.ts';
|
|
import { closeWindow } from './lib/window-helpers.ts';
|
|
|
|
const features = process._linkedBinding('electron_common_features');
|
|
const v8Util = process._linkedBinding('electron_common_v8_util');
|
|
|
|
// Loading the dictionaries is slow under ASan.
|
|
const timeout = (process.env.IS_ASAN ? 200 : 20) * 1000;
|
|
|
|
ifdescribe(features.isBuiltinSpellCheckerEnabled())('spellchecker', { timeout }, () => {
|
|
let w: BrowserWindow;
|
|
|
|
async function rightClick() {
|
|
const contextMenuPromise = once(w.webContents, 'context-menu');
|
|
w.webContents.sendInputEvent({
|
|
type: 'mouseDown',
|
|
button: 'right',
|
|
x: 43,
|
|
y: 42
|
|
});
|
|
return (await contextMenuPromise)[1] as Electron.ContextMenuParams;
|
|
}
|
|
|
|
// When the page is just loaded, the spellchecker might not be ready yet. Since
|
|
// there is no event to know the state of spellchecker, the only reliable way
|
|
// to detect spellchecker is to keep checking with a busy loop.
|
|
async function rightClickUntil(fn: (params: Electron.ContextMenuParams) => boolean) {
|
|
const now = Date.now();
|
|
const timeout = (process.env.IS_ASAN ? 180 : 10) * 1000;
|
|
let contextMenuParams = await rightClick();
|
|
while (!fn(contextMenuParams) && Date.now() - now < timeout) {
|
|
await setTimeout(100);
|
|
contextMenuParams = await rightClick();
|
|
}
|
|
return contextMenuParams;
|
|
}
|
|
|
|
// The misspelled word comes from the asynchronous check that underlines it,
|
|
// but its suggestions are looked up separately when the menu opens, and on
|
|
// macOS that lookup can time out on its own ("NSSpellServer
|
|
// suggestGuessesForWordRange timed out"). Wait for the menu to have both.
|
|
const isMisspelledWithSuggestions = (params: Electron.ContextMenuParams) =>
|
|
params.misspelledWord.length > 0 && params.dictionarySuggestions.length > 0;
|
|
|
|
// Setup a server to download hunspell dictionary.
|
|
const server = http.createServer(async (req, res) => {
|
|
// The provided is minimal dict for testing only, full list of words can
|
|
// be found at src/third_party/hunspell_dictionaries/xx_XX.dic.
|
|
try {
|
|
const data = await fs.readFile(
|
|
path.join(import.meta.dirname, '/../../third_party/hunspell_dictionaries/xx-XX-3-0.bdic')
|
|
);
|
|
res.writeHead(200);
|
|
res.end(data);
|
|
} catch (err) {
|
|
console.error('Failed to read dictionary file');
|
|
res.writeHead(404);
|
|
res.end(JSON.stringify(err));
|
|
}
|
|
});
|
|
let serverUrl: string;
|
|
beforeAll(async () => {
|
|
serverUrl = (await listen(server)).url;
|
|
}, timeout);
|
|
afterAll(() => server.close(), timeout);
|
|
|
|
const fixtures = path.resolve(import.meta.dirname, 'fixtures');
|
|
const preload = path.join(fixtures, 'module', 'preload-electron.js');
|
|
|
|
// On macOS the specs below go through the system spell checker, whose
|
|
// spelling server starts on first use. On a fresh CI machine that took 30 to
|
|
// 60 seconds, and until then AppKit times every request out ("NSSpellServer
|
|
// dataFromCheckingString timed out") and nothing is reported as misspelled.
|
|
// Blink does not ask again about text it has already checked, so the first
|
|
// spec's page never had its misspelling marked. Wait once, here, until the
|
|
// spell checker answers with suggestions for a misspelled word.
|
|
const spellServerStartTimeout = 120 * 1000;
|
|
beforeAll(async () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const warmUpWindow = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: { partition: 'spellchecker-warm-up', contextIsolation: false, preload }
|
|
});
|
|
try {
|
|
await warmUpWindow.loadFile(path.resolve(import.meta.dirname, './fixtures/chromium/spellchecker.html'));
|
|
await waitUntil(
|
|
async () =>
|
|
(await warmUpWindow.webContents.executeJavaScript('electron.webFrame.getWordSuggestions("typograpy")'))
|
|
.length > 0,
|
|
{ rate: 100, timeout: spellServerStartTimeout }
|
|
);
|
|
} finally {
|
|
await closeWindow(warmUpWindow);
|
|
}
|
|
}, spellServerStartTimeout + timeout);
|
|
|
|
const generateSpecs = (description: string, sandbox: boolean) => {
|
|
describe(description, () => {
|
|
beforeEach(async () => {
|
|
w = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: {
|
|
partition: `unique-spell-${Date.now()}`,
|
|
contextIsolation: false,
|
|
preload,
|
|
sandbox
|
|
}
|
|
});
|
|
w.webContents.session.setSpellCheckerDictionaryDownloadURL(serverUrl);
|
|
w.webContents.session.setSpellCheckerLanguages(['en-US']);
|
|
await w.loadFile(path.resolve(import.meta.dirname, './fixtures/chromium/spellchecker.html'));
|
|
}, timeout);
|
|
|
|
afterEach(async () => {
|
|
await closeWindow(w);
|
|
}, timeout);
|
|
|
|
// Context menu test can not run on Windows or Linux (https://github.com/electron/electron/pull/48657 broke linux).
|
|
const shouldRun = process.platform !== 'win32' && process.platform !== 'linux';
|
|
|
|
ifit(shouldRun)('should detect correctly spelled words as correct', async () => {
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").value = "typography"');
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").focus()');
|
|
const contextMenuParams = await rightClickUntil(
|
|
(contextMenuParams) => contextMenuParams.selectionText.length > 0
|
|
);
|
|
expect(contextMenuParams.misspelledWord).toBe('');
|
|
expect(contextMenuParams.dictionarySuggestions).toHaveLength(0);
|
|
});
|
|
|
|
ifit(shouldRun)('should detect incorrectly spelled words as incorrect', async () => {
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").value = "typograpy"');
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").focus()');
|
|
const contextMenuParams = await rightClickUntil(isMisspelledWithSuggestions);
|
|
expect(contextMenuParams.misspelledWord).toBe('typograpy');
|
|
expect(contextMenuParams.dictionarySuggestions.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
ifit(shouldRun)(
|
|
'should detect incorrectly spelled words as incorrect after disabling all languages and re-enabling',
|
|
async () => {
|
|
w.webContents.session.setSpellCheckerLanguages([]);
|
|
await setTimeout(500);
|
|
w.webContents.session.setSpellCheckerLanguages(['en-US']);
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").value = "typograpy"');
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").focus()');
|
|
const contextMenuParams = await rightClickUntil(isMisspelledWithSuggestions);
|
|
expect(contextMenuParams.misspelledWord).toBe('typograpy');
|
|
expect(contextMenuParams.dictionarySuggestions.length).toBeGreaterThanOrEqual(1);
|
|
}
|
|
);
|
|
|
|
ifit(shouldRun)('should expose webFrame spellchecker correctly', async () => {
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").value = "typograpy"');
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").focus()');
|
|
await rightClickUntil(isMisspelledWithSuggestions);
|
|
|
|
const callWebFrameFn = (expr: string) => w.webContents.executeJavaScript(`electron.webFrame.${expr}`);
|
|
|
|
expect(await callWebFrameFn('isWordMisspelled("typography")')).toBe(false);
|
|
expect(await callWebFrameFn('isWordMisspelled("typograpy")')).toBe(true);
|
|
expect(await callWebFrameFn('getWordSuggestions("typography")')).toHaveLength(0);
|
|
expect(await callWebFrameFn('getWordSuggestions("typograpy")')).not.toHaveLength(0);
|
|
});
|
|
|
|
describe('spellCheckerEnabled', () => {
|
|
it('is enabled by default', async () => {
|
|
expect(w.webContents.session.spellCheckerEnabled).toBe(true);
|
|
});
|
|
|
|
ifit(shouldRun)('can be dynamically changed', async () => {
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").value = "typograpy"');
|
|
await w.webContents.executeJavaScript('document.body.querySelector("textarea").focus()');
|
|
await rightClickUntil(isMisspelledWithSuggestions);
|
|
|
|
const callWebFrameFn = (expr: string) => w.webContents.executeJavaScript(`electron.webFrame.${expr}`);
|
|
|
|
w.webContents.session.spellCheckerEnabled = false;
|
|
v8Util.runUntilIdle();
|
|
expect(w.webContents.session.spellCheckerEnabled).toBe(false);
|
|
// spellCheckerEnabled is sent to renderer asynchronously and there is
|
|
// no event notifying when it is finished, so wait a little while to
|
|
// ensure the setting has been changed in renderer.
|
|
await setTimeout(500);
|
|
expect(await callWebFrameFn('isWordMisspelled("typograpy")')).toBe(false);
|
|
|
|
w.webContents.session.spellCheckerEnabled = true;
|
|
v8Util.runUntilIdle();
|
|
expect(w.webContents.session.spellCheckerEnabled).toBe(true);
|
|
await setTimeout(500);
|
|
expect(await callWebFrameFn('isWordMisspelled("typograpy")')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('custom dictionary word list API', () => {
|
|
let ses: Session;
|
|
|
|
beforeEach(async () => {
|
|
// ensure a new session runs on each test run
|
|
ses = session.fromPartition(`persist:customdictionary-test-${Date.now()}`);
|
|
}, timeout);
|
|
|
|
afterEach(async () => {
|
|
if (ses) {
|
|
await ses.clearStorageData();
|
|
ses = null as any;
|
|
}
|
|
}, timeout);
|
|
|
|
describe('ses.listWordsFromSpellCheckerDictionary', () => {
|
|
it('should successfully list words in custom dictionary', async () => {
|
|
const words = ['foo', 'bar', 'baz'];
|
|
const results = words.map((word) => ses.addWordToSpellCheckerDictionary(word));
|
|
expect(results).toEqual([true, true, true]);
|
|
|
|
const wordList = await ses.listWordsInSpellCheckerDictionary();
|
|
expect(wordList).toHaveLength(words.length);
|
|
expect(wordList).toEqual(expect.arrayContaining(words));
|
|
});
|
|
|
|
it('should return an empty array if no words are added', async () => {
|
|
const wordList = await ses.listWordsInSpellCheckerDictionary();
|
|
expect(wordList).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('ses.addWordToSpellCheckerDictionary', () => {
|
|
it('should successfully add word to custom dictionary', async () => {
|
|
const result = ses.addWordToSpellCheckerDictionary('foobar');
|
|
expect(result).toBe(true);
|
|
const wordList = await ses.listWordsInSpellCheckerDictionary();
|
|
expect(wordList).toEqual(['foobar']);
|
|
});
|
|
|
|
it('should fail for an empty string', async () => {
|
|
const result = ses.addWordToSpellCheckerDictionary('');
|
|
expect(result).toBe(false);
|
|
const wordList = await ses.listWordsInSpellCheckerDictionary;
|
|
expect(wordList).toHaveLength(0);
|
|
});
|
|
|
|
// remove API will always return false because we can't add words
|
|
it('should fail for non-persistent sessions', async () => {
|
|
const tempSes = session.fromPartition('temporary');
|
|
const result = tempSes.addWordToSpellCheckerDictionary('foobar');
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ses.setSpellCheckerLanguages', () => {
|
|
const isMac = process.platform === 'darwin';
|
|
|
|
ifit(isMac)('should be a no-op when setSpellCheckerLanguages is called on macOS', () => {
|
|
expect(() => {
|
|
w.webContents.session.setSpellCheckerLanguages(['i-am-a-nonexistent-language']);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
ifit(!isMac)('should throw when a bad language is passed', () => {
|
|
expect(() => {
|
|
w.webContents.session.setSpellCheckerLanguages(['i-am-a-nonexistent-language']);
|
|
}).toThrow(/Invalid language code provided: "i-am-a-nonexistent-language" is not a valid language code/);
|
|
});
|
|
|
|
ifit(!isMac)('should not throw when a recognized language is passed', () => {
|
|
expect(() => {
|
|
w.webContents.session.setSpellCheckerLanguages(['es']);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('SetSpellCheckerDictionaryDownloadURL', () => {
|
|
const isMac = process.platform === 'darwin';
|
|
|
|
ifit(isMac)('should be a no-op when a bad url is passed on macOS', () => {
|
|
expect(() => {
|
|
w.webContents.session.setSpellCheckerDictionaryDownloadURL('i-am-not-a-valid-url');
|
|
}).not.toThrow();
|
|
});
|
|
|
|
ifit(!isMac)('should throw when a bad url is passed', () => {
|
|
expect(() => {
|
|
w.webContents.session.setSpellCheckerDictionaryDownloadURL('i-am-not-a-valid-url');
|
|
}).toThrow(/The URL you provided to setSpellCheckerDictionaryDownloadURL is not a valid URL/);
|
|
});
|
|
});
|
|
|
|
describe('ses.removeWordFromSpellCheckerDictionary', () => {
|
|
it('should successfully remove words to custom dictionary', async () => {
|
|
const result1 = ses.addWordToSpellCheckerDictionary('foobar');
|
|
expect(result1).toBe(true);
|
|
const wordList1 = await ses.listWordsInSpellCheckerDictionary();
|
|
expect(wordList1).toEqual(['foobar']);
|
|
const result2 = ses.removeWordFromSpellCheckerDictionary('foobar');
|
|
expect(result2).toBe(true);
|
|
const wordList2 = await ses.listWordsInSpellCheckerDictionary();
|
|
expect(wordList2).toHaveLength(0);
|
|
});
|
|
|
|
it('should fail for words not in custom dictionary', () => {
|
|
const result2 = ses.removeWordFromSpellCheckerDictionary('foobar');
|
|
expect(result2).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
generateSpecs('without sandbox', false);
|
|
generateSpecs('with sandbox', true);
|
|
});
|